はじめに
Android歴1.5年の私がComposeコースのComposeの思想を読んで自分なりの理解でまとめたものになります
Jetpack Composeを理解していくにあたり特有の考え方や処理のクセなどの理解に役立てばと思います
宣言型プログラミングのパラダイム
宣言型プログラミングとは
細かく指示するImperative (命令的、指示的) に対してDeclerative (宣言的) は細かく指示しない、といったニュアンス
コーヒーの作り方は言わないで、「コーヒー頂戴」と目的を一言で表して細々とは伝えないような形
(see https://qiita.com/Hiroyuki_OSAKI/items/f3f88ae535550e95389d)
なかなか理解が難しい。。
パラダイムとは
範例、思考の方法論やものの見方
→要するに、宣言型プログラミングのパラダイムとは
細かく指定しなくても裏でいい感じに実装してくれるからやりたい事を明確にしてきちんと指示を出すことが大事
なプログラミング手法と理解
宣言型プログラミングの利点
従来のxmlを取得してViewを手動で操作する手法は、更新し忘れや更新の競合などでメンテナンスが複雑であった
宣言型UIフレームワークであるComposeでは以下の利点がある
画面全体をゼロから再構成して必要変更のみを適用する→手動で更新せずに済む
ただし、画面全体の再構成には時間やCPU、バッテリーの使用にも影響するため、Composeでは必要がある部分だけを再コンポーズすることでリソースの使用を軽減している
シンプルでコンポーズ可能な関数
こちらでも少し触れたが、@Composable
アノテーションをつけることでコンポーズ可能な関数を宣言できる
以下の特徴がある
- @ComposableアノテーションでComposeコンパイラにこの関数がデータをUIに変換するものと伝えることができる
- 引数でパラメータを受け取ることができる
- 他のコンポーズ可能な関数を呼び出すことができる
(UI階層を形成できる) - Returnで何も返さない/戻り値がない
(UIウィジェットを作成するというより、目的の画面状態を記述するためのもの)
宣言型パラダイム シフト
従来のxml方式(命令型)
- Viewのツリーをインスタンス化することでUIを初期化
- アプリのロジックでViewを操作できるようgetterとsetterが公開
Compose(宣言型)
- Viewはステートレスでgetterとsetterが公開されていない
- 同一のコンポーズ可能な関数を、異なる引数で呼び出すことでUIを更新
- アプリロジックは最上位のコンポーズ可能な関数にデータを提供
- 受け取ったコンポーズ可能な関数が他のコンポーズ可能な関数を呼び出す
- 階層を下っていきUIを描画
-
onClick
などでイベントを検知 - アプリロジックへ伝えられ、必要な箇所の状態を変更
- 状態が変更されると、コンポーズ可能な関数が新しいデータで再度呼び出される
- 必要な箇所のUIが再描画される
この再描画の一連の流れを再コンポーズという
再コンポーズ
変更されたコンポーネントを更新する再コンポーズを理解するには、以下の特徴を知る必要がある
- 更新する必要があるもののみに対して処理が走る
- 副作用を含んではいけない
- Compose可能な関数の動作特性
1. 更新する必要があるもののみに対して処理が走る
先述した通り、UIツリー全体を再コンポーズしようとするとCPUやバッテリーのコストがあるため最低限の更新を行うようになっている
基本的な流れは以下になる
- 入力に変更が検知される(関数の入力が変更)
- 対応するコンポーズ可能な関数を再度呼び出される
- 呼び出された関数の中で、変更された可能性がある関数やラムダを呼び出す
- パラメータを変更しない関数やラムダを全てスキップ
- 3、4を最後まで繰り返す
以上のステップでComposeでは効率的に再コンポーズを実施している
2. 副作用を含んではいけない
副作用とは、コンポーズ可能な関数の範囲外で発生するアプリの状態の変化のこと
具体的に以下のようなアクションは危険という記述があった
- 共有オブジェクトのプロパティへの書き込み(varで定義したローカル変数など)
-
ViewModel
で監視可能なデータの更新 - 共有設定の更新(
SharedPreference
など)
しかし、Composeの思想上は副作用がないのが理想であるがスナックバーの表示などの1回限りのイベントをトリガーする場合や特定の状態で別の画面に移動する場合などで副作用が必要になることがある
副作用のユーズケースに関連するAPIがいくつかいくつか紹介されていた
-
LaunchedEffect:コンポーザブルのスコープ内でsuspend関数を実行する
-
rememberCoroutineScope:コンポーザブルの外部でコルーチンを起動するためにComposition対応スコープを取得する
-
rememberUpdatedState:値が変化しても再起動すべきでない作用の値を参照する
-
DisposableEffect:クリーンアップが必要な作用
-
SideEffect:Composeの状態を非Composeコードに公開する
-
derivedStateOf:1つ以上の状態オブジェクトを別の状態に変換する
-
snapshotFlow:ComposeのStateをFlowに変換する
ただし、Compose可能な関数は副作用なしであるべきなので、これらのAPIを本当に使用する必要があるか検討することが大事
3. Compose可能な関数の動作特性
コンポーズ可能な関数では、以下のような意識しておくべき動作特性がある。副作用を含まないことに加え、同じ引数で何度呼び出しても同じ動作になることが重要。
1.コンポーズ可能な関数は任意の順序で実行される
次のコードは上から順番に実行されるように思えるが、その通りに実行されるとは限らない
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
StartScreen()
、MiddleScreen()
、EndScreen()
の呼び出しは任意の順番で発生する。
Composeでは一部のUI要素の優先順位が他よりも高く設定されていることがあるためこのような仕様になっている
その為、例えばStartScreen()
の中でグローバル変数を設定してMiddleScreen()
で利用するということはしてはいけない(副作用)
2.コンポーズ可能な関数は並行して実行できる
Composeでは、コンポーズ可能な関数を並行して実行することで、再コンポーズを最適化できる
この最適化はコンポーズ可能な関数がバックグラウンドのスレッドプールで実行される時があり、複数のスレッドから同時に呼び出される可能性がある
なので、以下コードのように関数がローカル変数に変更を与えるようなことをしてはいけない
@Composable
fun ListWithBug(myList: List<String>) {
var items = 0 //ローカル変数
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
items++ // ❌ラムダ外に影響を与えてはいけない
}
}
Text("Count: $items")
}
}
items
は、再コンポーズの度に変更されてしまう副作用となる。Composeではこのような書き込みはサポートされておらず避けるべきである
3.再コンポーズは可能な限りスキップする
UI の一部が無効な場合、Composeは更新が必要な部分のみを再コンポーズしようとする
/**
* ユーザーがクリックできる名前の一覧をヘッダーで表示する
*/
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// これはheaderが変わったときに再構成され、namesが変わったときには再構成されない
Text(header, style = MaterialTheme.typography.h5)
Divider()
// (LazyColumnは、RecyclerViewのComposeのようなもの)
// (items()で渡すラムダは、RecyclerView.ViewHolderと同じようなもの)
LazyColumn {
items(names) { name ->
// nameが更新されると、そのアイテムのアダプターが再コンポーズされる
// headerが変更されても再コンポーズされない
NamePickerItem(name, onNameClicked)
}
}
}
}
4.再コンポーズは厳密なものではない
Compose可能な関数の引数が変更された可能性があることをComposeが認識する度に再コンポーズが開始されるが、これは厳密なものではない
Composeは再度引数が変更されるより前に再コンポーズが終わる事を想定していて、もし再コンポーズが終わるより先に引数の変更が検知されればComposeは再コンポーズを一旦キャンセルして新しい引数に対応した再コンポーズが開始されることがある
再コンポーズがキャンセルされた場合、Composeは再コンポーズの処理からUIツリーを破棄する。
その為表示されているUIに依存するような副作用を持ってしまっている場合、変化に対応できずアプリの状態に一貫性がなくなる可能性がある
5.コンポーズ可能な関数は何度も実行されることがある
コンポーズ可能な関数がコストの高いオペレーション(デバイス ストレージからの読み取りなど)を行う場合、関数によってはUIジャンクが発生することがある
例えば、ウィジェットがデバイス設定を読み取ろうとすると、その設定が1秒間に数百回読み取られてしまうなど、アプリのパフォーマンスに深刻な影響を及ぼすことなどがある
コンポーズ可能な関数でデータが必要な場合は、データ用のパラメータを定義し、コストの高い処理をコンポーズ外の別のスレッドで実行、mutableStateOf
またはLiveData
を使用してデータを引数に渡すことで実現可能
まとめ
- Jetpack Composeは従来のxmlでのUI操作とは全く違う宣言型プログラミングを採用している
- コンポーズ可能な関数は、関数が関数を呼ぶことでUI階層を形成でき、引数のパラメータが命の戻り値なしの関数である
- データを取得してから表示した後は、必要な部分のみを再コンポーズしていき効率的にUIを更新
- 再コンポーズを理解するには以下三つを前提として理解する必要がある
- 更新する必要があるもののみに対して処理が走る
- 副作用を含んではいけない
- Compose可能な関数の動作特性