はじめに
最近、Androidアプリ開発においてJetpack Composeを使用する機会が増えたことを嬉しく感じています。
この記事では、Jetpack Compose で、 ViewModelを使わないで に Composable関数(Factory Function) を使って、状態とロジック をViewの外に切り出す方法 について、説明しています。
Composable関数(Factory Function) を使えば 「状態やロジック」 を簡単にViewの外に切り出すことができます。
つまり、 「ロジックの抽出と再利用」 ができるようになるということです。
ロジックの抽出と再利用について
「ロジックの抽出と再利用」 は、なぜするのか。
「ロジックの抽出と再利用」が必要な理由は、
複雑に肥大化したコンポーネント を、小分けにして 関心事で分別 し、クリーンな状態に整理するためです。
近年、宣言的UI を使用して大規模なプロジェクトを構築することが増えました。
肥大化してメンテしにくいコンポーネントを目の当たりにして、我々エンジニアが苦しむことも増えました。
肥大化したコンポーネントのコードは、依存関係が複雑で量も多く、コードを読むこと自体難しくなります。
そこで、コンポーネントの中から「状態とロジック」を外に追い出して、キレイに整理することが必要になります。
ViewModelを使って状態とロジックを切り出すこともできますが、この記事ではViewModelを使わないで Composable関数として切り出す方法について取り上げます。
Composable関数とは
Jetpack Compose を使いこなすには、 Composable関数 について深く理解することが、最も重要 です。
Composable関数とは、「コンポーズ可能な関数のこと」です。
Composable関数には、@Composable
アノテーションが付けられています。
(※↑はUIを定義するComposable関数)
「コンポーズ可能」 とは、「Jetpack Composeの仕組み上、その関数は再Composeされたときに並行 に 何度も 呼び出されたり 順番があべこべ になったり スキップ したり キャンセル するときがあるけど 大丈夫か? 」ということです。
コンポーズ可能な関数について知っておくべき5つのこと
公式ドキュメントには、コンポーズ可能な関数について知っておくべきこととして、以下の5つ が挙げられています。
つまり、 この仕様に耐えられる関数が「コンポーズ可能な関数」 ということです。
- コンポーズ可能な関数は 任意の順序で実行できる。
- コンポーズ可能な関数は 並行して実行できる。
- 再コンポーズは、可能な限り多くのコンポーズ可能な関数とラムダを スキップする。
- 再コンポーズは厳密なものではなく、 キャンセル される場合がある。
- コンポーズ可能な関数は、アニメーションのフレームごとに 何度も実行される場合がある。
コンポーズ可能な関数は、高速かつ冪等で副作用のないものにすることが重要です。
Composable関数にViewModelを渡すのは止めよう
補足の注意点として、 Composable関数にViewModelを渡す実装 は、危険 です。
ViewModelは、Composeの世界の外 で生成されるもので、Composeとはライフサイクルが異なります。
ViewModelをComposable関数に渡した場合、再Composeの処理に対応していないため、さまざまな不具合を引き起こす可能性があります。
つまり、ViewModelは再Composeに耐えることができません。
公式ドキュメントでも、注意を促しています。
Composable関数でUIを定義して、そのComposable関数にViewModelを渡す実装はやめたほうがよいです。
2種類のComposable関数
Composable関数には、以下の2種類があります。
-
戻り値のない(Unitを返す)Composable関数(UI Function)
- UIをCompose可能な関数として定義するために使われる
- 名前は、大文字で始める
-
オブジェクトを返すComposable関数(Factory Function)
- 状態やロジックや、それらをまとめたオブジェクトをCompose可能な関数として定義するのに使われる
- 名前は、小文字で始める(rememberを使ってオブジェクトを返す場合は、頭にremember をつけるのが公式の推奨)
- オブジェクトを返すComposable関数のことを、APIガイドラインでは、Factory関数と呼んでいる。
詳細は、APIガイドラインを参照してください。
Jetpack Composeにおいて、Composable関数は 「UIをCompose可能な関数として定義すること」"だけ" に使用されるものかと思っている人も多いかと思いますが、 「状態とロジックを返すCompose可能な関数として定義すること」にも使用できます。
つまり、React Hooksや、VueのComposition APIのようなことが可能 なのです。
(Reactで言う、useHogeHoge みたいなカスタムフックが定義できるということです)
rememberもComposable関数
例として、Jetpack Composeで使用される、「remember」関数の中身は、Compose可能な関数として定義されています。
@Composable
が付いている箇所が「Composable関数」です。
UIを定義するComposable関数
通常は、以下のように 「UIをCompose可能な関数として定義すること」 に使われます。
@Composable
fun CounterView() {
val count = remember { mutableStateOf(0) }
Button(onClick = { count.value++ }) {
Text("You clicked ${count.value} times")
}
}
状態とロジックをカプセル化したオブジェクトを返すComposable関数
上記のViewの状態とロジックを切り出して、以下のように 「状態とロジックをCompose可能な関数」 として書くことが可能です。
↓状態とロジックをViewの外に切り出した!!
data class CounterState(
val count: Int,
val increment: () -> Unit
)
@Composable
fun rememberCounter(): CounterState {
var count by remember { mutableStateOf(0) }
return remember(count) {
CounterState(
count = count, // 状態
increment = { count += 1 } // ロジック
)
}
}
※この記事で書かれたコードの例は、こう書けますよという一例であり「この書き方が正しい」と主張するものではありません。
Jetpack Composeの公式ドキュメントではViewModelを使う方法が説明されており、 それに従うのが無難 という考え方もありだと思っています。
ただ、公式ドキュメントを見ても、ViewModelを使う方法ばかりが書かれているため
「じゃあ、ViewModelを使わない場合はどう実装すればよいの?」
という疑問をお持ちの方のために、この記事を書いています。
誰かの参考になれば幸いです。
なぜViewModelを使わないのか?
宣言的UIには、ViewModelを使ったアーキテクチャは合わない、と感じているからです。
特に合わないなと思っているのは、ViewModelにビジネスロジックを書いてしまうと、UI固有のロジックとして書かれてしまいます。 ViewModelは画面毎に生成されるものだからです。
ViewModelにビジネスロジックを書くことについて
つまり、Viewからロジックを切り出すために、ViewModelにロジックを切り出すことは、再利用性/変更容易性を妨げますし、UIロジックとドメインロジックの境界線が曖昧になります。
Jetpack Composeの公式ドキュメントでも、たびたびViewModelが登場するのですが、ViewModelにビジネスロジックを書いていて、あまりよくない実装だなと思います。
公式ドキュメントの下記の例ですと、著者画面から 「著者をフォローする」というビジネスロジックがAuthorViewModelにfollowAuther というメソッドに実装されているのですが、じゃあ、書籍一覧画面からも「著者をフォローする」という機能が必要になった場合、BookListViewModelにもfollowAutherメソッドを実装することになります。結果としてコピペロジックが散らばってViewModelがカオスな状況へ一歩踏み出してしまいます。
再利用されるビジネスロジックは、ドメインレイヤにカプセル化する必要がある と考えています。
公式ドキュメントでも「followAutherは他の画面で再利用できないよ」と注意を促しているのですが、ちゃんとドキュメントを読み込んでいないと見落としがちかもしれないです。
合わないと感じているアーキテクチャを「過去に使っていたから」という理由だけで使い続けるのはよくないなと思います。
Jetpack Compose採用のタイミングは、アーキテクチャ再考のタイミングにピッタリです。
考え直した結果、「まだViewModelは必要だ」となるか「ViewModelを使わなくてもいいよね」となるかは、人それぞれだと思いますので、どちらの結果になったとしてもそれはそれで良いと思います。
もちろん既存のコードと相互運用するためにViewModelを使うのは賛成です。 (既存のコードをいきなり全部Jetpack Composeに書き換えるのは大変ですから)
新規作成するJetpack Composeアプリにおいて、私は、ViewModelは必要ない と考えています。(※個人の意見です)
ちなみに、GoogleのJetpack Compose開発者で有名なJim さんも
「ViewModelはJetpack Composeに必要ないと思ってるよ。 削除することを推奨するよ」 とツイートしています。
AAC ViewModel は Android 用の雑なもので、悪いパターンを増殖させる理由はないと思います。データレイヤーが適切に設計されていれば、AAC ViewModel を使う必要がないことに気づくはずです。
じゃあ、 なぜ公式ドキュメントにViewModelを使う例が多く書かれているのか?
Jimさん曰く、
「多くの人がすでにViewModelsに大きく依存している既存のアプリケーションを持っているので、相互運用性/完全性のため にdocsはそれらについて言及する必要があります」
とおっしゃっています。
つまりは、既存のアプリの後方互換のためにViewModelは公式Docで書かれているです。
もちろん、新規に作成する純粋なJetpack ComposeなアプリでもViewModelを使い続けるのは、一つの選択肢としてはありです。しかし、Composeの世界でViewModelを使うには注意点もあります。
また、Jimさんは、以下のようにおっしゃっています。
「アクティビティ内に ViewModel を持つことは問題ありませんが、純粋な Compose アプリでは、Compose が設定変更を処理できるため、おそらく不要です。 コンポーザブル関数が ViewModel を参照することは、コンポーザブルをアプリやプラットフォームに不必要に結びつける ことになり、非常に胡散臭いです。」
ViewModelを使用するときの注意点
とは言っても、なんだかんだでViewModel使いたい場合もあるかと思います。
Jetpack Composeで、ViewModelを使う場合、いくつか注意点がありますので、気をつけてください。
Composable関数にViewModelをそのまま渡さない
ViewModelは、 Composeの世界の外 で生成されるものであり、Composeの世界の理からは外れた存在です。
ViewModelは、世界の再Composeに耐えられないので、Composable関数に渡さないでください。
ViewModelで保持している状態を個別に渡してください。
ViewModelとComposeの世界を疎結合にすることが重要です。
ViewModelをCompositionLocalに渡さない
CompositionLocalを使うとめんどくさいバケツリレーをしなくて便利なのですが、これも上記と同じ理由でComposableな世界の理にViewModelは耐えられないので、ViewModelをそのまま渡さないでください。
SwiftUIでもViewModelは不要?
以前、 「SwiftUIでMVVMを採用するのは止めよう」 という記事を書きました。合わせてこちらもご覧ください。
SwiftUIとJetpack Composeには差異はありますが、宣言的UIという括りとしてはおおまかには変わらないと思っています。
※また、ここで言っているMVVMにおけるViewModelと、Android開発におけるAACのViewModelの意味も違うことは認識しています。ViewModelという言葉の捉え方は人さまざまで、誤解を生みやすい表現だということも認識しています。
ViewModelを使わずに、Composable関数(Factory Function)を使って状態とロジックを切り出す方法
Viewに状態とロジックが混在する問題
じゃあ、 ViewModelを使わずに、状態とロジックを切り出すにはどうしたらよいのか?
ここからは、カウンターアプリのコードを例に説明します。
最も単純なカウンターコンポーネントは、以下のようになります。
@Composable
fun CounterView() {
val count = remember { mutableStateOf(0) }
Button(onClick = { count.value++ }) {
Text("You clicked ${count.value} times")
}
}
ボタンをタップするとカウントアップしていきます。
Viewに、状態とロジックがべったり書かれています。
@Composable
fun CounterView() {
val count = remember { mutableStateOf(0) } // 状態!!
Button(onClick = { count.value++ }) { // ロジック!!
Text("You clicked ${count.value} times")
}
}
上記のコードの問題点として、コンポーネントの中に 「View、状態、ロジック」が混在 しています。
コンポーネントが肥大化すると、可読性が下がったり、メンテナンスしにくくなったり、様々な問題 を引き起こします。
Factory Functionで状態とロジックをViewの外に切り出す
そこで、コンポーネントの中から「状態とロジック」を外に切り出します。
Composable関数を使って、簡単に外に切り出すことができます。
@Composable
fun CounterView() {
val counter = rememberCounter()
Button(onClick = { counter.increment() }) {
Text("You clicked ${counter.count} times")
}
}
↓状態とロジックをViewの外に切り出した!!
data class CounterState(
val count: Int,
val increment: () -> Unit
)
@Composable
fun rememberCounter(): CounterState {
var count by remember { mutableStateOf(0) }
return remember(count) {
CounterState(
count = count, // 状態
increment = { count += 1 } // ロジック
)
}
}
rememberを使って状態とロジックを含むCounterStateを返すComposable関数として、rememberCounterに状態とロジックを切り出すことができました。
このようにComposable関数として状態とロジックを切り出すことによって、純粋なComposeの世界の中だけで実装が完結する のがこのやり方のメリットです。
ViewModelを使用するとComposeの世界の中と外を意識せねばならず、その境界線を跨ぐときに注意が必要なので、なるべく境界線を意識しない方法が良いと私は考えます。
rememberについて
rememberに慣れていないと、rememberCounter関数がやっていることは、一見魔法のように見えますが、中身を見てみると、内部のキャッシュにオブジェクトをキーと一緒に突っ込んでいます。再コンポーズ時にはそのキャッシュから値が復元されます。
ただし、画面回転時、rememberで保持した状態は消えてしまいます。
(大外のActivityごとComposeが削除される)
画面回転時に、ViewModelを使わずに状態を復元したい場合
rememberの代わりに rememberSaveable することで、画面回転時にも状態を復元できます。
※rememberSaveableはParcelableしか保存できないのでオブジェクトを保存したい場合、Parcelableにシリアライズする必要があります。(もっと簡単にしてほしい…今後に期待)
rememberは奥が深いので、今後また別の記事で説明できたらなと思います。
まとめ
- Jetpack Composeを使いこなすには、Composable関数について深く理解することが、最も重要
- Composable関数は、コンポーズ可能な仕様を満たす関数のこと
- Composable関数には、2種類ある。
- UI Function(return Unit)
- Factory Function(return value or object)
- Fuctory Functionを使って、状態とロジックをViewの外に切り出せる
- View/状態/ロジックが混在したコンポーネントは、すぐに肥大化して複雑になる。
- 状態とロジックは、こまめにダンボールに梱包して、Viewの外に切り出すとよい。
- まだまだViewModelを使わないやり方については、情報が少ない ので、詰まったときに自分で道を切り開くチカラが必要になる。
- 公式DocでViewModelを使用したコードを載せているのは、既存のアプリの後方互換性のために書いている
- 新規Jetpack ComposeアプリではViewModelは必要ないと、GoogleのJetpack Compose開発者のJimさんもツイートしている
- しかしながら、公式Docには、ViewModelを使用した例が多いので、それに従うのが もっとも無難かもしれない。(既存のコードの後方互換のためにViewModelを使っていくのが無難。ゆくゆくはPureなComposeな世界にはViewModelは必要ないのでViewModelは使わないようにしていく方針が良さそう)
- Composable関数やrememberの使い方を深く知ることは、Jetpack Composeを使いこなす上で重要 (大事なことなのでもう一度)
参考