Jetpack Compose のワンタイムイベントで SharedFlow をやめて Channel にした理由

Jetpack Compose で Snackbar、Navigation、Toast のような「一度だけ処理したいイベント」をどう扱うかは、多くの Android エンジニアが一度は悩むポイントです。

私も長い間 SharedFlow を使っていました。

しかし最終的には、Compose の UI イベント用途では Channel を使うようになりました。

この記事では、

- なぜ SharedFlow で問題が起きやすいのか
- なぜ Channel の方が自然だったのか
- Compose のライフサイクルと何が噛み合わないのか

を整理してみます。

 

🧑🏻‍💻 One-Time Event とは?

まず前提として、Compose には大きく 2 種類のデータがあります。


State
└─ 画面が「今どういう状態か」

Event
└─ 一度だけ実行したいもの

例えば:

State は「現在値」が重要です。

しかし Event は違います。

「発生した」という事実だけ重要

つまり:

- 1回だけ消費したい
- 再購読時に再実行したくない
- 画面回転で再発火したくない

という特徴があります。

 

🧑🏻‍💻 SharedFlow を使っていた頃

最初は多くのサンプルと同じように SharedFlow を使っていました。


private val _event = MutableSharedFlow<UiEvent>()
val event = _event.asSharedFlow()

UI 側では:


LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar -> {
                snackbarHostState.showSnackbar(event.message)
            }
        }
    }
}

一見すると問題なさそうです。

しかし実運用では、徐々に違和感が出てきました。

 

🧑🏻‍💻 Collector がいないとイベントが消える

SharedFlow(replay = 0) は、

購読者がいない時のイベントを保持しません

つまり:


ViewModel emits event
        ↓
Compose がまだ collect 開始していない
        ↓
イベント消失

が起きます。

特に Compose では:

- 画面遷移直後
- Lifecycle 切り替え
- BackStack 復帰
- 一時的な recomposition

などで collector の開始タイミングがズレやすいです。

 

🧑🏻‍💻 replay = 1 にすると今度は別問題

では replay = 1 にすれば良いのでしょうか?


MutableSharedFlow(
    replay = 1
)

これは確かにイベント消失を防ぎます。

しかし次は:

画面回転したら
昔のイベントが再配信される問題が発生します。

例えば:


Snackbar 表示
↓
画面回転
↓
Snackbar 再表示

これは UX 的にかなり不自然です。

Navigation だとさらに危険です。

画面回転しただけで
再度 navigate される

ことがあります。

 

🧑🏻‍💻 SharedFlow は「共有ストリーム」

ここで改めて考えると、SharedFlow は本来:

複数 collector に
同じデータを共有する

ための仕組みです。

つまり設計思想としては:

- 状態通知
- Broadcast
- Hot stream

寄りです。

一方 One-Time Event は:

- 1回だけ消費したい

なので、実は思想が少しズレています。

 

🧑🏻‍💻 Channel に変えた

そこで最終的にこうなりました。


private val _event = Channel<UiEvent>(
    capacity = Channel.BUFFERED
)

val event = _event.receiveAsFlow()

送信:


viewModelScope.launch {
    _event.send(
        UiEvent.ShowSnackbar("Saved")
    )
}

受信:


LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar -> {
                snackbarHostState.showSnackbar(event.message)
            }
        }
    }
}

 

🧑🏻‍💻 なぜ Channel の方が自然だったのか

Channel は:

Queue

です。

つまり:


送る
↓
溜まる
↓
1個ずつ消費

になります。

これは One-Time Event の性質にかなり近いです。

特に:

- Snackbar
- Navigation
- Dialog
- Toast

のような

順番に1回だけ処理したい

イベントと相性が良いです。

 

🧑🏻‍💻 BUFFERED が重要

Compose では collector が少し遅れて開始することがあります。

そのため:

Channel.BUFFERED

を使うことで、

collector 開始前のイベント

もある程度保持できます。

これは SharedFlow(replay = 0) よりかなり扱いやすく感じました。

 

🧑🏻‍💻 ただし Channel にも注意点はある

もちろん Channel が万能ではありません。

特に重要なのは:

基本的に single consumer 前提

である点です。

つまり:

複数 collector が同時に collect

すると、どこが消費するか不定になります。

そのため:

UI Event 専用

として割り切るのが重要です。

 

🧑🏻‍💻 個人的な使い分け

現在は大体こう整理しています。

かなり安定しました。

 

🧑🏻‍💻 Compose では「State」と「Event」を分けるのが重要

Compose は State 管理が非常に強力です。

しかしその反面、

Event を State と同じ感覚で扱う

と事故りやすいです。

特に:

- Snackbar
- Navigation
- Dialog
- Permission request

などは:

状態ではなく副作用

として考えた方が整理しやすいです。

 

🧑🏻‍💻 まとめ

SharedFlow は優秀ですが、

One-Time Event に完全最適

とは限りません。

Compose のライフサイクルと組み合わさると:

- イベント消失
- replay 問題
- collector timing 問題

に遭遇しやすいです。

一方 Channel は:

FIFO queue

として動作するため、

- 一度だけ処理したい
- 順番保証したい
- 再配信したくない

という UI Event とかなり自然に噛み合いました。

最近は:

State は StateFlow
Event は Channel

という形に落ち着いています。

 

🤔 参考


Kotlin by JetBrains「Jake Wharton | KotlinConfersations'26」の文字起こし

Android開発やKotlinコミュニティのキーパーソンであるジェイク・ウォートン(Jake Wharton)氏へのインタビューです。

 

🤔 1. 自己紹介とKotlinとの歩み [00:26]

経歴:
長年Androidデベロッパーとして活動。Square(現Block)、Cash Appを経て、現在はSkylightに在籍。

Kotlinとの出会い:
Square時代、Java 7の機能不足やGoogleのツール進化の遅さに悩んでいた「プレ1.0(正式リリース前)」の頃にKotlinに注目。社内向けに導入提案書(プロポーザル)を作成。

反響:
その提案書を公開したところコミュニティで大きな話題となり、SquareでのKotlin導入だけでなく、エコシステム全体の盛り上がりに繋がった。

 

🤔 2. AndroidにおけるKotlinとコミュニティの力 [01:50]

Googleでの活動:
一時期Googleに籍を置き、AndroidにおけるKotlinの公式サポート(KTXライブラリなどの立ち上げ)を支援。

エコシステムの進化:
KTXライブラリのコード自体は現在通常のライブラリに統合されて役目を終えたが、それは言語が成熟した良い証拠であると語る。

Apple(Swift)とのアプローチの違い:
iOSのSwiftがAppleという中央集権からトップダウンで提供されたのに対し、AndroidにおけるKotlinは「草の根(グラスルーツ)的なコミュニティの熱意」から始まり、最終的にGoogleが公式サポートせざるを得ない流れを作った点がユニークである。

非Androidへの広がり:
「KotlinはGoogle製でも、Android専用でもない」という点が、15年経った今ではAndroid以外の開発者にも広く認知されるようになった。

 

🤔 3. Kotlin Multiplatform (KMP) の挑戦 [05:35]

これまでのクロスプラットフォーム:
過去のXamarinやPhoneGap、現代のReact NativeやFlutterなどを評価した上で、Cash App時代にKotlin Multiplatform(KMP)とCompose Multiplatformを選択。

アプローチの特徴:
UI層はiOSならUIKit/SwiftUI、WebならHTML DOM、AndroidならCompose UIといった「各プラットフォーム独自のネイティブビュー」を尊重し、バックエンドのビジネスロジックやプレゼンターロジックのみを共通化(シェア)する方針をとった。

KMPの強み:
他のクロスプラットフォーム言語と異なり、WebならJavaScript、iOSならネイティブコード、AndroidならJavaバイトコードと、ターゲットごとに最適な形へコンパイルされるため、メモリ空間の競合や相互運用の壁(Interop layer)が少ない。

エコシステムへの貢献:
5年前に始めた当時はJetBrainsのComposeも初期段階だったため、自分たちで足りないターゲットを作るなどしてエコシステムを強力にプッシュした。

 

🤔 4. オープンソース(OSS)の重要性 [10:53]

キャリアへの影響:
オープンソースに貢献することで、自身のスキルアップだけでなく、新しい人との出会いや転職の機会など、キャリアの節目で何度も救われた。

企業とOSSの関係:
REST APIとの通信や画像読み込み、依存関係の解決(DI)など、どのアプリでも共通する「ビジネスの知的財産(IP)ではない部分」のコードはオープンにすべき。結果的に会社の採用活動(「OSSを見て応募した」という優秀な人材の獲得)など、数字に表れにくい大きなリターン(無形の資産)をもたらす。

 

🤔 5. 生成AI(LLM)とライブラリの未来 [15:11]

「AIがコード生成できるならライブラリ投資は不要か?」という問いへの持論:
「コードを書くこと(Writing code)」自体は、ソフトウェア開発において決して最も難しい部分ではない。AIは既存のスキルを加速させる(アクセラレーター)ツールとして使うべきであり、それなしではコードが書けないような依存の仕方はリスクがある(将来的なコスト高騰も含めて)。

コードは書かれる回数より、読まれる回数の方が10倍多い(Code is read 10 times more than it's written)。 要件は常に変わるため、全体を俯瞰して理解できるコード設計が重要。

ライブラリの役割:
ライブラリの真の価値は「再利用可能なコードの境界線(デリミテーション)」を明確に引き、人間の認知負荷を下げることにある。パレートの法則のように「80〜85%の共通ユースケース」を綺麗にカプセル化することが大切。人間にとって直感的で優れた設計は、言語モデル(AI)にとっても扱いやすいはずである。

 

🤔 6. 2026年現在のKotlinへの期待 [22:45]

K2コンパイラの恩恵:
長年開発が続けられてきた「K2コンパイラ」への移行(大きな山場)を乗り越えたことで、言語自体の進化スピードが再び加速している。

注目している新機能:
Rich Errors(エラー処理の改善)プロポーザルの刷新
未使用の戻り値チェッカー(Unused return value checker)
when 式のデフォルト網羅性(Exhaustive when by default)

現在の心境:
K2の開発に全力を注いでいた停滞期を抜け出し、標準ライブラリ(datetimeやco-routinesなど)や言語プロポーザルが活発に進化している今の状況は、初期のKotlinのワクワク感を思い出させる。

 

🤔 7. コミュニティへの参加に気後れしている人へのアドバイス [27:30]

提案書(KEEP)の難しさ:
KEEP(Kotlin Evolution and Enhancement Process)はコンパイラの構文解析や各プラットフォームのバイトコードまで考慮しなければならず、内容が非常に高度で圧倒されるのは当然。

おすすめの関わり方:
Kotlin公式Slack(Kotlinlang Slack)にある language-proposal や language-evolution チャンネルを覗いてみるのがおすすめ。そこでは、より人間的で身近な困りごとや質問が、消化しやすい形で活発に議論されている。


Androidアーキテクチャの現状:GoogleのUDFと現実世界の「MVI風MVVM」の比較

現代のAndroid開発においてどのアーキテクチャを採用するかを議論する際、議論はほぼ常にMVVM対MVIのどちらにするかという点に集約されます。

しかし、近年の傾向を見ると、この2つは対立する力ではなく、収束しつつあることは明らかです。

 

🧑🏻‍💻 Googleの立場:特定のパターン名よりもUDFを優先

Googleの公式「アプリアーキテクチャガイド」を詳しく見てみると、「MVI」という用語はほとんど出てこないことに気づくでしょう。その代わりに、Googleは一貫してUDF(単方向データフロー)という概念を推奨しています。

Googleは、コミュニティに既に馴染みのある「MVI」ではなく、なぜ抽象的な用語である「UDF」を使用するのでしょうか?

その理由は、Googleが柔軟性を重視していることにあり、彼らの哲学は次のようなものと思われます。

「MVIのような厳格な命名規則にとらわれすぎないでください。アーキテクチャの本質である一方向のデータフローが維持されるようにするだけで十分です。」

Reduxのような厳密な状態管理ライブラリを使用する場合でも、シンプルなViewModelで実装する場合でも、Googleが重視するのは「状態は下へ流れ、イベントは上へ流れる」という基本原則に従うことです。

 

🧑🏻‍💻 現代の標準:MVI風MVVMの実装

現場では、Googleが推奨するUDFに対する最も一般的なアプローチは、私が「MVI風味のMVVM」と呼ぶもので、MVIルールをMVVMコンテナに詰め込むものです。これには、状態、意図、効果という3つのコア要素が含まれます。

実際には以下のようになります。


Hiltのナビゲーション引数で SavedStateHandle から卒業する

なぜ Hilt 2.49+ と @AssistedInject が、完全な型安全性を備えた引数渡しの「正解」と言えるのか。

 

🤔 問題点:静的な依存関係と動的なデータの混在

Android開発において、ViewModelにランタイム引数(実行時引数)を注入することは、常に議論の的となってきました。

その本質は、「静的依存関係」(DIによって管理されるリポジトリなど)と、「動的データ」(画面遷移時に渡されるナビゲーション引数など)の対立にあります。

適切なDIパターンを適用せずにこの問題に対処しようとすると、高確率でアンチパターンに陥ります。たとえば、一時的な状態をシングルトン(Singleton)内に保持してしまうと、状態の汚染や画面間でのデータ漏洩といった致命的なバグを引き起こす危険性があります。

 

🤔 解決策:ハイブリッド注入を実現する @AssistedInject

この「静的」と「動的」のギャップを埋めるための最適なツールが、@AssistedInject です。これを使用することで、DI(Hilt)が管理するオブジェクトと、実行時に受け取るランタイムパラメータをスマートに組み合わせた「ハイブリッド注入」が可能になります。

通常の @Inject は不要 :
@AssistedInject を使用する場合、コンストラクタに通常の @Inject を付ける必要はありません(というか、付けてはいけません)。

関心の分離 :
リポジトリなどの依存関係は Hilt が自動で供給し、ナビゲーション引数などの動的なデータだけを手動で安全に渡す、という明確な役割分担(関心の分離)が実現します。

クリーンなコードへ :
これにより、バグの温床になりがちだった危険な lateinit var による後からの初期化コードとは、完全におさらばできます。


// ViewModel 実装

@HiltViewModel(assistedFactory = RouteBViewModel.Factory::class) 
class  RouteBViewModel  @AssistedInject constructor( 
    private val repository: MyRepository,
    @Assisted val navKey: RouteB,
) : ViewModel() { 

    @AssistedFactory
    interface Factory { 
        fun create (navKey: RouteB) : RouteBViewModel 
    } 
}

 

🤔 実装:assistedFactory と hiltViewModel の組み合わせ

公式の nav3-recipes リポジトリでは、Hilt 2.49 以降で導入された最新の標準的な実装パターンが示されています。

ファクトリの宣言 :
@HiltViewModel(assistedFactory = ...) を使用して、Assisted Factory を ViewModel に直接リンクさせます。

2つの型パラメータ: Compose 側では、hiltViewModel() のように両方の型を明示的に指定して呼び出します。

安全なライフサイクル管理 :
creationCallback を利用することで、ナビゲーションのバックスタック管理(Lifecycle)を壊すことなく、安全にランタイム引数を渡すことができます。


// UI(ナビゲーション定義)

 entry<RouteB> { key -> 
    val viewModel = hiltViewModel<RouteBViewModel, RouteBViewModel.Factory>( 
        creationCallback = { factory -> 
            factory.create(key) 
        } 
    ) 
    ScreenB(viewModel = viewModel) 
}

 

🤔 メリット:100%の型安全性を実現し、SavedStateHandle のボイラープレートを完全に排除

このアーキテクチャ(Navigation 3 + @AssistedInject)に移行することで、コードベースの品質は大幅に向上します。

完全な型安全性(100% Type-Safe):
かつての文字列ベースのキー(string のキー指定)は過去のものです。ナビゲーション引数は、Route(Serializable等で定義された型)として、厳密に型指定されたオブジェクトのまま直接安全に渡されます。

SavedStateHandle からの解放 :
単に次の画面へ引数を渡すだけのために、SavedStateHandle を使ってごにょごにょと値を書き出したり読み出したりする定型文(ボイラープレート)はもう一切不要になります。

圧倒的なリファクタリングのしやすさ :
すべてがコンパイル時にチェックされるため、引数の追加・削除・変更といったリファクタリングが、一瞬かつ確実に(ランタイムエラーの心配なく)行えるようになります。

 

🤔 まとめ

Hilt と最新の Jetpack Navigation の相乗効果により、画面間の引数の受け渡しは驚くほどスムーズかつ洗練されたものになりました。もはや、型安全性を犠牲にしたり、不自然なアーキテクチャで回避策(ワークアラウンド)を講じたりする必要はありません。

@AssistedInject と、進化した hiltViewModel() API を組み合わせることで、「完璧な型安全性」と「クリーンなハイブリッド依存性注入(DI)」を今すぐあなたのプロジェクトに導入できます。


「process death」とは何?

Androidにおける Process Death (プロセス終了) とは、システムがメモリ不足(Memory Pressure)になった際に、バックグラウンドにあるアプリのプロセスを OS が強制的に終了させる仕組みのことです。

ユーザーがアプリを閉じたり明示的に終了させたりする「通常の終了」とは異なり、OS側の都合で実行されるため、適切な対策をしないとアプリに戻った際にデータが消えてしまう原因になります。

 

🤔 なぜ気ににする必要があるのか

長いお問い合わせフォームを入力中に、少し調べ物をして戻ったら全部消えていた。

ECサイトで商品を比較していたのに、トップ画面に戻された。

「このアプリは不安定だ」「使いにくい」と思われ、アンインストールや低評価に直結します。

 

🤔 予期せぬクラッシュを防ぐため

Process Death からの復帰時、OSは「最後に開いていた画面」をいきなり表示しようとします。

もし、その画面が 「前の画面から渡されたデータ(IDなど)」 に依存しているのに、それをメモリ上の変数(ViewModelのフィールドなど)にしか持っていなかった場合、復帰した瞬間にデータが null や空になり、アプリがクラッシュします。

 

🤔 「バックグラウンド=一時停止」ではない

Androidの設計思想では、アプリがバックグラウンドに回った瞬間から、いつ消されても文句は言えないことになっています。

「メモリが潤沢にあるから大丈夫だろう」という想定は、現代のAndroid(特にバックグラウンドで動くサービスや重いゲームが多い環境)では通用しません。

「プロセスは必ずいつか死ぬ」という前提で設計することが、Android開発におけるプロフェッショナルな作法とされています。

 

🤔 開発時の確認方法

以下で開発時に確認しておくのがいいです。


1. アプリを起動したあと、ホーム画面に移動する。(終了させるのではなくて、バックグラウンドに移動する)

2. adb shell am kill <アプリのパッケージ名>

3. アプリの起動履歴から再度アプリを選択する。

注意: am force-stop とは異なり、am kill はバックグラウンドにいるアプリに対して「メモリが足りなくなったからシステムが回収した」という状態をシミュレートします。

起動履歴から再度アプリを起動したときに、バックグラウンドに移動する前の画面と同じものが表示されれば OK です。

 

🤔 まとめ

上記手順でやってみました。

これでOK!!