Contents
1. Compose が Coroutines を採用する背景
| 項目 | 内容 |
|---|---|
| 設計方針 | UI の再コンポジションは 宣言的 に扱い、副作用は明示的に分離。Compose は内部で Recomposer が保持する CoroutineScope 上でスケジュールされる coroutine を利用し、スレッド切り替え・キャンセル伝搬を自動化します【Android Developers – Compose Runtime】 |
| 安全性 | CoroutineScope が Composable のライフサイクルにバインドされているため、画面が破棄された瞬間に走行中の coroutine が自動的にキャンセルされ、メモリリークや不整合を防止します。 |
| 実装例 | rememberCoroutineScope() が返すスコープは Recomposer に紐付くので、Composable の onDispose と同等のタイミングでキャンセルが走ります【Mori Atsushi – Compose で Coroutines を多用する話】 |
要点:Compose と Coroutines は「UI 状態 ⇔ 非同期ロジック」を同一スコープで管理できるため、実務レベルの UI 開発が格段に安全・簡潔になります。
2. 基本的な非同期 UI パターン
2‑1. LaunchedEffect と produceState の組み合わせ
| API | 用途 |
|---|---|
LaunchedEffect |
キーが変化したときだけ coroutine を起動し、副作用を実行。 |
produceState |
suspend 関数の結果(ロード中・成功・失敗)を State<T> に変換。 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Composable fun UserProfile(userId: String, repository: UserRepository) { // produceState が State<Result<User>> を生成 val userResult = produceState<Result<User>>(initialValue = Result.Loading) { value = try { Result.Success(repository.getUser(userId)) } catch (e: Exception) { Result.Error(e) } } when (val r = userResult.value) { is Result.Loading -> CircularProgressIndicator() is Result.Success -> Text("Hello, ${r.data.name}") is Result.Error -> Text("Error: ${r.exception.localizedMessage}") } } |
ポイント:
produceStateは内部でLaunchedEffect相当の coroutine を走らせるため、1 行で「副作用起動+状態化」が完結します。
2‑2. UI イベントハンドリングに rememberCoroutineScope
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@Composable fun RefreshButton(onRefresh: suspend () -> Unit) { val scope = rememberCoroutineScope() // Recomposer にバインドされたスコープ Button(onClick = { scope.launch { try { onRefresh() } catch (e: CancellationException) { /* キャンセルは無視 */ } } }) { Text("リフレッシュ") } } |
- 利点:画面遷移時に自動キャンセルされ、
ViewModelでのviewModelScopeと併用しても安全です。
2‑3. 非同期派生状態は produceState / rememberUpdatedState が正解
Compose 1.6 系では derivedStateOf に coroutine 対応版は存在しません(公式ドキュメントに記載)。重い計算を非同期で行いたい場合は次のように実装します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Composable fun ExpensiveComputation(input: Int) { val result by produceState<String>(initialValue = "Loading") { value = computeHeavyTaskAsync(input) } Text("Result: $result") } // suspend で重い計算を実行する例 suspend fun computeHeavyTaskAsync(v: Int): String = withContext(Dispatchers.Default) { delay(500) // 擬似的な長時間処理 "Computed(${v * 2})" } |
結論:
derivedStateOfは「同期的にキャッシュされた派生状態」向け。非同期ロジックはproduceState、LaunchedEffect + mutableStateOf、またはrememberUpdatedStateと組み合わせて実装します。
3. ViewModel と Flow によるデータフロー構築
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class TodoViewModel( private val repository: TodoRepository ) : ViewModel() { // UI が常に最新値を取得できる StateFlow private val _todos = MutableStateFlow<List<Todo>>(emptyList()) val todos: StateFlow<List<Todo>> = _todos.asStateFlow() // One‑shot エラー通知用 SharedFlow private val _error = MutableSharedFlow<String>(replay = 0) val error: SharedFlow<String> = _error.asSharedFlow() init { refresh() } fun refresh() { viewModelScope.launch { runCatching { repository.fetchFromNetwork() } .onSuccess { list -> launch { repository.saveToDb(list) } // 別 coroutine で保存 _todos.value = list } .onFailure { e -> _error.emit("取得失敗: ${e.localizedMessage}") } } } /** 子 coroutine へキャンセルを伝搬 */ fun cancelAll() = viewModelScope.cancelChildren() } |
StateFlowは 最新値保持、SharedFlowは 一度きりのイベント に適しています【Kotlin Docs – StateFlow & SharedFlow】。
3‑2. UI 側で collectAsState と repeatOnLifecycle を併用
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@Composable fun TodoScreen(viewModel: TodoViewModel = viewModel()) { val lifecycleOwner = LocalLifecycleOwner.current // StateFlow → State に変換(UI が再表示されても最新値を保持) val todos by viewModel.todos.collectAsStateWithLifecycle(lifecycleOwner) // エラーは STARTED 以上のときだけ受信 LaunchedEffect(lifecycleOwner) { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.error.collect { msg -> // Snackbar で表示(実装例省略) println("Error: $msg") } } } LazyColumn { items(todos) { todo -> Text(todo.title) } } } |
collectAsStateWithLifecycleは Accompanist の拡張関数ですが、Compose 1.6 系でも公式に同等の API が提供されています【androidx.lifecycle:lifecycle-runtime-compose】。
4. 実践的 TODO アプリで学ぶ「ネットワーク取得 → DB 保存 → UI 更新」
4‑1. Repository の実装(Retrofit + Room)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class TodoRepository( private val api: TodoApi, private val dao: TodoDao, @IoDispatcher private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) { // SupervisorJob により個々の子タスク失敗が全体に波及しない private val fetchScope = CoroutineScope(ioDispatcher + SupervisorJob()) suspend fun fetchFromNetwork(): List<Todo> = withContext(fetchScope.coroutineContext) { api.getTodos() // Retrofit の suspend 関数 } suspend fun saveToDb(todos: List<Todo>) = withContext(ioDispatcher) { dao.insertAll(todos) } } |
- ポイント:
SupervisorJobは 子 coroutine が失敗しても他の子は継続 できるため、ネットワーク取得と DB 保存を同時に走らせても安全です。
4‑2. Room 側 DAO
|
1 2 3 4 5 6 7 8 9 |
@Dao interface TodoDao { @Query("SELECT * FROM todo") fun observeAll(): Flow<List<Todo>> // Flow でリアクティブ取得 @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(todos: List<Todo>) } |
Flowが提供されることで、DB の内容が変化した瞬間に UI が自動的に再描画されます。
4‑3. 完全なデータパイプライン
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class TodoViewModel( repository: TodoRepository, private val dao: TodoDao ) : ViewModel() { // DB の Flow を StateFlow に変換し UI が常に最新リストを取得できるようにする val todos = dao.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) init { refresh() } fun refresh() { viewModelScope.launch { try { val networkList = repository.fetchFromNetwork() launch { repository.saveToDb(networkList) } // DB 保存は別 coroutine } catch (e: IOException) { // エラーは SharedFlow へ流す(省略) } } } } |
stateInによって Cold Flow → Hot StateFlow が変換され、collectAsStateと合わせて UI 側のコードがシンプルになります。
5. 2026 年版最新 Compose API とベストプラクティス
| API | 主な役割 | 使用上の注意点 |
|---|---|---|
snapshotFlow { … } |
Compose の状態スナップショットを Flow に変換し、リアクティブに監視。 | 変更が頻繁な場合は debounce 等でバックプレッシャー対策を。 |
produceState |
suspend 関数の結果を State にラップ。 | キャンセルは内部 coroutine が自動的に処理。 |
LaunchedEffect |
Composable のライフサイクルに紐付く副作用実行。 | key が変化したときだけ再起動される点を忘れずに。 |
rememberCoroutineScope |
UI スコープ(Recomposer)にバインドされた CoroutineScope を取得。 | 画面破棄時に自動キャンセルされるが、長時間タスクは SupervisorJob と併用すると安全。 |
collectAsStateWithLifecycle (androidx.lifecycle) |
Lifecycle に合わせて Flow の収集を開始・停止。 | STARTED 以上でのみ購読することでバックグラウンド消費を抑制。 |
ベストプラクティスまとめ
- 副作用は必ず
LaunchedEffect/produceState/rememberCoroutineScopeのいずれかで起動 -
直接
launchするとライフサイクルと切り離され、メモリリークの原因になる。 -
データフローは ViewModel に集約し、
StateFlow(常時購読) とSharedFlow(One‑shot イベント) を使い分ける -
UI 側は
collectAsStateWithLifecycleで安全に購読。 -
エラーハンドリングは
runCatching+SharedFlow.emit、キャンセルはSupervisorJobとcancelChildren()の併用が基本 -
重い計算や I/O は必ず Dispatchers.IO / Default に切り替える
-
withContext(Dispatchers.Default)で CPU バウンド処理を分離。 -
snapshotFlowと Flow 演算子(debounce、filter、distinctUntilChanged) を活用し、状態変化の頻度を制御 -
テスト容易性のために
CoroutineDispatcherProviderパターンで Dispatcher を抽象化
kotlin
interface CoroutineDispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
// 本番は Dispatchers.xxx、テスト時は TestDispatcher に差し替える
6. 参考リンク
| 内容 | URL |
|---|---|
| Compose Runtime の公式解説 | https://developer.android.com/jetpack/compose/runtime |
Lifecycle‑aware Flow 収集(collectAsStateWithLifecycle) |
https://developer.android.com/jetpack/androidx/releases/lifecycle#runtime_compose |
| Kotlin Coroutines 1.8 ドキュメント | https://kotlinlang.org/docs/coroutines-overview.html |
snapshotFlow の使い方 |
https://developer.android.com/jetpack/compose/state#snapshotflow |
produceState の実装例 |
https://developer.android.com/jetpack/compose/state#producing-state |
| Mori Atsushi の記事(背景情報) | https://at-sushi.work/blog/48/ |
最後に
Compose と Coroutines は、「宣言的 UI × 非同期ロジック」 を同一スコープで安全に扱える設計が根底にあります。上記パターンとベストプラクティスをプロジェクトに組み込めば、2026 年現在の最新 API(Compose 1.6 系・Coroutines 1.8 系)でも 堅牢かつ拡張性の高い UI を実装できます。ぜひコード例を手元で試し、実際のアプリに適用してみてください。