メルペイ Android のアーキテクチャとライフサイクル

こんにちは。メルペイ Android チームの @KeithYokoma です。この記事は、Merpay Tech Openness Month 2020 の7日目の記事です。

Merpay Advent Calendar 2019 では メルペイ Android のアーキテクチャとテスト と題して、1つの画面を構築するときのアーキテクチャを紹介し、それぞれのテストの方針を解説しました。今回の記事では、Presentation と ViewModel に焦点を当て、Android Architecture Component を使いライフサイクル管理の改善をしたことについて記述します。

Controller と ViewModel

メルペイ Android のアーキテクチャとテストの記事で解説したとおり、メルペイ Android では次のような構成でひとつの画面を設計しています。Presentation に相当する部分には bluelinelabs/Conductor の Controller を使っています。

Redux の考え方と MVVM を組み合わせた構成
Redux の考え方と MVVM を組み合わせた構成

このアーキテクチャでは、ViewModel は画面ごとの状態 (State) を管理し、Presentation からの入力を受けて適切な Action へ変換する役割を担当します。一方で Controller は ViewModel から通知される新しい状態を見て、適切な表示へ更新する役割を担当します (各コンポーネントのより詳しい実装はメルペイ Android のアーキテクチャとテストを参照してください)。

次のコード片は、メルペイ Android のアーキテクチャに従った ViewModel、Controller と状態オブジェクトの実装例です。ViewModel での状態管理と、Controller での状態の監視についてくくりだして記述しています。ViewModel の実装には Redux の実装を利用するにあたってボイラープレートとなる部分をまとめた抽象クラスがあります。

SampleController で構築している画面で取り扱う状態はすべて SampleState という data class にまとめます。SampleStatetext プロパティは API 通信の状態を表現する RemoteData 型のプロパティで、通信前、通信中、成功、失敗のいずれかを表します。

// ==== SampleState.kt
import com.mercari.remotedata.RemoteData
import com.mercari.rxredux.State

data class SampleState(
  val text: RemoteData<String, Exception> = RemoteData.Initial
) : State

// ==== ViewModel.kt
import com.mercari.rxredux.Action
import com.mercari.rxredux.Reducer
import com.mercari.rxredux.State
import com.mercari.rxredux.Store

// Parent of all ViewModels
abstract class ViewModel<S : State, A : Action>(
  initialState: S,
  reducer: Reducer<S, A>
) {
  private val store = Store(
    initialState = initialState,
    reducer = reducer
  )

  val states: Observable<S> = store.states

  // ...
}

// ==== SampleViewModel.kt
class SampleViewModel(
  private val sampleDataSource: SampleDataSource
) : ViewModel<SampleState, SampleAction>(
  initialState = SampleState(),
  reducer = SampleReducer()
) {
  // convert inputs from Controller to Actions...
}

// ==== SampleController.kt
import com.bluelinelabs.conductor.Controller;

class SampleController : Controller() {
  private val viewModel = SampleViewModel(SampleDataSourceImpl())
  private val disposables = CompositeDisposable()

  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup): View =
    inflater.inflate(R.layout.controller_sample, container, falsee).also {
      disposables += viewModel.states
        .subscribe { state: SampleState ->
          // update views for a new state value...
        }
    }

  // clear disposables, finish view model lifecycle at the end, etc ...
}

ライフサイクル管理の課題

Android の画面にはライフサイクルがあり、画面の再生成に際して ViewModel が持つ状態を保存・復帰することも ViewModel の役割に含まれます。

先ほどの実装例では、画面の再生成時に Controller#onSaveInstanceStateController#onRestoreInstanceState を使って SampleViewModel がもっている SampleState の保存や復帰をしていないため、Don’t Keep Activities を有効にした状況で画面の再生成をテストすると、常に SampleStatetext は通信前の初期状態に戻ってしまいます。あるいは、UI からの入力を State に保存している場合はその状態が初期状態へと戻ってしまうことも考えられ、アプリのユーザビリティに影響を与えてしまいます。

画面の再生成時に SampleViewModel がもつ状態を保存し適切に復帰するには、各画面で Controller#onSaveInstanceStateController#onRestoreInstanceState を使う方法と、Android Architecture Component ViewModel を導入し、Saved State module と組み合わせる方法があります。幸い、メルペイ Android のアーキテクチャでは Android Architecture Component と親和性が高く、すべての画面で共通の仕組みを使って状態の保存と復帰が実装できることから、Android Architecture Component ViewModel と Saved State module の導入をすることにしました。

最近は Android 端末の新しい形態としてフォルダブル端末が登場し、ライフサイクル管理はほぼ必須のものとなっています。フォルダブル端末における画面の折りたたみや展開によるライフサイクルの動きは、画面回転のように無効化できません。もともと画面回転以外にも様々な端末の操作をきっかけにライフサイクルが動き画面の再生成が起きていたので、状態の保存と復帰を適切に取り扱うことはとても重要でしたが、フォルダブル端末の登場によってよりその重要性が増しています。

Saved State module for ViewModel を使うための準備

メルペイ Android のアーキテクチャのなかで、Android Architecture Component ViewModel と Saved State module への対応が必要なものはViewModel、State、Controllerの3つあります。それぞれ具体的な対応の内容を示したしたのが次のリストです。本記事ではこの順番で解説をしています。

  1. ViewModel
    • Android Architecture Component ViewModel から継承する
    • SavedStateHandle を介して State の保存と復帰を実装する
  2. State
    • State を Parcelable にする
    • State がもつプロパティもすべて Parcel で扱える型(Parcelable, Serializable, Primitive types, etc)にする
  3. Controller
    • Android Architecture Component ViewModel の Factory を使ったインスタンスの生成を可能にする
    • Activity/Fragment の代替として、Controller が ViewModel を扱うために必要なインタフェースを実装する

ライブラリの導入

Android Architecture Component ViewModel と Saved State module を組み合わせて使うには、androidx.lifecycle:lifecycle-viewmodel-ktxandroidx.lifecycle:lifecycle-viewmodel-savedstate を導入します。どちらもバージョン 2.2.0 以降のものを使用します(注1)。

注1:執筆時点での最新安定版は2.2.0です。このバージョンは推移的に appcompat 1.1.0 を利用しますが、appcompat 1.1.0 には WebView で長押し時にクラッシュしたり、端末のアクセシビリティの設定が反映されないなどのバグがあるため、appcompat1.2.0 以降を使用するよう別途依存関係の定義が必要です。
dependencies {
  implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0")
  implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0")
}

Android Architecture Component ViewModel と Saved State module を使う

先ほどの例をもとに、Android Architecture Component ViewModel を継承した ViewModel を作成します。

メルペイ Android には、すべての ViewModel がもつ RxReduxK の仕組みを内包した、共通の ViewModel クラスがあります。この ViewModel クラスは Android Architecture Component ViewModel とは関係がなく、LifecycleObserver の定義にしたがって Controller のライフサイクルに対応するメソッドを記述しています。

また ViewModel はコンストラクタの引数として State の初期値をとります。この初期値は直接 Store にわたしています。初期値のインスタンスは ViewModel の子クラスで作っています。

abstract class ViewModel<S : State, A : Action>(
  initialState: S,
  reducer: Reducer<S, A>
) : LifecycleObserver {
  private val store = Store(
    initialState = initialState,
    reducer = reducer
  )

  val states: Observable<S> = store.states

  @CallSuper
  @OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
  open fun onCreate() {
    // ...
  }

  @CallSuper
  @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
  override fun onDestroy() {
    // ...
  }
}

この ViewModel を Android Architecture Component ViewModel と Saved State module に対応させるには、次のことを行います。

  1. Android Architecture Component ViewModel を継承する
  2. コンストラクタに SavedStateHandle を追加する
  3. State の型パラメータの制約に Parcelable を追加する
  4. Store の初期化時、SavedStateHandle から State が読み出せたらそれを初期値とする
  5. Store の持つ最新の State を保存するメソッドを追加する

Android Architecture Component ViewModel に従った ViewModel の実装

はじめに、Android Architecture Component ViewModel が提供する抽象クラスの ViewModel を継承し、コンストラクタの引数に SavedStateHandle を追加します。混乱を避けるため、次のコードではこれまでの ViewModelStateSavingViewModel へと名前を変更し、Android Architecture Component ViewModel と区別しやすくしています。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.SavedStateHandle

abstract class StateSavingViewModel<S : State, A : Action>(
  initialState: S,
  reducer: Reducer<S, A>,
  private val handle: SavedStateHandle // SavedStateHandle をコンストラクタでうけとる
) : ViewModel() { // Android Architecture Component ViewModel を継承
  private val store = Store(
    initialState = initialState,
    reducer = reducer
  )
}

SavedStateHandle を利用した State の保存と復帰の実装は次のコードで示しています。

保存は Controller からのメソッド呼び出しのタイミングで SavedStateHandleState のインスタンスを保存します。

復帰はSavedStateHandle から State のインスタンスを取り出し、null ならコンストラクタで受け取った State のインスタンスを、null でなければ取り出したインスタンスを Store にわたします。

abstract class StateSavingViewModel<S : State, A : Action>(
  initialState: S,
  reducer: Reducer<S, A>,
  private val handle: SavedStateHandle
) : ViewModel() {
  private val store = Store(
    initialState = savedStateHandle.get<S>(KEY_SAVED_STATE) ?: initialState, // SavedStateHandle から状態の復帰を試みる
    reducer = reducer
  )

  fun onSaveState() {
    // RxJava の API を使って最新の状態を Store から取り出し、SavedStateHandle に保存する
    savedStateHandle[KEY_SAVED_STATE] = store.states
      .take(1)
      .blockingFirst()
  }

  companion object {
    private const val KEY_SAVED_STATE = "saved_state"
  }
}

最後に、StateSavedStateHandle で扱えるようにするために、StateParcelable を実装することを型パラメータの制約として宣言します。

abstract class StateSavingViewModel<S, A : Action>(
  initialState: S,
  reducer: Reducer<S, A>,
  private val handle: SavedStateHandle
) : ViewModel() where S : State, S : Parcelable // S の制約として Parcelable を上限に追加

Android Architecture Component ViewModel と Saved State module の組み合わせが実装できたら、SampleViewModelStateSavingViewModel を継承するよう書き換えます。また、Controller での ViewModel の生成に必要な Factory クラスも合わせて定義します。

class SampleViewModel<SampleState, SampleAction>(
  private val sampleDataSource: SampleDataSource
  savedStateHandle: SavedStateHandle
) : StateSavingViewModel( // StateSavingViewModel を継承
  initialState = SampleState(),
  reducer = SampleReducer(),
  savedStateHandle = savedStateHandle
) {

  // ViewModel のインスタンスを生成する Factory
  class Factory(
    owner: SavedStateRegistryOwner,
    private val sampleDataSource: SampleDataSource
  ) : AbstractSavedStateViewModelFactory(owner, null) {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T = SampleViewModel(
      sampleDataSource = sampleDataSource,
      savedStateHandle = handle,
    ) as T
  }
}

State を Parcelable にする

ここまでで ViewModel の準備が整いました。次に ViewModel で取り扱う StateParcelable にします。

SampleState には次のように RemoteData 型の text プロパティがあります。SampleStateParcelable にした場合、RemoteData 型もまた Parcelable である必要があります。

import com.mercari.remotedata.RemoteData
import com.mercari.rxredux.State

data class SampleState(
  val text: RemoteData<String, Exception> = RemoteData.Initial
) : State

RemoteData はライブラリで定義された型です。これを Parcelable とするには、次の2通りの方法があります。

  1. Parcelize の Parceler を使って、ライブラリの変更なしで Parcelable の実装をアドオンする
  2. ライブラリに変更を加え、RemoteDataParcelable を実装する

メルペイ Android では、RemoteData の型パラメータに様々な API レスポンスの型を指定しています。このため、1 の Parceler を使う場合、すべての API レスポンスの型について Parceler のロジックを実装する必要があります。一方で 2 のライブラリに変更を加え RemoteDataParcelable を実装する場合、API レスポンスの型を Parcel で扱える型にしておく必要があります。

メルペイ Android では、複数の API を呼びだしその結果の組み合わせで画面を構成する場面が多々あります。そのため ViewModel のレイヤーで API レスポンスの型を直接扱わず、画面の構成に最適なデータ構造を定義し、API レスポンスから変換して使うことを推奨しており、このデータ構造を Parcelable としておくほうが実装がシンプルになります。

よって本記事ではライブラリに変更を加えるパターンでの実装について解説します。

RemoteData を Parcelable にする

RemoteData はオープンソースプロジェクトとして Mercari, Inc. で公開しているライブラリで、次に示すような構造を持っています。


sealed class RemoteData<out V : Any, out E : Exception> {

  object Initial : RemoteData<Nothing, Nothing>()

  class Loading<V : Any> @JvmOverloads constructor(
    progress: Int? = null,
    val totalUnits: Int = 100
  ) : RemoteData<V, Nothing>()

  class Success<out V : Any>(val value: V) : RemoteData<V, Nothing>()

  class Failure<out E : Exception>(val error: E) : RemoteData<Nothing, E>()
}

この RemoteDataParcelable にするにはいくつか考慮すべき点があります。

  1. 既存の JVM 向けの実装に Android Framework に依存する実装を持ち込まないようにしたい
  2. RemoteData.Success の型パラメータには任意の型が指定できるようにしたい
  3. RemoteData.Failure の型パラメータにはエラーを表現する Parcelable な型を指定できるようにしたい

1. 既存の JVM 向けの実装に Android Framework に依存する実装を持ち込まないようにしたい

RemoteData は JVM 向けにリリースしている、オープンソースのライブラリです。Parcelable はAndroid Framework のインタフェースであり、Android Runtime 以外の JVM にとっては未知のものです。既存の RemoteData の実装に Parcelable の実装を追加してしまうと、以後 Android でしか RemoteData が使えなくなってしまいます。そこで Android 用のモジュール を作成し、Parcelable を実装した別バージョンの RemoteData 型を定義しました。

2. RemoteData.Success の型パラメータには任意の型が指定できるようにしたい

既存の RemoteData では、次にあげる例はすべて正しくコンパイルできます。

val intRemoteData: RemoteData<Int, Exception>
val stringRemoteData: RemoteData<String, Exception>
val enumRemoteData: RemoteData<Hoge, Exception>
val parcelableRemoteData : RemoteData<Fuga, Exception>

enum class Hoge { /* constants */ }
data class Fuga(/* properties */) : Parcelable { /* Parcelable implementations */ }

RemoteData.SuccessParcelable にしたあともこれらのコードをコンパイル可能にするため、RemoteData.Success の型パラメータそのものに制約を設けることができません。次のようにしてしまうと、プリミティブ型や java.io.Serializable を実装した型が指定できなくなってしまいます。

// NG: 型パラメータ V に制約をもたせると Parcelable を実装した型以外が扱えなくなる
sealed class RemoteData<out V : Parcelable, out E : Exception>, Parcelable {
  class Success<out V : Parcelable>(val value: V) : RemoteData<V, Nothing>()
}

Parcel には readValuewriteValue という任意の型でオブジェクトを読み書きするメソッドがあります。実際には読み書きするオブジェクトの型が Parcelable の要件に従っている必要があり、その要件を満たさないオブジェクトを渡すと実行時例外となります。コンパイル時の型チェックという利点を捨てることにはなりますが、既存の RemoteData の動きに合わせるためこれらのメソッドを使用することとしました。

ちなみに readValue にわたすクラスローダーにブートストラップクラスローダーが使えないことに注意してください。これは型パラメータに自分たちで定義した型を指定した場合、ブートストラップクラスローダはその型のことを知らないためにロードできず例外となることによります(注2)。

注2:Parcelize の @RawValue アノテーションを使って Parcel に読み書きするコードを自動生成するとき、readValue メソッドに渡すクラスローダーがブートストラップクラスローダーになる場合があります。

3. RemoteData.Failure の型パラメータにはエラーを表現する Parcelable な型を指定できるようにしたい

メルペイ Android では API 通信に失敗したパターンを RemoteData.Failure で表現しています。このとき、失敗した理由や HTTP Status Code などはすべて RemoteData.Failure が保持する例外オブジェクトから読み出しています。

Parcel には例外オブジェクトを読み書きするメソッド ([readException](https://developer.android.com/reference/android/os/Parcel#readException()) と writeException)があります。しかしドキュメントに書かれている通り、書き込める例外の型に制限があったり、例外を読み出したタイミングでその例外がスローされたりと、API レスポンスがエラーである場合にそのエラーの詳細を読み出すユースケースとは合わない挙動となってしまいます。

このため、RemoteData.FailureParcelable にするにあたっては例外をそのまま使わず、エラーを表現する別の Parcelable なインタフェース (ErrorKind) を定義して、適宜例外からこのインタフェースを実装した型へ変換することとしました。

Android Architecture Component ViewModel に従った Controller の実装

最後に Controller でうまく Android Architecture Component ViewModel を取り扱う準備をします。

bluelinelabs/Conductor の Issue Tracker で議論されているとおり、まだ Controller は Android Architecture Component ViewModel の対応が完全には終わっていません。本記事執筆時点で対応ができているのは LifecycleOwner の実装のみです。

Android Architecture Component ViewModel と Saved State module に対応するには、さらに ControllerViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner を実装する必要があります。

それぞれのインタフェースを実装したとき Controller ですべきことを次に列挙します。

  1. ViewModelStoreOwner
    • ViewModelStore を保持する
    • Controller#onDestroyViewModelStore#clear を呼び、ViewModel#onCleared が呼ばれるようにする
    • getViewModelStore を実装し、保持している ViewModelStore を返す
  2. HasDefaultViewModelProviderFactory
    • getDefaultViewModelProviderFactory を実装し、SavedStateViewModelFactory を返す
  3. SavedStateRegistryOwner
    • ライフサイクルのコールバックに対応した SavedStateRegistryController のメソッド (performRestore, performSave) を呼ぶ
    • getSavedStateRegistry を実装し、SavedStateRegistryController から SavedStateRegistry を取り出し返す

これらをまとめたコード例を次に示します。Conductor ライブラリ内で LifeCycleOwner を実装した LifecycleController をベースに必要な実装を追加しています。

Controller の API で Fragment と大きく異なる点として、Saved State を保持する Bundle を受け取れるのが onRestoreInstanceState または onRestoreViewState のいずれかのみである点があります。これらのメソッドは以前に保存した Bundle がない場合には呼ばれないため、Controller のインスタンスが初めて生成されたケースで savedStateRegistryController.performRestore を正しく呼ぶ処理を別のコールバックメソッドで実現する必要があります。このメソッドを正しく呼ばないと、SavedStateRegistry の内部状態が不整合となり、ViewModel で SavedStateHandle の値を参照しようとしたときに例外がスローされます。

import androidx.lifecycle.HasDefaultViewModelProviderFactory
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.SavedStateViewModelFactory
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import com.bluelinelabs.conductor.archlifecycle.LifecycleController

abstract class SavedStateViewModelController(args: Bundle? = null) : LifecycleController(args),
  ViewModelStoreOwner,
  HasDefaultViewModelProviderFactory,
  SavedStateRegistryOwner {

  private var viewModelStore = ViewModelStore()
  private val savedStateRegistryController = SavedStateRegistryController.create(this)
  private var restored = false

  override fun onContextAvailable(context: Context) {
    super.onContextAvailable(context)
    if (lifecycle.currentState == Lifecycle.State.INITIALIZED && restored.not()) {
      // Controller の初回生成時に savedStateRegistryController.performRestore を呼び、SavedStateRegistry の内部状態の整合性をとる
      savedStateRegistryController.performRestore(null)
    }
  }

  override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    savedStateRegistryController.performSave(outState)
  }

  override fun onRestoreInstanceState(savedInstanceState: Bundle) {
    super.onRestoreInstanceState(savedInstanceState)
    savedStateRegistryController.performRestore(savedInstanceState)
    restored = true
  }

  override fun onDestroy() {
    super.onDestroy()
    viewModelStore.clear()
  }

  override fun getViewModelStore() = viewModelStore

  override fun getSavedStateRegistry() = savedStateRegistryController.savedStateRegistry

  override fun getDefaultViewModelProviderFactory() = SavedStateViewModelFactory(
    activity!!.application,
    this
  )
}

この実装に合わせて、Fragment KTX に含まれる viewModels 拡張関数SavedStateViewModelController 用に作っておくと、Fragment で ViewModel のインスタンスを生成する方法と同じ方法が Controller でも使えるようになります。

最終的に、上記の SavedStateViewModelController を利用した SampleController は次のように書き換えることとなります。

class SampleController : SavedStateViewModelController() {
  private val viewModel by viewModels { // 直接初期化せず、viewModesl 拡張関数と Factory を介してインスタンスを作る
    SampleViewModel.Factory(this, SampleDataSourceImpl())
  }
}

まとめ

本記事では、Android Architecture Component ViewModel への対応と Saved State module の組み込みの一連の流れを解説しました。

メルペイ Android では画面で取り扱う状態を State として一つのオブジェクトで管理していることと、ViewModel 内部で Redux の仕組みを共通で持っていることから、どのモジュール、どのパッケージでも同じ方法で状態の保存と復帰が実現できました。画面の構築に Controller を利用している点が Activity や Fragment を利用している場合と少し異なりますが、役割としては同じです。Android Architecture Component では ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner を公開しており、これらを実装している Fragment と同じことを Controller でも実現可能になっています。

今回の取り組みは、新機能開発や既存機能の改修のなかで同時に実装できるよう、これまでの実装には手を加えない形ですすめました。すでにリリースもできており、今後の開発の中で標準となるよう浸透をすすめていきます。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加