Jetpack Composeの導入は、アーキテクチャについて再検討する良い機会でしょう。
GoogleはAndroid Architecture Components(AAC)のViewModelとJetpack Composeを結合する方法を解説しており、今まで通りのViewModelが利用できるとしています。一方でTwitter等ではViewModelは不要になるのでは?といった議論もされてきました。
結論から言うと、Jetpack Composeの導入によってViewModelの形や名称は変化する可能性はあるが、関心の分離の観点からUIとロジックの分離は依然として重要であり、今後もViewModelに相当するものは無くならないでしょう。
今回はJetpack Comopseを使う上で、ViewModelをどのように扱うのが良いのか、どのように変化する可能性があるのか、いくつかの考察を行ってみたいと思います。
まずは、既存のViewModelをできるだけそのまま使った場合に、注意すべき点について考えます。
公式のドキュメントには、以下のように言及されています。参照
ViewModel は、Compose UI ツリーの上位レベルのコンポーザブル、または Navigation ライブラリ内のデスティネーションであるコンポーザブルの状態ホルダーとしておすすめです。
また、同時に提示されている以下のサンプルコードからも、多くのことを学ぶことが出来ます。
class HelloViewModel : ViewModel() {
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
fun onNameChange(newName: String) {
_name.value = newName
}
}
// 画面単位といった大きな粒度でViewModelと結合する
@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
val name: String by helloViewModel.name.observeAsState("")
HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}
// 子Composableには純粋なパラメータとコールバックでやりとりをする
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") }
)
}
}
アーキテクチャは行うべきことに注目しがちですが、行ってはいけないこともそれ以上に重要です。
ここで注目すべきは以下の2点ではないでしょうか
コンポーザブルは、必要なものにのみ渡す ことが重要です。
これにより子Composableの依存関係が明確になり、また再利用性、テスト容易性が高まります。
// 以下は行わない
@Composable
fun HelloContent(helloViewModel: HelloViewModel) {
/* ... */
}
CompositionLocalは子Composableに暗黙的にデータを伝えることが出来ます。
これにより、バケツリレーが解消される一方、データが追いにくくなるというデメリットもあります。
公式によるCompositionLocal を使用すべきかどうかの判断によると、本来ViewModelを必要としていない子ComposableからもViewModelのインスタンスが参照できてしまうことに触れ、CompositionLocal
でViewModelを保持すべきでないと明言されています。
上記をまとめると、Jetpack ComposeとViewModelは疎であるべきであり、大部分でViewModelを意識しない形にすることが理想でしょう。
AACのViewModelが誕生した背景から、今後のViewModelのあり方について考えてみます。
ViewModelの大きな役割として、 画面の回転などの構成の変更後にデータを引き継ぐことができる というものがあります。
そもそも、画面回転などの構成変更時にActivityが再生成されているのは、xmlでの命令的UIに起因します。縦横のレイアウトで大きくUIが異なる場合、命令的にUIを更新していくより、一度全てを破棄して再生成するほうが考慮すべきことが減り、容易だったのです。
Jetpack Composeによる宣言的UI導入後はどうでしょう? 縦横でレイアウトが異なる場合でも、差分があるComposableのみを再Composeするだけで問題ありません。むしろ、全てを一度破棄するのは効率が悪いと言えます。
以上のことから、Jetpack Composeのみで構成されたアプリケーションにおいて、画面回転時にActivityを破棄させないという選択肢は、大いに検討する価値があります。同時に、AACのViewModelの存在意義は多少減るでしょう。
画面回転時に状態を引き継ぐ必要がなければ、状況によっては通常のクラスで十分に役割を果たすことが出来ます。
class HelloViewModel {
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
fun onNameChange(newName: String) {
_name.value = newName
}
}
@Composable
fun HelloScreen() {
val helloViewModel = remember { HelloViewModel() }
val name: String by helloViewModel.name.observeAsState("")
HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}
AACのViewModelは基本的にActivity、Fragment、NavGraphのScopeでのみで扱え、制限されています。独自クラスであれば、特定のComposableのScopeで扱うなど、もっと柔軟に操作することが出来ます。
一方で、CoroutinesScopeや画面遷移時にViewModelを保持する仕組み等、必要な機能は自前で実装しなければならなくなります。今後、Jetpack Libraryに機能追加があった場合に、追従が難しいかもしれません。画面回転時にActivityを破棄しなかったとしても、AAC ViewModelを使い続けることは選択肢の1つでしょう。
Jetpack Composeはマルチプラットフォーム対応を進めており、現在ではDesktop、Webブラウザに対応しています。また、Kotlin Multiplatform Mobile(KMM)を用いて、iOS/AndroidでViewModelを共通化させることも期待され始めています。
AACのViewModelはマルチプラットフォームに対応されておらず、そのまま流用させることはできません。
まず解決策として考えられるのは、Interfaceを切って実装を分ける方法でしょう。委譲を使うのも良いと思います。LiveData
も同様にマルチプラットフォーム対応されていないため、ここではStateFlow
を使っています。
interface SampleViewModel {
val name: StateFlow<String>
fun onNameChange(newName: String)
}
// for multiplatform
class DefaultSampleViewModel: SampleViewModel {
private val _name = MutableStateFlow("")
overide val name: StateFlow<String> = _name
override fun onNameChange(newName: String) {
_name.value = newName
}
}
// for android
class AndroidSampleViewModel(
default: DefaultSampleViewModel
): ViewModel(), SampleViewModel by default
また、先程解説したように、画面回転時等のActivity破棄を行わず、AAC ViewModelを使わないことも、共通化する上では利便性が高いと言えそうです。
一方で、マルチプラットフォーム対応を予定していないのにも関わらず、これを理由にAACのViewModelを使わないとするのはやりすぎだと感じています。マルチプラットフォーム対応するためには他にも多くの制約があり、実際には行わないのにも関わらず、それを守ることにはコストが高すぎると感じています。
ここまではクラスを使ってロジックを表現することを説明してきましたが、最後にComposableにロジックを書く方法について紹介します。
Jetpack Composeが多くの影響を受けているReactでは、ロジックを独自フックとして切り出す方法が紹介されています。
これをJetpack Composeで当てはめると、以下のようになるでしょう。
data class SampleState(
val name: String,
val onNameChange: (newName: String) -> Unit
)
@Composable
fun useSampleState(): SampleState {
var name: String by remember { mutableStateOf("") }
return remember(name) {
SampleState(
name = name,
onNameChange = { name = it }
)
}
}
LaunchedEffect 等を使うことで、非同期処理も扱うことが出来ます。
@Composable
fun useSampleState(): SampleState {
var name: String by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
name = Api.fetch()
}
return remember(name) {
SampleState(
name = name,
onNameChange = { name = it }
)
}
}
Classではなく1メソッドで扱うことで、半ば宣言的にロジックを記述することが出来ます。reducer
のような形をとるほうが、より効果を発揮するでしょう。また、Jetpack ComposeのState
を使うことで、UI更新を自然に行うことが出来ます。
ここでも重要なことは関心の分離です。ロジックをComposableで表現する場合、UIのコンポーネントを同じComposableで扱わないようにする必要があります。
一方で、コアロジックをあまりJetpack Composeに依存させないほうが良いと感じています。今後新たなUIフレームワークが出現しないとは限らないですし、可能な限りプレーンな状態のKotlinを使うことで、より寿命の長いコードを実現することが出来るでしょう。
いくつかの観点からJetpack ComposeとViewModelの形について確認を行いました。
確かにAAC ViewModelが必要なくなるケースも増えてきそうですが、今後使い続けることも選択肢の1つでしょう。個人的には、ロジックも含めて再利用可能なコンポーネントを実現するためには、AACのViewModelは少し使いにくいと感じています。
Jetpack Composeを使ったアーキテクチャの検討は、まだ始まったばかりです。FluxやMVIといったアーキテクチャもJetpack Composeと相性が良さそうです。今は過渡期だと思うので、柔軟に検討を行い、プロダクトの性質や状態に合わせた、より良いアーキテクチャ選定が行われることを祈っています。