Navigation3 時代のSavedStateHandleを考え直す

Jetpack Navigation 3が登場し、画面遷移の設計は大きく変わろうとしています。

その中でも特に変化が大きいのが、ViewModelでSavedStateHandleから画面引数を取得するという、これまで当たり前だった実装です。


@HiltViewModel 
class UserViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle
) : ViewModel() { 
    val userId: String = checkNotNull(savedStateHandle["userId"]) 
}

これまで多くのサンプルや公式ドキュメントでも紹介されてきたため、この書き方に違和感を持つ人は少ないでしょう。

しかしNavigation 3では、この設計を前提としなくてもよくなりました。

むしろ、画面引数は直接ViewModelへ渡すほうが自然だという考え方へ移りつつあります。

 

🧑🏻‍💻 SavedStateHandleは本来何のためのAPIなのか

SavedStateHandleは、プロセスデス時の状態復元を目的として作られたAPIです。

Androidではメモリ不足になるとアプリのプロセスが終了されます。

その際、入力途中の内容やスクロール位置などをBundleへ保存し、アプリ復元時に元へ戻せるようにする仕組みがあります。

SavedStateHandleは、その保存領域へアクセスするためのラッパーです。

つまり、本来の役割は

- テキスト入力
- スクロール位置
- 一時的なUI状態

などを保持することであり、

画面引数を受け渡すためのAPIではありません。

 

🧑🏻‍💻 Navigation 2では便利だった

Navigation Composeでは画面引数が自動的にSavedStateHandleへ入るため、


val userId = savedStateHandle["userId"]

だけで取得できました。

非常に便利だった反面、この実装には問題もありました。

ViewModelが

- 引数名
- Navigationの仕様
- ルート構造

まで知る必要があったからです。

本来ビジネスロジックだけを持つはずのViewModelが、Navigationへ依存してしまいます。

 

🧑🏻‍💻 Navigation 3では考え方が変わった

Navigation 3では画面遷移が型安全になりました。

例えば


@Serializable
data class UserScreen(
    val userId: String
)

のようにルート自体がオブジェクトになります。

Composableでは


fun UserScreen(
    route: UserScreen
)

として受け取れるため、

ViewModelにもそのまま渡せます。


@HiltViewModel(assistedFactory = Factory::class)
class UserViewModel @AssistedInject constructor(
    @Assisted
    private val route: UserScreen
) : ViewModel()

これなら

- 文字列キーが不要
- 型安全
- Navigationへの依存が少ない

というメリットがあります。

 

🧑🏻‍💻 ViewModelはNavigationを知らなくていい

以前は


Navigation
    ↓
SavedStateHandle
    ↓
ViewModel

という流れでした。

Navigation 3では


Navigation
    ↓
Route
    ↓
ViewModel

という構成になります。

ViewModelは必要なデータだけを受け取り、Navigationの実装を意識しなくて済みます。

責務も明確になります。

 

🧑🏻‍💻 SavedStateHandleが不要になったわけではない

もちろん、SavedStateHandle自体が不要になるわけではありません。

例えば

- 検索キーワード
- フォーム入力
- スクロール位置
- タブ選択状態

など、プロセスデス後も復元したいUI状態には引き続き最適です。

つまり、

- 画面引数
- UI状態

を分けて考えることが重要になります。

 

🧑🏻‍💻 これからの設計

Navigation 2では

「画面引数=SavedStateHandle」

という書き方が一般的でした。

しかしNavigation 3では、

- Routeをそのまま渡す
- Assisted Injectionを利用する
- SavedStateHandleはUI状態だけに使う

という設計のほうがシンプルで責務も明確になります。

すぐに既存コードを書き換える必要はありませんが、新規実装ではこの考え方を取り入れていく価値は十分にあるでしょう。

Navigation 3は単なるAPIの更新ではなく、ViewModelとNavigationの関係そのものを見直すきっかけになりそうです。

 

🧑🏻‍💻 参考


Jetpack Composeで snapshotFlow の使いどころ

Jetpack Composeにおいて、Composeの「状態(State)」と「非ComposeのAPI(Flowなど)」を連携させたい場合に snapshotFlow が非常に有効です。

具体的には、以下のようなケースで使用します。

 

🧑🏻‍💻 1. Composeの状態変化をFlowとして監視したいとき

Composeの State オブジェクトの値が変化したタイミングで、何らかの副作用(Side-effect)を起こしたい場合に使用します。

コード例:TextFieldの入力値を監視して検索APIを呼ぶ


LaunchedEffect(textState) {
    snapshotFlow { textState.value }
        .debounce(300L) // 300ms入力が止まったら処理
        .distinctUntilChanged()
        .collect { query ->
            viewModel.search(query)
        }
}

 

🧑🏻‍💻 2. 非ComposeのAPIと値を同期させたいとき

Composeの管理外にあるライブラリやシステムAPIに対し、Compose側の状態の変化を伝えたい場合です。

コード例:スクロール位置をトラッキングツールへ送信


LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .filter { it > 0 }
        .collect { index ->
            Analytics.log("ScrollPosition", index)
        }
}

 

🧑🏻‍💻 3. 複雑な条件で「状態の変化」をトリガーにしたいとき

LaunchedEffect は状態が更新されるたびに毎回実行されますが、snapshotFlow を使うと、

Flowの演算子(filter, distinctUntilChanged など)を組み合わせる

ことで、「特定の値になったときだけ」といった細かい条件付けが容易になります。

 

🧑🏻‍💻 注意点:使うべきではないケース

すべての状態監視に snapshotFlow を使う必要はありません。

- 単純なUIの更新: 単にComposeのUIを再描画したいだけであれば、snapshotFlow を使わず、Composeの State をそのまま読み取ってUIを構成するのが最も効率的です。

- 計算処理: 高負荷な計算をFlowの中で行う場合は、適切なスレッド(Dispatchers.Defaultなど)で処理を行うよう注意が必要です。

 

🧑🏻‍💻 まとめ

snapshotFlow は、

「Composeのリアクティブな状態」を「Kotlin Flowの強力な演算子」とブリッジさせるためのツール

です。

「状態が変化した時に、何か別のアクション(通信、ログ記録、画面遷移など)を発生させたい」と考えたときが、この機能の使いどころです。

 

🧑🏻‍💻 参考


Jetpack Compose Navigation3 複数 NavBackStack 超シンプル実装

公式サンプルは複雑すぎない?

と思ったので。

 

🤔 実際のアプリはボトムナビゲーションをどう扱っているか

実装に入る前に、主要アプリの挙動を見てみましょう。

ほとんどのアプリは以下のどれかです:

- タブごとに独立した BackStack(最も一般的)
- 1つの共有 BackStack(最もシンプル)
- タブ切り替え時にリセット(常にルートに戻る)

 

🤔 バックボタンの挙動


タブ内の履歴

  ↓

タブのルート

  ↓

アプリ終了

- タブの切り替えは手動(バックボタンでは行わない)
- 各タブは独自の履歴を持つ
- 履歴が空になったらアプリを終了
- 同じタブを再度タップ → そのタブをルートにリセット
- タブ数は固定

 

🤔 考え方

タブをキーとした BackStack の Map を使う

これだけです。複雑な状態ホルダーは一切不要です。

 

🤔 主要コンポーネント

- NavKey
- NavBackStack
- NavigationBar / NavigationBarItem
- NavDisplay

 

🤔 ナビゲーションモデル


NavKey
└── TabRoot (エントリポイント)
     ├── Home
     ├── Search
     │    ├── Result (検索結果画面)
     │    └── Detail (検索詳細画面)
     └── Profile



@Serializable
sealed interface TabRoot : NavKey {
    val label: String
    val selectedIcon: ImageVector
    val unselectedIcon: ImageVector

    companion object {
        val entries = listOf(Home, Search, Profile)
    }
}

@Serializable
data object Home : TabRoot {
    override val label = "Home"
    override val selectedIcon = Icons.Filled.Home
    override val unselectedIcon = Icons.Outlined.Home
}

@Serializable
data object Search : TabRoot { ... }

@Serializable
data object Profile : TabRoot { ... }

@Serializable
data class Result(val keyword: String) : NavKey

@Serializable
data class Detail(val id: String) : NavKey

なぜこれがうまくいくか

- 単一の型安全なナビゲーションモデル
- タブと画面が同じシステムを共有
- @Serializable で状態復元が可能
- sealed + object でコンパイル時安全性

 

🤔 状態管理

1. 選択中のタブ


var currentTab by rememberSerializable {
    mutableStateOf<TabRoot>(Home)
}


var currentTab by rememberSerializable {
    mutableStateOf<TabRoot>(Home)
}

2. 複数の NavBackStack


val stacks = TabRoot.entries.associateWith { root ->
    rememberNavBackStack(root)
}

重要なポイント
- Map は再コンポーズごとに再作成されが生成コストは小さい
- しかし各 NavBackStack 維持されて再作成されない

これで安全かつシンプルに実現できます。

3. 現在の NavBackStack


val currentStack = stacks[currentTab]!!

Compose では currentTab が変わると自動的に更新されます。

4. タブ切り替え


onClick = {
    currentTab = root
}

これだけです。

5. 画面遷移(Push)kotlin


onClick = {
    currentStack.add(Result(keyword))
}

6. 戻る操作(Pop)


NavDisplay(
    onBack = {
        currentStack.removeAt(currentStack.lastIndex)
    }
)

- ルートを削除しようとしたらアプリ終了(Android 標準挙動)

7. 同じタブを再度タップしたときのリセット


if (selected) {
    currentStack.clear()
    currentStack.add(root)
}

- Instagram や Twitter(X)と同じ挙動になります。

 

🧑🏻‍💻 最終的な最小パターン


var currentTab by rememberSerializable {
    mutableStateOf<TabRoot>(Home)
}

val stacks = TabRoot.entries.associateWith { root ->
    rememberNavBackStack(root)
}

val currentStack = stacks[currentTab]!!

 

🚀 このアプローチが優れている理由

- コード量が最小
- 実際のアプリ挙動にマッチ
- 完全に Compose ネイティブ
- 将来的に拡張しやすい
- Process Death 後も完全な状態復元が可能

というかんじでどうでしょうか。

 

🚗💨 参考


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