1. 現場の病理:「一過性のイベントはストリームで流せばいい」という根深い思い込み

AndroidのGUIアプリケーション開発において、ViewModelからUI(Activity / Fragment / Composable)へ一度だけ実行したいアクション——いわゆる「ワンショットイベント(Single-shot Event)」をどのように伝達するかという問いは、長年にわたり幾多のエンジニアを悩ませてきました。

代表的な例としては、次のようなものが挙げられます。

この要件に対し、Androidの歴史は「間違ったパターンの流行と陳腐化」の繰り返しでした。かつてJava全盛期には LiveData<Event<T>> や、Googleの公式サンプルが提示したことで現場に猛烈にコピペされた SingleLiveEvent が乱立しました。そしてKotlin CoroutinesとKotlin Flowの台頭とともに、多くのエンジニアが「LiveDataは古い!これからはコルーチンとFlowの時代だ!」と歓喜し、次のようなコードを書き始めました。

// ❌ 現場で今なお量産されている典型的なアンチパターン
sealed interface CheckoutUiEvent {
    data class ShowToast(val message: String) : CheckoutUiEvent
    data object NavigateToSuccess : CheckoutUiEvent
}

class CheckoutViewModel(
    private val paymentRepository: PaymentRepository
) : ViewModel() {

    // 「単発イベントだからキャッシュ(replay)は不要」という浅はかな確信
    private val _eventFlow = MutableSharedFlow<CheckoutUiEvent>()
    val eventFlow: SharedFlow<CheckoutUiEvent> = _eventFlow.asSharedFlow()

    fun onPayButtonClicked() {
        viewModelScope.launch {
            val result = paymentRepository.executePayment()
            if (result.isSuccess) {
                _eventFlow.emit(CheckoutUiEvent.NavigateToSuccess)
            } else {
                _eventFlow.emit(CheckoutUiEvent.ShowToast("決済に失敗しました: ${result.error}"))
            }
        }
    }
}

一見すると、非常にモダンでクリーンなKotlinコードに見えます。プレゼンテーション層とロジック層が分離され、リアクティブなFlowで結合されています。しかし、この実装こそが、本番環境で「再現頻度:低(たまに起きる)」とレポートされる最悪の地雷の震源地です。

「端末を横向きに回転させながら決済ボタンを押したら、エラーメッセージが出ずに画面が固まった」
「画面回転した瞬間、すでに完了したはずの画面遷移がもう一度走ってアプリがクラッシュした」

なぜこのような怪奇現象が多発するのか? その物理的なメカニズムを、Androidのライフサイクルとストリームの内部構造から解き明かしていきましょう。

2. 第1の地雷:`MutableSharedFlow(replay = 0)` による不可逆イベント消失

まず、もっとも被害件数が多いのが MutableSharedFlow(replay = 0)(デフォルト設定)を採用したケースです。

エンジニアは「1回だけ通知したいイベントだから、過去の値をバッファ(replay)したくない」と考えます。しかし、SharedFlow の本質は「ホットストリーム(Hot Stream)」です。ホットストリームの絶対的な仕様として、「emit() が実行されたまさにその瞬間に、アクティブに購読(collect)しているコレクターが存在しなければ、値は即座に破棄(Drop)される」という動作があります。

ここに、モダンAndroidにおける必須プラクティスである「ライフサイクル対応購読」が組み合わさることで、完全なトラップが完成します。

// UI側(Activity / Fragment / Compose)の受信処理
viewLifecycleOwner.lifecycleScope.launch {
    // 画面が非表示(STOPPED以下)のときは購読を停止し、リソース消費を防ぐ鉄則
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.eventFlow.collect { event ->
            when (event) {
                is CheckoutUiEvent.ShowToast -> showToast(event.message)
                CheckoutUiEvent.NavigateToSuccess -> findNavController().navigate(R.id.action_success)
            }
        }
    }
}

バックグラウンド時や画面破棄時に不要な描画処理を走らせないため、Googleは repeatOnLifecycle(Lifecycle.State.STARTED) や Compose の collectAsStateWithLifecycle() を使うことを強く推奨しています。これにより、画面がバックグラウンドに回ったり、画面回転(Configuration Change)でActivityが再生成されたりする際、コレクターのコルーチンは自動的にキャンセルされます。

では、この環境下で次のシナリオが発生したらどうなるでしょうか?

  1. ユーザーが「決済ボタン」をタップする。バックグラウンドで非同期通信(APIリクエスト)が開始される。
  2. ユーザーが通信を待つ数秒の間に、端末の向きを回転させる(またはLINEの着信が来て画面が一時停止する)。
  3. 画面回転により、古いActivityが破棄され、新しいActivityが再生成される。この過渡期の間、ライフサイクルは STOPPED に落ち、repeatOnLifecycle ブロックはキャンセルされる。
  4. まさにこの瞬間、非同期通信が完了し、ViewModel内で _eventFlow.emit(CheckoutUiEvent.ShowToast("残高不足")) が実行される。

このとき、eventFlow の購読者数(subscriptionCount)は ゼロ(0) です。

SharedFlow(replay = 0) は何のエラーも吐かず、例外もスローせず、イベントを宇宙の彼方に静かに破棄します。その後、新しいActivityの初期化が完了して STARTED に復帰し、再び collect が開始されたとしても、過去に捨て去られたイベントが届くことは二度とありません。

結果として、ユーザーの前にはエラー表示が一切現れず、画面は決済ボタンが押せる状態のまま静止します。ユーザーは「あれ?ボタンが反応しなかったのかな?」と判断し、もう一度ボタンをタップして二重決済(二重注文)を引き起こすのです。

「じゃあ replay = 1 にすれば解決するのでは?」という悪魔の囁き

このロスト問題を報告されたエンジニアが次にやりがちなのが、「じゃあ直近1件を保持すればいいんだな」と MutableSharedFlow(replay = 1) に変更することです。

これは火に油を注ぐ行為です。replay = 1 にした瞬間、画面回転を行うたびに新しいActivityが前回の最新イベント(例: NavigateToSuccess)を再度 collect してしまいます。結果、端末を縦から横にするたびに画面遷移が暴発し、BackStackが破壊されるという新たな地獄を生み出すだけです。

3. 第2の地雷:`Channel` が引き起こす「キュー競合」「ゴースト発火」「プロセス死」

「SharedFlowがダメなら、未受信のイベントを内部キューに溜めてくれる Channel を使えばいいじゃないか」——そう考えて Channel<T>(Channel.BUFFERED).receiveAsFlow() を採用するアーキテクチャも頻繁に見受けられます。

// ❌ Channel による安易なバッファリング
class CheckoutViewModel : ViewModel() {
    private val _eventChannel = Channel<CheckoutUiEvent>(Channel.BUFFERED)
    val eventFlow = _eventChannel.receiveAsFlow()
    // ...
}

確かに Channel はバッファを持つため、コレクターが不在の間にイベントが届いても消失することはありません。しかし、Channel には別の破壊的な現場の地雷が埋まっています。

地雷A: 画面再生成時の「イベント奪い合い(Race Condition)」

Channel の本質は「1対1のパイプライン(Queue)」です。複数の受信者が同時に存在する場合、要素は 1つの受信者にしか届きません(Fan-out動作)。

Androidの画面再生成時、古いActivity/Fragmentの終了処理と、新しいインスタンスの初期化処理は、完全に直列ではなくミリ秒単位で並行して動作する瞬間があります。もし古い画面のコレクターが停止する直前と、新しい画面のコレクターが開始するタイミングが重なると、破棄されゆく古い画面がイベントを奪い取って破棄され、新しい画面には何も届かないという不可解なレースコンディションが発生します。

地雷B: 時間差によるコンテキスト喪失(ゴースト発火)

ユーザーが通信中にホーム画面に戻り、10分後に再びアプリを開いたとします。Channel に溜まっていた「決済完了ダイアログの表示」という命令は、10分後に画面がフォアグラウンドに戻った瞬間に突然再生されます。ユーザーの思考コンテキストは完全に切れているため、「いきなり何が起きたのか分からない」という極めて不快なUXを提供することになります。

地雷C: OSによるプロセス破棄(Process Death)耐性の完全な欠如

Android開発で絶対に忘れてはならないのが、端末メモリ逼迫時にOSのLow Memory Killerによってアプリプロセス全体が強制終了される「Process Death」です。

ViewModelは画面回転(Configuration Change)には耐えられますが、Process Deathの前には無力です。ヒープメモリ上に保持された Channel の未処理バッファは、プロセスが殺された瞬間にすべて消滅します。ユーザーがタスクスイッチャーから復帰した際、画面の状態だけが SavedStateHandle から中途半端に復元され、Channelにあったはずの重要イベントは消え去り、システムの整合性は回復不能になります。

4. 思想的根本原因:「宣言的UI」に「命令型メッセージング」を持ち込むな

なぜ、どれほど技巧的なFlowのラッパーを作っても、このワンショットイベント問題は解決しないのでしょうか?

その根本原因は、技術的なツールの選択ミスではなく、「UIイベントを命令(Command / Action)として扱っている思想そのもの」にあります。

Jetpack ComposeをはじめとするモダンUIパラダイムの基礎は、以下の数式で表される「宣言的UI(Declarative UI)」です。

UI = f(State)
「画面の表示(UI)とは、ある瞬間における状態(State)の純粋な射影(Projection)である」

宣言的UIの世界において、画面はいつでも破棄され、いつでも再生成されます。画面が何回回転しようが、プロセスが死んで蘇ろうが、「同じState(状態)が渡されれば、100%同一のUIが決定論的(Deterministic)に復元される」ことがアーキテクチャの絶対的な契約(Contract)です。

しかし、「Toastを表示せよ」「画面遷移を実行せよ」というワンショットイベントは、状態(State)ではありません。それは「時間軸の特定の瞬間にしか存在しないパルス信号(命令型メッセージ)」です。

「いつでも死んで生き返る非同期ライフサイクルの世界」に対して、時間依存のパルス信号を流し込もうとするから、タイミングがズレた瞬間に「消失(Drop)」するか「多重再生(Replay)」するかの二者択一の破綻に追い込まれるのです。

5. 現場の決定解:イベントを「UI State」に溶かし込む(State-based Event Modeling)

では、Google公式アーキテクチャガイド(UI events in ViewModel)でも明確に示されている、真に堅牢な解決策とは何でしょうか?

答えは極めて明快です。「イベントをストリームに流すな。イベントをUI Stateの中に『状態』として持たせろ。」

「エラーToastを表示する」という現象を命令型メッセージとして捉えるのではなく、「画面が 『未読のエラーメッセージを保持している状態』 に遷移した」と捉え直すのです。そして、UI側が表示を終えたら、ViewModelに「消費完了(Dismissed)」を通知して状態をリセットします。これこそが、完全な単方向データフロー(UDF)です。

ステップ1: メッセージを「一意なIDを持つ状態」としてモデル化する

まず、一過性のメッセージをUI State内にモデリングします。重要なのは、各メッセージに一意な識別子(ID)を付与することです。

// メッセージを一意なIDとともにカプセル化
data class UserMessage(
    val id: Long, // UUIDや nanoTime などの一意なID
    val text: String
)

// UI Stateにメッセージリスト(または null 許容型)を持たせる
data class CheckoutUiState(
    val isLoading: Boolean = false,
    val userMessages: List<UserMessage> = emptyList(),
    val isPaymentSuccess: Boolean = false
)

ステップ2: ViewModelで状態を発行し、「消費完了」を受け取る

ViewModelは MutableStateFlow のみを公開します。SharedFlow や Channel は1行も登場しません。

class CheckoutViewModel(
    private val paymentRepository: PaymentRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(CheckoutUiState())
    val uiState: StateFlow<CheckoutUiState> = _uiState.asStateFlow()

    fun onPayButtonClicked() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            val result = paymentRepository.executePayment()
            
            if (result.isSuccess) {
                _uiState.update { 
                    it.copy(isLoading = false, isPaymentSuccess = true) 
                }
            } else {
                val newMessage = UserMessage(
                    id = System.nanoTime(),
                    text = "決済に失敗しました: ${result.error}"
                )
                _uiState.update { 
                    it.copy(
                        isLoading = false, 
                        userMessages = it.userMessages + newMessage
                    ) 
                }
            }
        }
    }

    // 【最重要】UI側で表示処理が完了したことを受け取り、状態から除外する
    fun onUserMessageDismissed(messageId: Long) {
        _uiState.update { state ->
            state.copy(userMessages = state.userMessages.filterNot { it.id == messageId })
        }
    }
}

ステップ3: Compose(UI側)で「表示」と「消費」を決定論的に連携させる

Jetpack Compose側では、LaunchedEffect とメッセージのIDを組み合わせて表示を行います。

@Composable
fun CheckoutScreen(
    viewModel: CheckoutViewModel,
    snackbarHostState: SnackbarHostState
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // メッセージの監視と表示処理
    uiState.userMessages.firstOrNull()?.let { message ->
        // message.id を key に指定することで、同じメッセージに対する重複実行を物理的に防ぐ
        LaunchedEffect(message.id) {
            // SnackBarを表示(ユーザーが閉じる、またはタイムアウトするまで待機)
            snackbarHostState.showSnackbar(message.text)
            
            // 表示が完了した(またはDismissされた)ので、ViewModelに消費完了を通知
            viewModel.onUserMessageDismissed(message.id)
        }
    }

    // メインUIの描画
    CheckoutContent(
        isLoading = uiState.isLoading,
        onPayClick = viewModel::onPayButtonClicked
    )
}

なぜこの「State駆動モデリング」は絶対に壊れないのか?

この設計の真価は、過酷なエッジケースを検証したときに明らかになります。

6. 画面遷移(Navigation)の勘違い:ViewModelからルーティングを発火させるな

ワンショットイベントの中でも、もっとも不必要な複雑さを生み出しているのが「画面遷移」です。

現場では、「ボタンを押したら画面遷移させたい」という理由だけで、次のようなコードが平然と書かれています。

// ❌ 画面遷移のためだけにFlowのピンポンを行う不毛なコード
fun onSettingButtonClicked() {
    viewModelScope.launch {
        _eventFlow.emit(NavigateToSettings) // なぜわざわざViewModelを経由するのか?
    }
}

画面遷移の設計においては、以下の2つの原則を徹底的に叩き込んでください。

原則1: ユーザー起点の即時遷移は、UI層(Compose)内で直接実行せよ

「設定ボタンを押したら設定画面を開く」「リストの項目を押したら詳細画面を開く」といった、ビジネスロジックの検証を伴わない単純な画面遷移。これらは纯粋な「UIコンポーネントのルーティングの関心事」です。

ViewModelにメソッドを生やし、コルーチンを立ち上げ、Flowで通知し、UI側でcollectして navController.navigate() を呼ぶ——この一連の儀式には何の価値もありません。Composeのコールバック引数(onNavigateToSettings: () -> Unit)を直接発火させれば、わずか1行で済みます。

原則2: ビジネスロジック完了に伴う遷移は「完了状態(State)」として表現せよ

「API呼び出しが成功したら完了画面へ飛ばしたい」というケース。これも「遷移せよという命令」ではなく、「注文が完了したという状態(isPaymentSuccess = true)」としてUI Stateに持たせるのが正解です。

// UI側で状態の変化を監視して遷移
LaunchedEffect(uiState.isPaymentSuccess) {
    if (uiState.isPaymentSuccess) {
        navController.navigate("order_complete") {
            // バックスタックから決済画面を取り除き、二重遷移を防止
            popUpTo("checkout") { inclusive = true }
        }
    }
}

画面遷移が実行されると、現在の画面はバックスタックからポップされ、破棄されます。メッセージパッシングのような「届いたか?消えたか?」という曖昧なタイミング問題は、「状態が満たされたから遷移し、画面ごと破棄された」という確定的なライフサイクルによって美しく清算されます。

7. 「Stateをクリアするコードを書くのが面倒」という怠惰への処方箋

ここまで解説すると、ほぼすべてのエンジニアが納得しつつも、心の中でこう呟きます。

「理屈は分かった。でも、イベントを1つ通知するたびに、IDを発行して、Stateのリストに詰めて、UIで表示して、ViewModelに消費完了メソッドを作って消す……正直、ボイラープレートが多すぎて面倒くさい」

この「面倒くさい」という感情こそが、エンジニアを再び SharedFlow や Channel の甘い罠へと引きずり戻す悪魔の囁きです。しかし、よく考えてみてください。

数行のコードを書く手間をケチった結果、現場では何が起きているでしょうか?

状態のライフサイクルを明示的に閉じる責任を引き受けることは、堅牢なシステムを構築するための「必要経費」です。どうしても記述量を減らしたいのであれば、次のような汎用マネージャーを1つ用意してViewModelで委譲(Composition)すれば、ボイラープレートは完全に消滅します。

// 共通化のためのメッセージマネージャー
class UiMessageManager {
    private val _messages = MutableStateFlow<List<UserMessage>>(emptyList())
    val messages: StateFlow<List<UserMessage>> = _messages.asStateFlow()

    fun emit(text: String) {
        val message = UserMessage(id = System.nanoTime(), text = text)
        _messages.update { it + message }
    }

    fun clear(id: Long) {
        _messages.update { list -> list.filterNot { it.id == id } }
    }
}

8. まとめ:命令型パルスを捨て、決定論的アーキテクチャを選べ

本稿で論じた各アプローチの特性を、マトリクス表で整理します。

手法 画面回転時のイベント消失 画面回転時の多重発火 Process Death耐性 決定論的動作 (UI=f(State)) 総合評価
SharedFlow(replay = 0) ❌ 確実に消失する ⭕ なし ❌ 完全消失 ❌ 破綻 アンチパターン(絶対禁止)
SharedFlow(replay = 1) ⭕ なし ❌ 確実に暴発する ❌ 完全消失 ❌ 破綻 危険極まりない
Channel(BUFFERED) ⭕ なし 🔺 競合・ゴースト発火のリスク ❌ 完全消失 🔺 低い 非推奨(問題の先送りに過ぎない)
UI State (ID + 消費通知) ⭕ 100% 保持される ⭕ IDにより厳密に1回実行 ⭕ SavedStateHandle連携可 ⭕ 完全一致 唯一の正解

AndroidのUIは、本質的に「状態の写像」です。

ライフサイクルという荒波が吹き荒れるAndroidプラットフォームにおいて、時間軸に依存する「命令型のパルス信号」を裏口から持ち込もうとすれば、いつか必ずその歪みが本番障害となって跳ね返ってきます。

イベントを「流す」誘惑を断ち切ること。イベントを「状態」として真正面から受け止め、単方向データフロー(UDF)のループを誠実に閉じること。その厳格な規律こそが、どんな過酷な端末環境でも決して揺るがない、真に一流のAndroidアプリを生み出すのです。