Jetpack Compose Navigation 3: ViewModel の生存期間を NavEntry 単位に変更する

Jetpack Navigation 3は、従来の Navigation Component と比較して非常にシンプルで「Action based」な設計になっています。しかし、デフォルト設定のまま ViewModel を利用すると、その生存期間に驚くかもしれません。

今回は、rememberViewModelStoreNavEntryDecorator() を使って NavEntry(画面)の破棄と同時に ViewModel をクリアする設定 について解説します。

 

🧑🏻‍💻 ViewModel のオーナーと生存期間

Nav3 で viewModel() を呼び出した際、そのオーナー(ViewModelStoreOwner)がどこにあるかを確認することは非常に重要です。

デフォルトの挙動:Activity スコープ

特に設定を行わない場合、ViewModel のオーナーは Activity (または Fragment) になります。

現状:
スクリーンが NavBackStack から消えても、ViewModel は破棄されません。

影響:
1 Activity で構成されたフル Jetpack Compose アプリの場合、一度生成された ViewModel はアプリが終了するまでメモリ上に生き続けます。

これは、画面を戻る(Pop back)たびに状態をリセットしたい場合には不都合な挙動となります。

 

🧑🏻‍💻 entryDecorators に ememberViewModelStoreNavEntryDecorator() を追加する

画面(NavEntry)ごとに独立した ViewModelStore を持たせるためのデコレータです。これを利用することで、NavEntry がスタックに積まれている間だけ ViewModel が生存するようになります。

設定方法:entryDecorators への追加

NavDisplay を呼び出す際、entryDecorators 引数にこのデコレータを渡します。


val backStack = rememberNavBackStack(initialBackStack)

NavDisplay(
    backStack = backStack,
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),

        // これを追加することで NavEntry 単位の ViewModel スコープが有効になる
        rememberViewModelStoreNavEntryDecorator()
    )
) { entry ->
    // 各画面の Composable 呼び出し
    when (val key = entry.contentKey) {
        is ScreenA -> ScreenAContent()
        is ScreenB -> ScreenBContent()
    }
}

ViewModel の生存期間はどう変わるのか?

このデコレータを適用すると、内部的な構造は以下のように変化します。

適用前:
全ての NavEntry が Activity の ViewModelStore を参照。

適用後:
各 NavEntry が個別の ViewModelStore を保持。

これにより、NavBackStack から特定の NavEntry が取り除かれた(Pop された)タイミングで、紐づく ViewModelStore が clear() され、ViewModel も正しく破棄されるようになります。

 

🧑🏻‍💻 まとめ

Jetpack Navigation 3 で ViewModel を適切に管理するためのポイントは以下の通りです。

1. デフォルトでは Activity スコープ になり、画面を閉じても ViewModel は生き続ける。

2. rememberViewModelStoreNavEntryDecorator() を NavDisplay の entryDecorators に追加する。

3. これにより、Screen(NavEntry)の生存期間 = ViewModel の生存期間 となり、メモリ効率と状態管理の健全性が向上する。

Navigation 3 において、この設定は現代的な Android アプリ開発のスタンダードな構成と言えるでしょう。


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 をインストールしてみましょう。その一歩が、モダンなモバイルアプリ開発における最強の武器になるはずです。