【Android】再コンポーズの仕組みを理解する

実務でバグの修正などを行うにあたり、仕組みや挙動などを知っておかないと解決できないことが多いためこの記事で理解できるようにまとめます。

コンポジション

Composeフレームワークがコンポーザブル関数を実行するとコンポーザブルの親子関係を表現する木構造がメモリ上に構築されます。これをコンポジションと呼びます(コンポーザブル関数を実行してコンポジションを作成することはコンポーズと呼ぶ)

ルートノードについて

コンポジションはsetContentをルートとしてエントリーポイントごとに作成されます。この場合コンポジションの生存期間はsetContentを記述したActivityなどのライフサイクルと同一となります。

※onPauseやonResumeなどは保持されるが画面回転などでActivityが再生成されるとコンポジションも再生成される

コンポーザブル関数の役割

コンポーザブル関数の役割はこのコンポジションを作成することと、状態が変化した際にコンポジションを更新することです。

またコンポーザブル関数を実行しても画面に表示されるUIそのものが作られるわけでなく、実際に表示するUIを作成し描画するのはComposeフレームワークの役割となります。

再コンポーズ

再コンポーズのトリガーとなるのはStateの値となります。起点となるのはStateを読み取っているコンポーザブル関数ですが、ColumnなどのInline関数の場合はその親が起点となります。

例)State、MutableState、StateFlow.collectAsState、remember { mutableStateOf }

再コンポーズはコンポーザブルが保持する情報の更新だけでなくコンポジションの更新も行います。

ここで再コンポーズとしてトリガーされるのはコンポジションにあるコンポーザブルのみとなりますので、LazyColumnなどの非表示状態から表示状態にさせるためにStateの値を更新した場合などはコンポジションに含まれていない可能性があるので注意が必要です。

再コンポーズのスキップ

再コンポーズでは効率よく表示を更新するために再コンポーズの範囲に含まれていても再コンポーズの対象外となる場合があります。これは再コンポーズのスキップと呼ばれています。

再コンポーズがスキップされる条件はコンポーザブル関数の引数に変化がないことです。これは例えば1つのComposable関数内で複数のComposableを呼び出していたとしても引数に変化がなければそのComposableはスキップされます。

ただし、渡す値がIntやStringなどではなくMutableListなどであればCompose独自の判断で再コンポーズされる可能性があるとのことです(IntやStringはEqualで変化を検知できるが独自クラスやlistなどのインターフェースは不安定な型として扱われるため)

再コンポーズとは「再描画」ではなくComposable関数をもう一度呼ぶかどうかの判断となります。

Commposeは常にアップデートされているためスキップ条件などは変化されていくため、開発者が気をつけるべき点は確実にUIを更新させるための設計が必要となります。

コンポーザブル状態の保持

再コンポーズは単にComposable関数を再実行するだけなので通常であれば関数内で保持しているローカル変数は呼び出す度に初期化されます。

そのため再コンポーズを超えてオブジェクトを保持するにはrememberを利用します。実際にはrememberも値を返すComposable関数です。Composable関数は値を返さないのが基本ですがrememberは例外で値を返します。

内部ではコンポジションにデータのキャッシュを作成して値を返しています。remeberにはkeyを指定することで値の変化がなくてもkeyの変化をトリガーとして値を更新することも可能です。

Stateとrememberの関係

Stateとrememberは共存しています。remeberを利用せずにStateのみで値の変更を行った場合はStateを監視しているComposeがComposable関数を再実行しますが保持している値はリセットされるので更新されない形となります。

画面の再生成でも値を保持させる場合

rememberは画面回転、アプリのサイズ変更、テーマ変更などによる画面の再生成では、コンポジションが削除されるためそれを参照しているrememberのキャッシュも削除されます。

そのため上記に加えて、スクロール位置や入力中のテキストなどリセットを許さずに状態を保持しておきたいものはrememberSavableを利用して状態を保持させます。

注意点としてrememberSavableはSharedPreferenceなどの永続化データベースではありません。保存先はアプリの一時的なデータを保存するためのBundleです。

保存できる型はParcelableでIntやMutalbeStateデフォルトで保存できますが、独自クラスは@Parcelizeアノテーションをつけてクラスを定義すれば保存可能となります。@Parcelize以外の方法ではSaverを定義して保存可能にする方法もあり、rememberSavableの引数にはSaverを指定することができるためmapSaverをobjectを渡して復元することも可能です。

コンポーズの副作用

コンポジションの更新を作用、それ以外の処理を副作用となります。コンポーズで定期的に実行される処理については下記の関数を利用して実行させます。

SideEffect

コンポーズ・再コンポーズが正常に完了した時にのみ実行されます。SideEffect関数を利用するとコンポーズ完了後に必ず実行されるためログ出力としても有効です。

ただし、値の変化によって何度も再コンポーズが発生する場合はこれを利用するべきではありません。

LaunchedEffect

LaunchedEffectの場合は初回のコンポーズとkeyの値が変化していた場合にのみ実行されます。keyには直接値を設定して他の値が変更されても実行されないように制御することが可能です。

keyにUnitを定義すると初回のコンポーズ実行時のみ実行することが可能となるため、画面遷移後のログ出力などに適しています。

また、実行中の処理に関してはLaunchedEffectがコンポジションが削除されるとキャンセルされるため画面が消えても動き続けるという心配がありません。

DisposableEffect

これはコンポーザブルがコンポジションから削除されるタイミングで実行されます。実行タイミングはLaunchedEffectと同様ですが、onDisposeメソッドを呼び出して処理を記述する必要があります。

rememberCoroutineScope

ButtonなどでUIイベントのコールバックとしてsuspend 関数を実行させる必要がある場合はrememberCoroutineScopeを利用します。

しかしながらComposeまわりには大体viewModelがあって、そこで実行させたりするため不要となるケースが多いです。viewModelに依存しない一時的にイベントを投げるような軽めの意味をも持つ場合に実行します。(アニメーション、UIエフェクト、フォーカス操作、スクロール制御など)

rememberUpdatedState

副作用APIやコールバック関数などで実行中のコルーチンからコンポジションの最新の値を取得したい場合にはrememberUpdatedStateを利用します。

コンポジション内のデータの共有

今までの引数を通してデータを共有する方法もありますが、CompositionLocalという方法を利用してデータを共有する方法もあります。

CompositionLocal = グローバル変数

多用するとコードの可読性とメンテナンス低下につながるため、マテリアルデザインやアプリやデバイスの固定値を定義するときに利用します。