こんにちは。メルペイ 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 を使っています。
このアーキテクチャでは、ViewModel は画面ごとの状態 (State) を管理し、Presentation からの入力を受けて適切な Action へ変換する役割を担当します。一方で Controller は ViewModel から通知される新しい状態を見て、適切な表示へ更新する役割を担当します (各コンポーネントのより詳しい実装はメルペイ Android のアーキテクチャとテストを参照してください)。
次のコード片は、メルペイ Android のアーキテクチャに従った ViewModel、Controller と状態オブジェクトの実装例です。ViewModel での状態管理と、Controller での状態の監視についてくくりだして記述しています。ViewModel の実装には Redux の実装を利用するにあたってボイラープレートとなる部分をまとめた抽象クラスがあります。
SampleController
で構築している画面で取り扱う状態はすべて SampleState
という data class にまとめます。SampleState
の text
プロパティは 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#onSaveInstanceState
や Controller#onRestoreInstanceState
を使って SampleViewModel
がもっている SampleState
の保存や復帰をしていないため、Don’t Keep Activities を有効にした状況で画面の再生成をテストすると、常に SampleState
の text
は通信前の初期状態に戻ってしまいます。あるいは、UI からの入力を State
に保存している場合はその状態が初期状態へと戻ってしまうことも考えられ、アプリのユーザビリティに影響を与えてしまいます。
画面の再生成時に SampleViewModel
がもつ状態を保存し適切に復帰するには、各画面で Controller#onSaveInstanceState
や Controller#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つあります。それぞれ具体的な対応の内容を示したしたのが次のリストです。本記事ではこの順番で解説をしています。
- ViewModel
- Android Architecture Component ViewModel から継承する
- SavedStateHandle を介して State の保存と復帰を実装する
- State
- State を Parcelable にする
- State がもつプロパティもすべて Parcel で扱える型(Parcelable, Serializable, Primitive types, etc)にする
- Controller
- Android Architecture Component ViewModel の Factory を使ったインスタンスの生成を可能にする
- Activity/Fragment の代替として、Controller が ViewModel を扱うために必要なインタフェースを実装する
ライブラリの導入
Android Architecture Component ViewModel と Saved State module を組み合わせて使うには、androidx.lifecycle:lifecycle-viewmodel-ktx
と androidx.lifecycle:lifecycle-viewmodel-savedstate
を導入します。どちらもバージョン 2.2.0 以降のものを使用します(注1)。
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 に対応させるには、次のことを行います。
- Android Architecture Component ViewModel を継承する
- コンストラクタに SavedStateHandle を追加する
- State の型パラメータの制約に Parcelable を追加する
- Store の初期化時、SavedStateHandle から State が読み出せたらそれを初期値とする
- Store の持つ最新の State を保存するメソッドを追加する
Android Architecture Component ViewModel に従った ViewModel の実装
はじめに、Android Architecture Component ViewModel が提供する抽象クラスの ViewModel
を継承し、コンストラクタの引数に SavedStateHandle
を追加します。混乱を避けるため、次のコードではこれまでの ViewModel
を StateSavingViewModel
へと名前を変更し、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
からのメソッド呼び出しのタイミングで SavedStateHandle
に State
のインスタンスを保存します。
復帰は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"
}
}
最後に、State
を SavedStateHandle
で扱えるようにするために、State
が Parcelable
を実装することを型パラメータの制約として宣言します。
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 の組み合わせが実装できたら、SampleViewModel
を StateSavingViewModel
を継承するよう書き換えます。また、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 で取り扱う State
を Parcelable
にします。
SampleState
には次のように RemoteData
型の text
プロパティがあります。SampleState
を Parcelable
にした場合、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通りの方法があります。
- Parcelize の Parceler を使って、ライブラリの変更なしで
Parcelable
の実装をアドオンする - ライブラリに変更を加え、
RemoteData
がParcelable
を実装する
メルペイ Android では、RemoteData
の型パラメータに様々な API レスポンスの型を指定しています。このため、1 の Parceler を使う場合、すべての API レスポンスの型について Parceler のロジックを実装する必要があります。一方で 2 のライブラリに変更を加え RemoteData
が Parcelable
を実装する場合、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>()
}
この RemoteData
を Parcelable
にするにはいくつか考慮すべき点があります。
- 既存の JVM 向けの実装に Android Framework に依存する実装を持ち込まないようにしたい
- RemoteData.Success の型パラメータには任意の型が指定できるようにしたい
- 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.Success
を Parcelable
にしたあともこれらのコードをコンパイル可能にするため、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
には readValue
と writeValue
という任意の型でオブジェクトを読み書きするメソッドがあります。実際には読み書きするオブジェクトの型が Parcelable の要件に従っている必要があり、その要件を満たさないオブジェクトを渡すと実行時例外となります。コンパイル時の型チェックという利点を捨てることにはなりますが、既存の RemoteData の動きに合わせるためこれらのメソッドを使用することとしました。
ちなみに readValue
にわたすクラスローダーにブートストラップクラスローダーが使えないことに注意してください。これは型パラメータに自分たちで定義した型を指定した場合、ブートストラップクラスローダはその型のことを知らないためにロードできず例外となることによります(注2)。
@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.Failure
を Parcelable
にするにあたっては例外をそのまま使わず、エラーを表現する別の 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 に対応するには、さらに Controller
が ViewModelStoreOwner
, HasDefaultViewModelProviderFactory
, SavedStateRegistryOwner
を実装する必要があります。
それぞれのインタフェースを実装したとき Controller
ですべきことを次に列挙します。
ViewModelStoreOwner
ViewModelStore
を保持するController#onDestroy
でViewModelStore#clear
を呼び、ViewModel#onCleared
が呼ばれるようにするgetViewModelStore
を実装し、保持しているViewModelStore
を返す
HasDefaultViewModelProviderFactory
getDefaultViewModelProviderFactory
を実装し、SavedStateViewModelFactory
を返す
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 でも実現可能になっています。
今回の取り組みは、新機能開発や既存機能の改修のなかで同時に実装できるよう、これまでの実装には手を加えない形ですすめました。すでにリリースもできており、今後の開発の中で標準となるよう浸透をすすめていきます。