はじめに
-
snapshotFlow { ... }は Compose の Snapshot 読み取りを監視して Flow に変換する仕組み - ブロック内で 読まれた State が更新されると、そのたびにブロックが再実行されて
emitされる - 内部的には Snapshot の依存トラッキング+ApplyObserver を使っている(Recomposer と同じ通知経路)
- 「Compose の State → コルーチン世界の Flow」へ橋渡しする時にめちゃくちゃ便利
- ただし 重い処理をブロック内に書きすぎる・大量の State を読む とパフォーマンス悪化するので注意
1. snapshotFlow とは何か?
公式のイメージだと:
State(
mutableStateOf等)を監視して Flow に変換するユーティリティ
ですが、本質的にはこうです:
「Snapshot の read-tracking 機能を利用して、
ブロック内で読まれた State の変更を Flow に流すトリガー装置」
なので、単純な「State を Flow に変換するラッパ」ではなく、
Compose の内部機構(Snapshot)をそのまま Flow 側で再利用している感じに近いです。
2. まずは基本の書き方
よくあるパターン:Compose の State → Flow → 非同期処理
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val query by viewModel.query.collectAsState()
LaunchedEffect(Unit) {
snapshotFlow { query }
.debounce(300)
.distinctUntilChanged()
.collect { q ->
viewModel.search(q)
}
}
// TextField など UI は省略
}
ここで起きていること:
-
snapshotFlow { query }
→ ブロック内でquery(= State)を読む -
queryが更新されるたびに
→ Snapshot が「この State 変わったよ」と snapshotFlow に通知 - snapshotFlow がブロックを再実行して 新しい query を emit
- Flow 演算子(
debounce等)を通して非同期処理へ
これは 「UI の State 変化を安全に Flow に渡す」 という、Compose では頻出のパターンです。
3. snapshotFlow の内部仕組み
3.1 ざっくり流れ
-
snapshotFlow { block }が呼ばれる - ブロックを read-only Snapshot 上で実行
- 実行中に読まれた State を Snapshot システムが記録
- 実行後、その State 群に対して「変化したら教えてね」という ApplyObserver を登録
- その後、いずれかの State が変化
→ Snapshot が ApplyObserver を呼ぶ
→snapshotFlowがブロックを再度 Snapshot 上で実行
→emit(newValue)される
つまり、
「ブロック内で読んだ State の集合に対する、差分トリガー付き Flow」
です。
3.2 シーケンス図で見る SnapshotFlow
Mermaid で内部フローを図解してみます。
ポイント:
- ブロックは「状態が変わったときだけ」再実行される
- 並列実行ではなく、状態変化ごとに順に実行される(Flow の collect に従う)
- 依存関係の管理は Snapshot システム側が担当(Compose 内部と同じ)
4. Recomposer と snapshotFlow の関係
4.1 共通点:どちらも Snapshot の通知を見ている
- Recomposer
→ 「State が変わった → どの Composable がその State を読んでいたか?」をもとに再コンポーズをスケジュール -
snapshotFlow
→ 「ブロック内で読まれた State」が変わったら、ブロックを再実行して Flow にemit
どちらも内部では:
- Snapshot の ApplyObserver
- State の read-tracking
を使っています。
違うのは「通知をどう使うか」。
| 役割 | Recomposer | snapshotFlow |
|---|---|---|
| 目的 | UI の再コンポーズ | Flow に値を流す |
| 読み取り対象 | Composable 内で読まれた State |
snapshotFlow {} ブロック内で読まれた State |
| 変化時のリアクション | SlotTable 更新 → UI 再描画 | Flow の emit(value)
|
| 動作する場所 | Compose ランタイム内部 | 任意の coroutine / LaunchedEffect などから利用可 |
4.2 だから何が嬉しいのか
- UI 側の State と、非 UI なコルーチン処理(API 呼び出し、保存処理など)の間を
「同じ Snapshot メカニズムで」つなげる - State の同期ずれや、
collectAsState/LaunchedEffectでの発火条件の混乱を減らせる - State 変化の Flow 化を、かなり宣言的に書ける
5. よくある実戦パターン
5.1 検索クエリのデバウンス
さっきの例を少しだけ一般化すると:
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
snapshotFlow { uiState.query }
.debounce(300)
.distinctUntilChanged()
.collect { query ->
viewModel.search(query)
}
}
}
-
uiState.queryをブロック内で読むだけ -
queryが変わったときだけemit - それを
debounce + distinctUntilChangedで API 呼び出しの回数を制御
5.2 リストスクロール位置を反映しつつ、Flow とも連携
@Composable
fun ListScreen(viewModel: ListViewModel, listState: LazyListState) {
// スクロール位置を State に反映
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { index ->
viewModel.onScrollIndexChanged(index)
}
}
// ViewModel 側で index に応じた処理を Flow で行う、など
}
LazyListState も Compose の State を内部に持っているので、
snapshotFlow { listState.firstVisibleItemIndex } で同じように監視できます。
6. 他の API との違い
6.1 collectAsState() とどう違う?
-
collectAsState()
→ Flow → State(Compose 側) の橋渡し -
snapshotFlow
→ State(Compose)→ Flow の橋渡し
方向が逆。
UI 外にロジックや処理を逃がしたいときは snapshotFlow、
UI に値を反映したいときは collectAsState。
6.2 derivedStateOf との違い
derivedStateOf は:
- 複数の State から「派生 State」を作るための API
- Snapshot システム上で 「キャッシュ+依存トラッキング」 を行ってくれる
- Flow とは無関係(あくまで State → State)
一方 snapshotFlow は:
- State → Flow
- Flow 演算子(
debounce,map,combine,flatMapLatest, …)を活用できる - ネットワークや DB など「非同期処理」と一緒に使うのに向いている
組み合わせることもできる:
val derived by remember {
derivedStateOf {
"${uiState.firstName} ${uiState.lastName}"
}
}
LaunchedEffect(Unit) {
snapshotFlow { derived }
.collect { fullName ->
// ログ送信とか
}
}
6.3 LaunchedEffect(key) とどう使い分ける?
LaunchedEffect(key) は、
- key の変化ごとに coroutine ブロック全体を再起動する
- = key が変わると 古い coroutine はキャンセルされ、新しく起動
一方、snapshotFlow { ... } は:
-
ブロック内で読んだ State の変化を契機に
emit - coroutine 自体は同じスコープで生き続ける
なので、
- key が変わったら「処理ごとキャンセルして作り直したい」
→LaunchedEffect(key) - key の変化を Flow として扱いたい(
debounce,map,filteretc)
→snapshotFlow { key }+ 1 つのLaunchedEffect(Unit)
というイメージで使い分けるとスッキリします。
7. よくある落とし穴・アンチパターン
7.1 ブロック内で「重い処理」をしない
snapshotFlow {
// ❌ やりがち:重い処理を直接ここでやる
importantCalculation(uiState)
}
.collect { result ->
// ...
}
snapshotFlow ブロックは State が変わるたびに再実行される ので、
ここに重い計算を書くと、そのままパフォーマンス問題になります。
👉 対策:
- ブロック内は「値の読み取り」だけにして、重い処理は
collect側 or Flow 演算子内で行う
snapshotFlow { uiState }
.map { state -> heavyCalc(state) }
.collect { result -> /* ... */ }
7.2 監視したくない State まで読み込んでしまう
snapshotFlow {
// ❌ なんでもかんでも 1 つの snapshotFlow に詰め込む
uiState // huge データ
}
ブロック内で 読んだ State は全部監視対象になる ので、
本当に変化をトリガーにしたい値だけ読むように絞ると良いです。
👉 シンプルに「必要なスカラー値だけ」にするのが吉:
snapshotFlow { uiState.query to uiState.filter }
7.3 collect 側を Compose のライフサイクルと紐づけ忘れる
snapshotFlow は ただの Flow なので、
LaunchedEffect, rememberCoroutineScope, viewModelScope 等との組み合わせをちゃんと設計しないと、
- 画面から離れても coroutine が動き続ける
- 逆に、必要なときに収集されていない
といった問題が発生します。
Compose 内で使うときは基本:
LaunchedEffect(Unit) {
snapshotFlow { ... }
.collect { ... }
}
で、「画面が存在する間だけ収集」されるようにしておくのが安全です。
まとめ
-
snapshotFlowは Compose の Snapshot を Flow に変換するための橋渡し API - 内部では State の read-tracking と Snapshot の ApplyObserver を使っている
- Recomposer が UI を再コンポーズするのと 同じ通知経路 から、Flow 側へ値を流している
- 検索クエリのデバウンス、スクロール位置監視、フォーム入力の検証などで非常に役立つ
- ただし「ブロック内に重い処理を書かない」「読む State を絞る」という設計が重要