mcpField
K
kotlin-flow-state-event-modeling

kotlin-flow-state-event-modeling

github.com/chrisbanes開発1.1k1.0k 回インストール1 日前に更新
リポジトリ

kotlin-flow-state-event-modeling について

状態とイベントのモデル化に使う Flow プリミティブの選択と利用を支援する Kotlin スキルです。再生、配信、同期読み取りの要件に合わせ、共有状態の問題を避けます。

主な機能

  1. 01StateFlow、SharedFlow、Channel、Flow の特性を解説
  2. 02不在や読み込み状態のモデル化を案内
  3. 03StateFlow のアトミックな更新を提示
  4. 04stateIn の共有と起動方式を解説
  5. 05同期的な value アクセスの維持方法を説明

活用シーン

  1. 01一度だけのナビゲーションや Snackbar イベントのモデル化
  2. 02同期読み取りに使う状態ストリームの選択
  3. 03並行コルーチンによる更新消失の防止
  4. 04Flow の共有方法やプレースホルダー状態のレビュー

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/chrisbanes/skills --skill kotlin-flow-state-event-modeling

インストール後、Claude Code で/kotlin-flow-state-event-modelingを入力して読み込まれたか確認します。

詳細

Kotlin Flow:状態とイベントのモデリング

基本原則

リプレイ、ファンアウト、同期読み取りの要件に合ったプリミティブを選んでください。 StateFlow、SharedFlow、Channel を基盤とするフロー、コールド Flow は、バッファリング、各エミッションを受け取る対象、.value の有無がそれぞれ異なります。誤った選択をすると、イベントが失われたり、共有コルーチンがリークしたり、状態に偽のドメイン用センチネル値を持ち込むことになります。

このスキルを使う場面

次のような Kotlin コードを記述またはレビューしている場合です。

  • 実際の値が非同期で取得されるため、MutableStateFlow<T>(SomeSentinel) — NoUser、Empty、Loading など — を使っている
  • .stateIn(...) をプロパティに代入せず、関数内で呼び出している
  • .value を同期的に読み取り、常に最新の値である必要があるフローに SharingStarted.WhileSubscribed(...) を使っている
  • ナビゲーションイベント、スナックバーなど、失われると問題になる一度限りのエミッションに MutableSharedFlow を使っている
  • 利用側で引き続き同期的な .value が必要な StateFlow に .map { } を使っている
  • MutableStateFlow.value = _state.value.copy(...) を使っている、または update { ... } 内でコストの高いオブジェクトを構築する更新コードを書いている

一度だけ処理する単一コンシューマーのイベントには SharedFlow

SharedFlow のデフォルトでは、リプレイバッファーがありません。エミッションの瞬間に収集しているものがなければ、イベントは失われます。ナビゲーションやスナックバーなどのイベントを一度だけ処理する単一の UI コンシューマーには、Flow として公開するバッファー付き Channel のほうが、セマンティクスに合う場合がよくあります。

// ❌ BAD
private val _navEvents = MutableSharedFlow<NavigationEvent>()
val navEvents: SharedFlow<NavigationEvent> = _navEvents.asSharedFlow()

// ✅ GOOD
private val _navEvents = Channel<NavigationEvent>(Channel.BUFFERED)
val navEvents: Flow<NavigationEvent> = _navEvents.receiveAsFlow()

Channel.receiveAsFlow() はブロードキャストではなくファンアウトです。複数のコレクターがいる場合、各イベントはいずれか1つのコレクターに配信されます。Channel.BUFFERED の容量には上限があるため、送信が一時停止したり、trySend が失敗したりすることがあります。複数のオブザーバーが同じイベントをすべて受け取る必要がある場合は、明示的な状態、永続ストレージ、または意図的に設定した SharedFlow を使ってください。

無効なセンチネル値で汚染された StateFlow

StateFlow には初期値が必要です。実際の値が非同期で取得される場合、開発者は NoUser、EmptyUser、プレースホルダー ID などの偽のドメイン値を用意することがあります。その結果、すべての利用側でそのセンチネル値を実データとして扱わなければならなくなります。

// ❌ BAD — sentinel leaks into the type
class UserSession(private val db: Db) {
    private val _user = MutableStateFlow<User>(NoUser)
    val user: StateFlow<User> = _user.asStateFlow()
    init { scope.launch { _user.value = db.load() } }
}

解決策の1つは段階化です。実際の値が存在するまでは StateFlow を公開しないようにします。

// ✅ GOOD — bootstrap suspends; observers only see real users
class UserSession(private val db: Db) {
    private var _user: MutableStateFlow<User>? = null
    val user: StateFlow<User>
        get() = checkNotNull(_user) { "Call login() first" }

    suspend fun login() {
        _user = MutableStateFlow(db.load())
    }
}

値がない状態、読み込み中、エラーが実際の状態である場合は、それを明示的にモデル化してください(User?、sealed interface UserUiState、Result など)。問題なのは、偽のドメイン値を実データのように見せかけることであり、初期値があること自体ではありません。

update { ... } で MutableStateFlow を変更する

.value を読み取って書き戻すよりも、MutableStateFlow.update { current -> ... } を使うことをおすすめします。update は最新の状態に対して変換をアトミックに適用するため、複数のコルーチンが同じ状態を変更するときの更新の取りこぼしを防げます。

// BAD — read/modify/write can lose concurrent updates.
_state.value = _state.value.copy(
    selectedId = id,
    details = details,
)

// GOOD — transform starts from the latest state.
_state.update { current ->
    current.copy(
        selectedId = id,
        details = details,
    )
}

現在の状態が必要な場合を除き、オブジェクトの生成は update ブロックの外で行ってください。更新ラムダは再試行されることがあるため、コストの高い処理や副作用が含まれていると、複数回実行される可能性があります。

// GOOD — details does not depend on current state, so build it once.
val details = Details.from(response)
_state.update { current ->
    current.copy(details = details)
}

// GOOD — derived value depends on current state, so compute it inside.
_state.update { current ->
    val nextItems = current.items.replaceById(updatedItem)
    current.copy(items = nextItems)
}

ブロック内では、状態を純粋かつ高速に変換してください。値をブロックの前に取得しておく場合を除き、ネットワーク呼び出し、データベースへの書き込み、ログ出力などの副作用、ランダム ID の生成、時刻の読み取りは行わないでください。

関数内での stateIn()

// ❌ BAD — new sharing coroutine every call
fun getPreferences(): StateFlow<Prefs> =
    repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default)

getPreferences() を呼び出すたびに、新しいコルーチンが scope 上で起動し、完了しないまま残ります。繰り返し読み取ると、すぐにパフォーマンスが低下します。

// ✅ GOOD — one shared instance, computed once
val preferences: StateFlow<Prefs> =
    repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default)

同期的な .value と WhileSubscribed

SharingStarted.WhileSubscribed(timeout) は、アクティブなコレクターがいなくなると上流との接続を切断します。切断中に .value を読み取ると、最後にキャッシュされた値が返ります。この値は古いか、初期値のままの可能性があります。

ルール: .value が常に最新である必要がある場合や、アクティブなコレクターがいなくても初期化済みである必要がある場合は、SharingStarted.Eagerly または明示的な初期化を使ってください。古い値やキャッシュされた値を許容でき、利用側が主に非同期で収集する場合は、WhileSubscribed で問題ありません。

StateFlow の .map では .value が失われます

// ❌ BAD — `name.value` won't compile; it's now a plain Flow
val name: Flow<String> = userState.map { it.name }

同期的な .value が必要な場合は、チェーンを .stateIn(...) で終端させます。

// ✅ GOOD
val name: StateFlow<String> = userState
    .map { it.name }
    .stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name)

コミュニティ製の「派生 StateFlow」ユーティリティは、.value を読み取るたびに変換処理を実行します。高速で冪等な変換に限って使用してください。基本的には .stateIn(...) を使います。

選択の指針:どの Flow 型を使うべきか?

必要なものプリミティブ
常に値を持ち、非同期コレクターと同期コードの両方から読み取られる状態StateFlow。.value が重要な場合は、SharingStarted.Eagerly を使うことがよくあります
ホットストリーム、複数のサブスクライバー、同期的な .value は不要SharedFlow
1 つのコンシューマーに対する個別のイベント、厳密に 1 回の受け渡しChannel(BUFFERED).receiveAsFlow() を検討します
コレクションごとに 1 つのコンシューマーを持つコールドストリーム通常の Flow

SharedFlow を使おうとしている場合は、次の点を確認してください。値の欠落が不具合になるか、また何個のコンシューマーが値を受け取る必要があるでしょうか?1 つのコンシューマーがイベントを厳密に 1 回処理する必要がある場合は、Channel が適している可能性があります。すべてのオブザーバーがイベントを受け取る必要がある場合は、永続的な状態としてモデル化するか、ブロードキャストストリームを意図的に設定してください。

クイックリファレンス

症状問題修正
MutableStateFlow<X>(FakeDomainValue)無効なプレースホルダーのデフォルト値値がない状態を明示的にモデル化するか、段階的な初期化を使います
1 つのコンシューマー向けナビゲーション/スナックバーに MutableSharedFlow<Event> を使うデフォルトのイベントストリームでは値が失われますChannel(BUFFERED).receiveAsFlow() を検討します
fun foo() = flow.stateIn(...)呼び出しごとに共有コルーチンが作成されますval または共有インスタンスにします
WhileSubscribed を使い、.value を最新の状態または初期化済みの状態にする必要がある古いデータまたは初期データSharingStarted.Eagerly または明示的な初期化を使います
stateFlow.map { ... } を状態として使用する.value が失われます.stateIn(...) で終端させます
_state.value = _state.value.copy(...)非アトミックな読み取り/変更/書き込み_state.update { it.copy(...) }
現在の状態を使わない高コストなオブジェクト生成を update { ... } 内で行うupdate が再試行されると処理が繰り返される可能性がありますupdate の前に生成し、現在の状態を使う変換だけを内部に置きます

レビュー時の危険信号

考え実際のところ
「サブスクライバーが複数いるので SharedFlow が必要です」サブスクライバーが複数いると、セマンティクスが変わります。Channel.receiveAsFlow() はブロードキャストではありません。イベントモデルを意図的に選択してください。
「リソースを節約するために WhileSubscribed を使います」.value の古い値や初期値を許容できる場合に限ります。適用する前に確認してください。
「実際のデータが読み込まれるまでセンチネル値を使います」コンシューマーはそれを実際のドメイン値として扱います。明示的な UI/状態のモデリング、または段階的な初期化を優先してください。
「便利なので、新しいオブジェクトを update 内で構築します」ラムダは再試行される可能性があります。現在の状態に依存しない限り、外部で構築してください。

関連項目

  • kotlin-control-flow — 状態やイベントをモデル化する際の when、ガード条件、網羅性、スマートキャスト、早期リターンの選択。
  • kotlin-coroutines-structured-concurrency — スコープの所有、初期化時の起動、ファイア・アンド・フォーゲットの境界、キャンセル、runBlocking
  • compose-side-effects — イベントフローの収集と Compose での副作用の接続
  • compose-state-hoisting — 状態ホルダーからプレーンな状態駆動型 UI にフローを公開する場所

コメント