UiState で UiEvent を扱う3つのパターン

「AndroidのUI設計って、イベント駆動から状態駆動に完全にシフトしたよね」という話の続きです。

ViewModelから画面側(Compose)へ単発のイベントを伝えるとき、SharedFlowやChannelを使わずにUiStateだけで閉じた設計にしようとすると、だいたい次の3つのアプローチに行き着きます。

実務での使い分けを踏まえて整理してみました。

 

🤔 1. Single Event (nullable)

UiState の中に event: UiEvent? をひとつ持たせる一番シンプルで直感的なパターンです。


sealed interface UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent
    data class NavigateToDetail(val id: String) : UiEvent
    data class ShowDialog(val type: DialogType) : UiEvent
}

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val event: UiEvent? = null,
)

// ViewModel
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

fun onSaveClicked() {
    _uiState.update { it.copy(event = UiEvent.ShowSnackbar("保存しました")) }
}

fun onEventConsumed() {
    _uiState.update { it.copy(event = null) }
}

// UI (Compose)
LaunchedEffect(uiState.event) {
    when (val event = uiState.event) {
        is UiEvent.ShowSnackbar -> {
            snackbarHostState.showSnackbar(event.message)
            viewModel.onEventConsumed()
        }
        is UiEvent.NavigateToDetail -> {
            navController.navigate("detail/${event.id}")
            viewModel.onEventConsumed()
        }
        is UiEvent.ShowDialog -> viewModel.onEventConsumed()
        null -> Unit
    }
}

メリット
・ 構造がとにかくシンプル。フィールドも1つ、消費用コールの記述も1つで済む。
・ StateFlow だけで完結するので、Channel 管理の煩わしさやリークのリスクを回避できる。
・ uiState.value.event をアサートするだけで単体テストが書ける。

デメリット
・ ほぼ同時に複数のイベントが発生すると、最後のやつで上書きされて途中のイベントが消える。
・ 全く同じ内容のイベントが連続すると、data class の同値判定(equals)に引っかかって LaunchedEffect が再発火しない場合がある。

 

🤔 2. Queue + drop(1)

イベントをQueue(List)で保持し、処理が終わった順に先頭を削っていく(drop(1))パターンです。


data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val eventQueue: List<UiEvent> = emptyList(),
)

// ViewModel
private fun sendEvent(event: UiEvent) {
    _uiState.update { it.copy(eventQueue = it.eventQueue + event) }
}

fun onSaveClicked() {
    sendEvent(UiEvent.ShowSnackbar("保存しました"))
    sendEvent(UiEvent.NavigateToDetail(savedId))
}

fun onEventConsumed() {
    _uiState.update { it.copy(eventQueue = it.eventQueue.drop(1)) }
}

// UI (Compose)
LaunchedEffect(uiState.eventQueue) {
    val event = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
    when (event) {
        is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
        is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
        is UiEvent.ShowDialog -> { /* ... */ }
    }
    viewModel.onEventConsumed()
}

メリット
・ 複数イベントの連打や同時発火に対応でき、発生した順番通りに処理できる。
・ when 式の型チェック(exhaustive)をそのまま活かせる。

デメリット
・ UI側で onEventConsumed() を呼び忘れるとキューの先頭が残り続け、後続のイベントがすべて詰まる。
・ 単純に drop(1) しているだけなので、「今処理したイベント」と「実際に削除されたイベント」がずれるリスクが残る(同一内容のイベントが連続した場合など)。

 

🤔 3. Queue + ID matching

キュー構造に加えて、イベントごとに一意のID(System.nanoTime() など)を付与し、ID指定でピンポイントに消費させるパターンです。


data class QueuedEvent(
    val id: Long = System.nanoTime(),
    val event: UiEvent,
)

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val eventQueue: List<QueuedEvent> = emptyList(),
)

// ViewModel
private fun sendEvent(event: UiEvent) {
    _uiState.update { it.copy(eventQueue = it.eventQueue + QueuedEvent(event = event)) }
}

fun onSaveClicked() {
    sendEvent(UiEvent.ShowSnackbar("保存しました"))
    sendEvent(UiEvent.NavigateToDetail(savedId))
}

fun onEventConsumed(id: Long) {
    _uiState.update { state ->
        state.copy(eventQueue = state.eventQueue.filterNot { it.id == id })
    }
}

// UI (Compose)
LaunchedEffect(uiState.eventQueue.firstOrNull()?.id) {
    val queued = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
    when (val event = queued.event) {
        is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
        is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
        is UiEvent.ShowDialog -> { /* ... */ }
    }
    viewModel.onEventConsumed(queued.id)
}

メリット
・ 内容が全く同じイベントが並んでも、ID指定で削除するため誤削除が発生しない。
・ LaunchedEffect のキーにIDを渡せるため、二重処理や再発火漏れを極力防げる。

デメリット
・ ラッパークラスやID生成、filterNot の処理など、ボイラープレートコードが増える。
・ 通常のトースト表示や画面遷移程度なら、正味オーバースペック。

 

🤔 比較

 

🤔 現場での判断軸

プロダクト開発では、最初から凝りすぎず段階的に育てるアプローチが取りやすいです。

1, まずは「Single Event」で組む

8割方の画面はこれで十分事足ります。コードも簡潔で可読性が高く、チーム内での認知負荷も低く抑えられます。

2.「保存成功スナックバーを出しつつ元の画面に戻る」など、同時発生が要件になったら「Queue」へ移行

Single Eventの限界(上書き問題)が見えたタイミングでキュー構造にリファクタリングします。

3. 二重処理や損失が致命傷になる重要フロー(決済やアカウント削除など)のみ「ID matching」を入れる

堅牢性が必要な局所に絞ってピンポイントで採用するのが、過剰な抽象化(オーバーエンジニアリング)を防ぐコツです。

 

🤔 参考


2026年8月31日 API レベル36+ の件

もう疲れました。

Google PlayアプリのターゲットAPIレベル要件
2026年8月31日から開始:

・ Google Playに提出される新規アプリおよびアプリのアップデートは、Android 16(APIレベル36)以降をターゲットにする必要があります。ただし、Wear OSおよびAndroid Automotive OSアプリはAndroid 15(APIレベル35)以降をターゲットに、Android TVおよびAndroid XRアプリはAndroid 14(APIレベル34)以降をターゲットにする必要があります。

・既存のアプリは、アプリのターゲットAPIレベルよりも高いAndroid OSを実行しているデバイスで新規ユーザーが引き続き利用できるようにするには、Android 15(APIレベル35)以上をターゲットにする必要があります。Android 14(APIレベル34)以下をターゲットとするアプリ(Wear OSおよびAndroid TV向けのAndroid 13(APIレベル33)以下、Android XR向け、Android Automotive OS向けのAndroid 12(APIレベル31)以下を含む)は、アプリのターゲットAPIレベルと同じかそれ以下のAndroid OSを実行しているデバイスでのみ利用可能です。

アプリのアップデートにさらに時間が必要な場合は、2026年11月1日まで延長を申請できます 。アプリの延長申請フォームは、今年後半にPlay Consoleからアクセスできるようになります。

👉 Target API level requirements for Google Play apps - Play Console Help

実質のユーザ利用の端末OSレベルが API 36+ で 22.3% ということなのですが。

アプリ開発側だけが辛い感じで。

みなさんは、どう思ってますか。


【Kotlin 2.4】ついに登場!コレクションリテラル(実験的サポート)と型推論の仕組みを徹底解説

Kotlin開発者の皆さん、お待たせしました!

2026年6月にリリースされた Kotlin 2.4 にて、待望の「コレクションリテラル(Collection Literals)」が実験的(Experimental)にサポートされました。

これまで listOf() や mutableListOf()、arrayOf() などの関数を使って記述していた配列やリストの生成が、ついにスクエアブラケット [] を使って、よりシンプルかつ直感的に書けるようになります。

この記事では、コレクションリテラルの導入方法から、最も重要な挙動である「文脈依存の型推論(Context-sensitive Type Inference)」について詳しく解説します。

 

🧑🏻‍💻 コレクションリテラルとは?

Kotlin 2.4.0からは、以下のようにブラケット [] を使ってコレクション(配列やリストなど)を簡潔に作成できるようになります。


// Kotlin 2.4からの新しい書き方(リテラル表記)
val names = ["Joe", "Alice"]

従来の listOf("Joe", "Alice") と比べてタイピング量が減り、他言語(Javaの配列リテラルや、JavaScript/TypeScript/Pythonなどの配列・リスト表記)に慣れている開発者にとっても、より親しみやすいコードになります。

 

🧑🏻‍💻 導入方法(実験的サポートの有効化)

Kotlin 2.4時点では、この機能はまだ実験的(Experimental)な位置づけです。そのため、プロジェクトで利用するにはコンパイラオプションで明示的に機能を有効化する必要があります。

build.gradle.kts に以下の設定を追加してください。


kotlin {
    jvmToolchain(21)
    compilerOptions {
        // コレクションリテラルを有効化するコンパイラ引数
        freeCompilerArgs.add("-Xcollection-literals")
    }
}

 

🧑🏻‍💻 注目すべき「型推論」の挙動

コレクションリテラルを使う上で、最も興味深いのが「コンパイラがどのように型を推論するか」という点です。

Kotlinのコレクションリテラルは、単一の固定された型を表すのではなく、周囲の文脈(期待される型)に応じて最終的な型が変化する「文脈依存(Context-sensitive)」の性質を持っています。

具体的なコードで比較してみましょう。

1. 明示的な型指定がない場合

ターゲットとなる型を何も指定せずにリテラルを書いた場合、コンパイラはデフォルトで Array(配列) と推論します。


val names = ["Joe", "Alice"]
// 推論される型: Array<String>

2. 期待される型(型注釈)がある場合

変数の型を明示的に指定すると、リテラルの中身は同じ [] であっても、コンパイラが文脈を読み取って適切なコレクション型へと変換してくれます。


val names: MutableList<String> = ["Joe", "Alice"]
// 推論される型: MutableList<String>

このように、左辺の MutableList<String> という情報をコンパイラが解釈し、[] の部分を適切に MutableList として扱ってくれるのが、今回の型推論の面白いところです。

 

🧑🏻‍💻 まとめ:Kotlinのコードはさらに洗練される

Kotlin 2.4のコレクションリテラルは、ただの「書き方の省略」にとどまらず、Kotlinの強力な型推論エンジンとシームレスに融合している点が大きな特徴です。

現在はまだ実験的な機能であるため、プロダクション環境への導入には慎重になる必要がありますが、将来的に正式機能となれば、Kotlinのコードをさらにモダンでスッキリとしたものに変えてくれることは間違いありません。

興味のある方は、ぜひコンパイラオプションを追加して、手元のプロジェクトで新しい書き味を試してみてください!


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 チャンネルを覗いてみるのがおすすめ。そこでは、より人間的で身近な困りごとや質問が、消化しやすい形で活発に議論されている。


「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!!