SavedInstanceState 不要!? Navigation3 の NavKey がもたらす Jetpack Compose 開発の変革

Android 開発者を長年悩ませてきた「画面回転」や「プロセス死」に伴う状態保存。SavedInstanceStateSavedStateHandle と格闘する日々は、もう過去のものになろうとしています。

Jetpack Navigation3 のソースコードを読み解くと、新しく登場した NavKey という仕組みが、Compose 時代の状態保存をいかにスマートに変革しようとしているかが見えてきました。

 

🤔 1. NavKey:単なる「マーカー」ではない、型安全の要


/**
 * Marker interface for keys.
 *
 * Objects and classes that extend this class must be marked with the [Serializable] annotation in
 * order to be saved with by the [rememberNavBackStack] function.
 *
 * This class is required because [Serializable] is only an annotation and does not provide a way to
 * link classes marked with the annotation together and provide a serializable that works with all
 * of them, resulting it making it impossible to properly save and restore.
 */
public interface NavKey

Navigation3 でバックスタックを管理する rememberNavBackStack を使う際、避けて通れないのが NavKey インターフェースの実装です。一見すると中身のないマーカーインターフェースですが、これこそが 「型安全な自動保存」 の鍵を握っています。

従来の Navigation では、引数の受け渡しや状態保存の裏側で Bundle が使われてきました。しかし、Bundle は何でも入る反面、型安全性が欠け、実行時のエラーを招きやすいという課題がありました。


/**
 * Provides a [NavBackStack] that is automatically remembered in the Compose hierarchy across
 * process death and configuration changes.
 *
 * This overload **does not take a [SavedStateConfiguration]**. It relies on the platform default:
 * on **Android**, state is saved/restored using a **reflection-based serializer**; on **other
 * platforms this will fail at runtime**. If you target non-Android platforms, use the overload that
 * accepts a [SavedStateConfiguration] and register your serializers explicitly.
 *
 * ### When to use this overload
 * - You are on **Android only** and want a simple API that uses reflection under the hood.
 * - Your back stack elements use **closed polymorphism** (sealed hierarchies) or otherwise work
 *   with Android’s reflective serializer.
 *
 * ### Serialization requirements
 * - Each element placed in the [NavBackStack] must be `@Serializable`.
 * - For **closed polymorphism** (sealed hierarchies), the compiler knows all subtypes and generates
 *   serializers; Android’s reflection will also work.
 * - For **open polymorphism** (interfaces or non-sealed hierarchies):
 *     - On Android, the reflection path can handle subtypes without manual registration.
 *     - On non-Android, this overload is **unsupported**; use the configuration overload and
 *       register all subtypes of [NavKey] in a [SerializersModule].
 *
 * @sample androidx.navigation3.runtime.samples.rememberNavBackStack_withReflection
 * @param elements The initial keys of this back stack.
 * @return A [NavBackStack] that survives process death and configuration changes on Android.
 * @see NavBackStackSerializer
 */
@Composable
public fun rememberNavBackStack(vararg elements: NavKey): NavBackStack<NavKey> {
    return rememberSerializable(
        serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
    ) {
        NavBackStack(*elements)
    }
}

NavKey は、バックスタックに入るすべての要素が「シリアライズ可能であること」をコンパイル時に保証するための型上の制約として機能します。

 

🤔 2. なぜ「Serializable アノテーション」だけでは不十分か

Kotlin Serialization を使っているなら、@Serializable だけで十分だと思うかもしれません。しかし、ソースコードのコメントには重要な洞察が記されています。

Serializable はあくまでアノテーションであり、クラス同士をコード上でリンクさせる手段を提供しない。そのため、すべての要素を適切に保存・復元することが不可能になる。

NavKey という共通のインターフェースを介することで、Navigation 側は「このバックスタックの中身はすべて、共通の手順でシリアライズできる仲間である」と認識できるようになります。これが、「何も考えなくても状態が復元される」 という体験の裏側にあるロジックです。

 

🤔 3. Android 限定の「リフレクション」がもたらす魔法

Navigation3 の rememberNavBackStack には、Android 開発者にとって非常に強力な恩恵があります。

Android プラットフォーム上では、リフレクションベースのシライライザーがデフォルトで動作します。これにより、開発者はシリアライザーを個別に登録する手間(ボイラープレート)から解放されます。

  • 開発者: NavKey を継承し、@Serializable を付ける。
  • Navigation3: process death の際、リフレクションを使ってバックスタックをまるごと保存し、復帰時に自動で復元する。

これこそが、「さらば SavedInstanceState」と言える最大の理由です。

 

🤔 4. Navigation3 時代の実装スタイル

これからの Compose 開発では、以下のように NavKey を実装した sealed interface を定義するのが標準になるでしょう。


@Serializable
sealed interface Screen : NavKey {
    @Serializable
    data object Home : Screen

    @Serializable
    data class UserProfile(val userId: String) : Screen
}

// Composable 内での利用
val backStack = rememberNavBackStack(Screen.Home)

この実装だけで、userId を含めた遷移状態が、OSによるメモリ回収の後でも完璧に復元されます。もはや Bundle を意識するシーンはほとんどなくなるはずです。

 

🤔 まとめ

Navigation3 における NavKey の導入は、Google が掲げる開発思想の現れのように感じます。

内部の複雑なシリアライズ処理を NavKey という一つのインターフェースに集約し、開発者には最小限の実装(アノテーションと継承)だけで最大限の恩恵(自動状態保存)を与える。

「状態保存に怯える開発」はもう終わりです。Navigation3 を武器に、より本質的な UI 実装に集中していきましょう。


Navigation3 entryProvider DSLの使い方と設計

「手動マッピング(命令型)」か、「DSLマッピング(宣言型・型安全)か」

ということなります。

これまで:
when 式などを使って、ルートごとに手動でインスタンスを生成・紐付けするスタイル。自由度は高いですが、記述量が増えやすく、型の不整合も起きがちです。

DSL形式:
entry() のように、型を渡すだけで自動的にマッピングを完結させるスタイル。ボイラープレートが排除され、型安全性が保証されます。

 

🤔 比較してみる


entryProvider = { key ->
    when (key) {
        is RouteA -> NavEntry(key) { ... }
        is RouteB -> NavEntry(key) { ... }
        else -> error()
    }
}

  • key は Any
  • Any → is チェック必須
  • 毎回 is RouteB などの分岐が必要


entryProvider = entryProvider {
    entry<RouteA> { ... }
    entry<RouteB> { key ->
        Text(key.id)
    }
}

  • key は 型付き (RouteB)
  • 分岐不要
  • entry → コンパイル時保証

 

🤔 まとめ


NavDisplay
  └ entryProvider (lambda)
       └ when(key)
            ├ RouteA → NavEntry + UI
            ├ RouteB → NavEntry + UI
            └ else → error


NavDisplay
  └ entryProvider (DSL)
       ├ entry<RouteA> { UI }
       └ entry<RouteB> { key -> UI(key.id) }

DSLは便利だが、抽象化が増えるため「内部の仕組み」が見えにくい感じに思います。


【JetpackCompose Navigation3】rememberViewModelStoreNavEntryDecorator() とは何なのか

rememberViewModelStoreNavEntryDecorator()は、Google が開発を進めている次世代のナビゲーションライブラリ Navigation 3 (Android Jetpack) において、特定の画面(NavEntry)に ViewModelStore を提供するためのデコレーターを生成する関数です。


NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator() // *
    ),

一言でいうと、
「この画面で ViewModel を使えるようにする(ViewModel の器を用意する)」
ための設定項目の一つです。

 

🧑🏻‍💻 役割と仕組み

Navigation 3 では、画面の定義を「デコレーター」という仕組みで拡張します。

  • ViewModel の保持: 通常、ViewModel は ViewModelStore という場所に保存されます。この関数を使うことで、ナビゲーションの各エントリ(画面)が自分自身の ViewModelStore を持てるようになります。
  • ライフサイクルとの連動: これにより、画面が破棄されたときに、その画面に紐づく ViewModel も適切にクリアされるようになります。
  • Shared ViewModel の実現: 親のナビゲーショングラフでこのデコレーターを定義することで、複数の子画面間で同じ ViewModel インスタンスを共有(Shared ViewModel)することも可能になります。


NavDisplay
 └─ NavBackStack
      ├─ NavEntry A
      │    ├─ contentKey = A
      │    └─ ViewModelStore A
      │         └─ ViewModel A
      │
      └─ NavEntry B
           ├─ contentKey = B
           └─ ViewModelStore B
                └─ ViewModel B

 

🧑🏻‍💻 なぜ必要なのか

従来の Navigation Compose では NavHost が内部で自動的に ViewModel の管理を行っていましたが、Navigation 3 はよりシンプルでカスタマイズしやすい設計を目指しています。

そのため、「どの画面が ViewModel の器(Store)を持つか」を明示的に指定する必要があり、そのためにこの関数が用意されています。


Jetpack Compose と SwiftUI の相互乗り入れチャンス:宣言的 UI の「共通言語」を武器にする

モバイルアプリ開発の主戦場は、完全に「宣言的 UI(Declarative UI)」にシフトしました。Android の Jetpack Compose と iOS の SwiftUI。これらは単に似ているだけでなく、設計思想の根幹が驚くほど共通しています。

「片方の OS しかやらない」のはもはやもったいない。今回は、両者の似ている点と、実際に触れてみて分かったそれぞれの強み・弱みをエンジニア視点で深掘りします。

 

🤔 1. 驚くほど似ている「共通言語」

まずは、両者がどれだけ似ているかを見てみましょう。基本的な構造はほぼ 1 対 1 で対応しています。

このように、概念さえ理解していれば、文法を「翻訳」するだけでコードが書けてしまいます。これが今、エンジニアが「相互乗り入れ」すべき最大の理由です。

 

🤔 2. Jetpack Compose の「いいとこ・わるいとこ」

いいとこ:柔軟性とロジックの書きやすさ

  • Kotlin の恩恵: 単なる関数(Function)なので、UI の中に if や for などの標準的なロジックを非常に自然に記述できます。
  • プレビューの強力さ: MultiPreview などの機能により、複数のデバイス設定やテーマを一度に確認できるのが強力です。
  • 後方互換性: OS のバージョンに依存せず、ライブラリの更新で新機能が使える(Android 5.0+ 等)のは、ビジネスサイドから見ても大きな利点です。

わるいとこ:ビルド速度と環境構築

  • コンパイル時間: Kotlin Symbol Processing (KSP) や Compose コンパイラの処理により、プロジェクトが大きくなるとビルド時間が課題になりがちです。
  • プレビューの不安定さ: 依然として、複雑なプロジェクトではプレビューがビルドエラーで止まることがあり、ストレスを感じる場面もあります。

 

🤔 3. SwiftUI の「いいとこ・わるいとこ」

いいとこ:簡潔さと OS との一体感

  • Modifier の直感性: .padding().background().cornerRadius() とドットで繋いでいく記述(メソッドチェーン)は、Compose の Modifier よりも直感的で、記述量が少なく済みます。
  • プレビューの速さ: Xcode の Previews(Canvas)は、シミュレータを立ち上げ直さずにコード変更を即座に反映する「Canvas プレビュー」が非常に軽快です。
  • デフォルトの美しさ: 最小限のコードで「iOS らしい」アニメーションや挙動が手に入ります。

わるいとこ:OS バージョンの壁

  • 「iOS 15 以前」の壁: 新しい SwiftUI の機能を使いたくても、サポート対象の OS バージョンによって使えないことが多々あります。これが開発者の最大の悩みどころです。
  • ブラックボックス: 内部実装が隠蔽されている部分が多く、標準から外れた挙動をさせようとすると、途端に難易度が上がります(Introspect などのハックが必要になることも)。

 

🤔 4. 相互乗り入れがもたらす「エンジニアとしての価値」

今、この両方を触るメリットは「両方のプラットフォームでアプリが作れる」ことだけではありません。

  • 「UI 設計の抽象化」が身につく: 実装の詳細に振り回されず、「状態をどう定義し、どう UI に流し込むか」という設計の本質に集中できるようになります。
  • KMP (Kotlin Multiplatform) への布石: Compose を知っていれば Compose Multiplatform で iOS UI も書けますし、SwiftUI を知っていれば KMP の UI 層を SwiftUI で書く選択がスムーズになります。
  • Action and Simple: 複雑な理論をこねくり回すより、まずは両方の環境で簡単な Todo アプリを作ってみる。この「Action」こそが、モバイルエンジニアとしての視野を一気に広げてくれます。

 

🧑🏻‍💻 まとめ

Jetpack Compose と SwiftUI は、もはや別々の島の言葉ではありません。同じ「宣言的 UI」という大陸にある、少し方言が違う程度の差です。

Android エンジニアなら Mac を手に取り、iOS エンジニアなら Android Studio をインストールしてみましょう。その一歩が、モダンなモバイルアプリ開発における最強の武器になるはずです。


Jetpack Compose における State と Effect の境界線:ワンショットイベントに Channel を採用する理由

Jetpack Compose で開発をしていると、必ず直面する問いがあります。

「これは State として保持すべきか、それとも Effect(副作用)として処理すべきか?」

という問題です。

Compose の宣言的 UI パラダイムにおいて、この境界線を曖昧にすると、画面回転時の二重トーストや、意図しない画面遷移といったバグを招きます。

本記事では、その明確な使い分けと、イベント制御における Kotlin Channel の有効性について解説します。

 

🧑🏻‍💻 1. 「状態 (State)」と「副作用 (Effect)」の本質的な違い

使い分けの基準はシンプルです。

「そのデータは、UI のスナップショットの一部か?」

と自問してください。

State:UI の「今」を表すもの

State は、再構成(Recomposition)によって何度読み込まれても同じ結果を示すべきものです。

  • 例: テキストフィールドの入力値、読み込み中フラグ、リストデータ
  • 性質: 保持(Retention)

Effect:UI の「外」で起きる一回きりのこと

Effect は、Compose のレンダリングサイクルとは独立して実行される処理です。

  • 例: ログ出力、アナリティクス送信、タイマーの開始
  • 性質: 実行(Execution)

 

🧑🏻‍💻 2. ワンショットイベントの罠:StateFlow vs Channel

ここで問題になるのが、トースト表示や画面遷移のような「一度だけ実行したいアクション」です。

これらを StateFlow で管理しようとすると、Android 特有のライフサイクル問題にぶつかります。

StateFlow の限界

StateFlow は常に「最新の状態」を保持します。

1. エラーが発生し、State を ErrorMessage("Failed") に更新。
2. UI がそれを検知してトーストを表示。
3. ここで画面を回転させる。
4. 新しい Activity が StateFlow を購読し、最新の "Failed" を再び受け取ってしまう。
5. トーストが二重に表示される。

これを防ぐために「フラグを戻す」処理を挟むのは、シンプルではありません。

 

🧑🏻‍💻 3. Channel は「消費されるイベント」に最適である

そこで登場するのが Channel です。Channel は、「土管」のような振る舞いをします。

  • 一度きりの配送: 誰かがイベントを受け取った(消費した)瞬間、そのイベントは Channel から消えます。
  • 画面回転に強い: 新しい Activity が再購読しても、古いイベントは既に消費されているため、二重実行は発生しません。
  • バッファの活用: Channel.BUFFERED を使うことで、アプリがバックグラウンドにいる間に発生したイベントも、フォアグラウンドに戻った瞬間に安全に処理できます。

 

🧑🏻‍💻 4. 実装のベストプラクティス

私のプロジェクトでは、以下のような棲み分けを徹底しています。


// ViewModel

// UI の状態(表示データ)
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()

// UI へのイベント(ワンショット)
private val _eventChannel = Channel<UiEvent>(Channel.BUFFERED)
val events = _eventChannel.receiveAsFlow()


// UI (Compose)

LaunchedEffect(Unit) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
            is UiEvent.NavigateToDetail -> navController.navigate("detail")
        }
    }
}

 

🧑🏻‍💻 まとめ

  • 永続的な見た目に関わるなら State (StateFlow)
  • 一過性の挙動に関わるなら Effect (Channel)

複雑なフラグ管理でコードを汚す前に、ツールが持つ「自然な性質」を利用しましょう。

Channel を使うことは、Compose におけるイベントハンドリングを最もシンプルにする考え方の一つです。