Kotlin/ViewModelによる状態管理
既存のXMLによる画面レイアウトをComposableな関数による画面レイアウトに書き換えてみう試みは先日、うまくいって終わったんですが、微妙に消化不良なことがチラホラあったんです。そのうちの一つがViewModelでして、調べ物をしているとよくこの単語が出てきてたんですね。ただ、自分のアプリが小ぶりなこともあって、「無くても実装できる」「持ち出すほどでもない」という判断がされてしまってこれまで触れることがありませんでした。そして、今、二つ目のアプリ書き換えに手を付けて、どうやらViewModelの出番が作れそうです。
アプリのアーキテクチャとやら
ここでViewModelを触るにあたって、知っておいた方がよさげな予備知識というのがあるそうでして、Webのフロントエンドをよく触っている方ならよくご存じなアレの話が解説を呼んでると出てきました。VMCとかVMMVとかアプリをいくつかの要素に分離させて考えるとかいうアレです。解説を読んでいると、アプリのアーキテクチャには「UIレイヤ」と「データレイヤ」(あとその中間に「ドメインレイヤ」があることもあるらしい)が必要、っていうかだいたい必然的に生まれてくるってことのようです。まぁ、アプリには操作する人と操作する対象があるわけですから、アプリとそれらをつなぐアーキテクチャが必要ってのはわかりやすいですね。
せっかくだからAI先生に作図してみてもらいました。(うまく指示できないなぁ)このレイヤをしっかり分けて実装すると大規模開発時に拡張性が高まったり、多人数での開発が責任の切り分けによって楽になるっていうメリットがあります。ぶっちゃけ、私みたいな個人開発が多い人間には無くても何とかなるっちゃなるんですが、大勢を使った開発にも参加することはあるので、最低限の知識くらいは持ってなくもないのでした。全体の設計をしっかり考えられる人がやると開発は事前の見積もり通り完了し、そうではない人がやるとぬかるみのような開発が延々と続くやつですね。
- 関心の分離
- モデル駆動型 UI
アーキテクチャの原則というのも挙げられていて、「アプリを機能別に分割しろ」とか、「まずデータありきだ」とか、言われているようです。たくさんプログラムを書いてきた人にとってはごく当たり前のようなことかもしれませんが、「原理」とかいう単語が付くと何だか大事にしなきゃな、っていう気持ちが湧いてきますね。ここで推奨されているアーキテクチャに従って、Jetpack ComposeではUI要素を書き、状態ホルダーとしてデータの保存、アプリのロジック、UIとの連携をViewModelに担わせるのが、UIレイヤの構成としておすすめらしいです。状態っていうのは、前にrememberしてたあれのことですね。前はComposableな関数の中にあれこれ書いてましたが、あれを分離してかけるってことなんでしょうか?
そもそもrememberでは何を思い出していたんでしょう?ユーザーとアプリをUI要素がつないでいると、アプリ上で起こったことをユーザーに伝えるのもUIに課せられた仕事になります。この伝えられたかどうか忘れないように思い出すのがrememberなんでしょうね。具体的にはアプリがデータの読み込みを終えたら、それを画面に表示してやることを忘れると、アプリユーザーはずっと、まだかなまだかなと待ち続けなきゃならないわけです。これはremember必須ですよね。そして「データが読み込み終わったかどうか」がUIを更新するかどうか決める状態ってやつなんでしょう。
実装
では、実際に書いてみましょう。
class MainViewModel : ViewModel() {}
ずばり、ViewModelを継承しただけの状態です。MainViewModel と命名しましたが、これはMainScreenというメイン画面のUIを表すComposal関数を制御する用途のViewModelであるというつもりです。もっと小さな部品用のViewModelを作るのでもよかったし、いろんな画面で使う部品があるならそう書いたんですが、とりあえずメイン画面を全部面倒見るようなものを作ってみましょう。このViewModelを表すファイルをどこに置こうか悩んだ・・・っていうかComposal関数のファイルをどこにどう配置するべきか全然わからなかったんですが、AIさんに聞いてみたところ、「大きいプロジェクトだと画面を表すファイルやViewModelを表すファイルは種類ごとにフォルダ分けして分類すると見通しがいいけど、小さいプロジェクトなら画面ごとにフォルダを分けてもいいかも」って言われました。
└ui └main └MainScreen.kt └MainViewModel.kt
こんなファイルツリーでやってみますか。後で変えたくなったらAndroid Studioがいい感じにリファクタリングしてくれるので、とりあえず今わかりやすいことを大事にやっていきます。
@Composable fun MainScreen( navController: NavController, mainViewModel: MainViewModel = viewModel() ) {}
↑こんな感じでMainScreen関数の引数に先ほどのViewModelを渡してやるようにしておきます。引数が省略されると代わりにviewModel関数が必要なViewModelを渡してくれるようです・・・これは省略されることが前提にあって、任意の状態も渡せるようにしてあるって感じですかね。とりあえず、mainViewModelという名前のMainViewModel のインスタンスを通じて、Composable関数の中から状態を管理できるってことになります。(まだピンときていない)
ここから具体的な実装が始まります。そもそも今回ViewModelを使おうと思った理由がいくつかありまして、
- 画面上にログを出力するカスタムViewのUIがあるので、それを制御したい
- マイクを使ったUIの音声入力処理がUIとは非同期な処理なので、入力後に画面UIと同期を取りたい
↑この辺を解決したいのでした。となると、ViewModelは「ログ出力UIの状態」と「非同期処理の進行状態」なんかを持たせたらいいのかなと思っています。
var logView: LogView? by remember { mutableStateOf(null) } AndroidView( factory = { context: Context -> LogView(context).also { logView = it savedInstanceState?.let { bundle -> it.restoreInstanceState(bundle) addLog(context.getString(R.string.restore_main_activity)) } } }, update = { logView = it } )
↑こんな感じのカスタムView(をAndroidViewでラップしたもの)があって、logView変数を通して、ビューを操作できるっちゃできるんですが、画面のいろんなところからログ出力ビューを更新したい要請があるけど、logViewの狭いスコープではいろんなところからは呼び出せないよね、っていうのが今の悩みです。でも、さっきのmainViewModelなら広いスコープの場所にも置けるし、なんならいろんなところからviewModel()関数でいくらでも呼び出せるわけですね・・・これって、みんな大好きグローバル変数に近い何かにみえますね。バグを作りこむから忌避されているグローバル変数、でもどこからでもアクセスできる頼もしい隣人グローバル変数!Rustなんかをいじっているとグローバル変数に似た安全を担保した何か、というのが最近の言語にはあるのかもしれないなって思います。では、mainViewModelにlogViewを持たせてみましょう。
class MainViewModel : ViewModel() { var logView: LogView? = null }
さっきComposable関数の中でrememberしていたものをViewModelが持てるようにしました。・・・「This field leaks a context object」と怒られました(警告)。ViewModelにViewを渡すのはそもそもM,V,VMを明確に分けるアーキテクチャを取ってるから理念的にも駄目だし、Viewなんかはアプリより儚いライフサイクルしか持ってないので、ViewModelに持たせても気づいたら蒸発してなくなっちゃうよ。あとそこを密に近づけるとユニットテストもやりにくくなるよ!ってことで全然よくないのでした。まさに突然死するグローバル変数のメモリリーク問題の仲間って感じですね。なんでもグローバル化できると思うなよ!ってことでしょう。
状態の持ち方
一体何が悪かったのでしょう?私はログを出力するカスタムViewを表示したいだけなのに・・・。整理しましょう。
- View
- ログ出力カスタムView
- ViewModel
- ログ出力カスタムView自身の管理
↑これだと怒られました。ViewModelにViewを持たせるなってことですよね。
- View
- ログ出力カスタムView
- ViewModel
- ログデータの管理
↑こうあるべきってことですね。状態はログを表す文字列のリストだけで十分なのです。Viewはそれを表示する役割だけ担ってくれればいいのです。では、ログデータはどこにあるのでしょう・・・ログ出力カスタムViewの中だ!Viewの中に一心同体でログが存在しているので、Viewのライフサイクルが終わったら一緒にログデータも死ぬわけです。ActivityみたいなかつてViewの親玉みたいなやつだったあいつは、画面が回転するだけで儚く死んでたわけですよ。それで子分のViewも巻き込んで死んでいくわけですが、ログデータだけは消えないようにonSaveInstanceState時にBundleにデータを詰め込んでいましたっけ・・・そうか、あのBundleがあの頃の状態だったわけか。それでComposable関数を使う時代になったらViewModelやrememberな変数に状態を明け渡してViewとViewModelをきっちり分離させようとしているんですね・・・カスタムView作り直しじゃん!カスタムViewのまま状態を切り離すか、Composable関数として書き直すかちょっと悩みますが、どっちにしろ工数はそれなりにかかりそうです。→Composable関数でViewを作り直しました。
data class Chat( val text: String, val isSystem: Boolean )
ログデータにもテキストデータに発信者を表すBoolean値を加えてみました。isSystemがtrueのときはアプリからのメッセージで、falseのときはユーザーのアクションを表すメッセージとします。このデータ構成でチャット風のUIに作り直すことにしたのでした。
class MainViewModel : ViewModel() { private val _messages = MutableStateFlow( listOf( Chat("こんにちは!", false), Chat("やあ、元気?", true), Chat("元気だよ、ありがとう!", false) ) ) val messages: StateFlow<List<Chat>> = _messages fun addChat(text: String, isSystem: Boolean) { _messages.update { it + Chat(text, isSystem) } } }
ViewModelには先ほどのメッセージデータ(Chat)のlistを持たせました。_messages というprivateな変数でMutableStateFlowになってます・・・MutableStateFlowって何?Mutableってことは中身が書き換え可能なんでしょう。Stateとついているので状態を表してくれるんでしょうかね。Flowは・・・今はViewModelのトピックなので詳細な説明はしないでおきましょうか。自分も今初めて見たのでわかってませんが、データがストリーミング的に取得されるとかなんとか。決まった型のデータが複数取得できるんですが、非同期処理の結果をメインスレッドを止めることなく処理を進めてくれるようです。非同期をいかに取り入れていくか、マルチなプロセッサー使いが求められる昨今、という感じがします。
そして、publicなmessages変素の方が書き換えられないStateFlowで提供されています。こうしておくと、自由に状態が書き換えられないけど、外からはオープンに読み出しはできるっていうことなんでしょうね。今後もよくでてきそうな構成かも。
最後にメッセージリストを書き換えるaddChatメソッドを追加します。この関数を通さずにデータは書き換えられないので、入力値のチェックとかも仕込みやすそうです。MutableStateFlowの更新の仕方はupdateメソッドを介して、itを操作するのかぁ。いつまでもKotlinの作法に慣れないなぁ。
@Composable fun LogView(viewModel: MainViewModel = viewModel()) { val messages by viewModel.messages.collectAsState() LazyColumn( modifier = Modifier.fillMaxSize().padding(8.dp), reverseLayout = false ) { items(messages.count()) { i -> Log(messages[i]) } } }
ログを表示するカスタムViewをComposableな関数に書き換えました。ViewModelに持たせていたmessagesをcollectAsStateを介して状態として関数内に導入した・・・ってことですかね。普通にループで回して全件表示しているだけですが、後でもうちょっといろいろ機能を足しましょう。LogっていうComposable関数が実際に個別のメッセージを描画していますが、ここでは省きます。まぁ、Textに毛が生えた程度の中身です。
長くなってきたので、次回へ続きます。