Hiltのナビゲーション引数で SavedStateHandle から卒業する

なぜ Hilt 2.49+ と @AssistedInject が、完全な型安全性を備えた引数渡しの「正解」と言えるのか。

 

🤔 問題点:静的な依存関係と動的なデータの混在

Android開発において、ViewModelにランタイム引数(実行時引数)を注入することは、常に議論の的となってきました。

その本質は、「静的依存関係」(DIによって管理されるリポジトリなど)と、「動的データ」(画面遷移時に渡されるナビゲーション引数など)の対立にあります。

適切なDIパターンを適用せずにこの問題に対処しようとすると、高確率でアンチパターンに陥ります。たとえば、一時的な状態をシングルトン(Singleton)内に保持してしまうと、状態の汚染や画面間でのデータ漏洩といった致命的なバグを引き起こす危険性があります。

 

🤔 解決策:ハイブリッド注入を実現する @AssistedInject

この「静的」と「動的」のギャップを埋めるための最適なツールが、@AssistedInject です。これを使用することで、DI(Hilt)が管理するオブジェクトと、実行時に受け取るランタイムパラメータをスマートに組み合わせた「ハイブリッド注入」が可能になります。

通常の @Inject は不要 :
@AssistedInject を使用する場合、コンストラクタに通常の @Inject を付ける必要はありません(というか、付けてはいけません)。

関心の分離 :
リポジトリなどの依存関係は Hilt が自動で供給し、ナビゲーション引数などの動的なデータだけを手動で安全に渡す、という明確な役割分担(関心の分離)が実現します。

クリーンなコードへ :
これにより、バグの温床になりがちだった危険な lateinit var による後からの初期化コードとは、完全におさらばできます。


// ViewModel 実装

@HiltViewModel(assistedFactory = RouteBViewModel.Factory::class) 
class  RouteBViewModel  @AssistedInject constructor( 
    private val repository: MyRepository,
    @Assisted val navKey: RouteB,
) : ViewModel() { 

    @AssistedFactory
    interface Factory { 
        fun create (navKey: RouteB) : RouteBViewModel 
    } 
}

 

🤔 実装:assistedFactory と hiltViewModel の組み合わせ

公式の nav3-recipes リポジトリでは、Hilt 2.49 以降で導入された最新の標準的な実装パターンが示されています。

ファクトリの宣言 :
@HiltViewModel(assistedFactory = ...) を使用して、Assisted Factory を ViewModel に直接リンクさせます。

2つの型パラメータ: Compose 側では、hiltViewModel() のように両方の型を明示的に指定して呼び出します。

安全なライフサイクル管理 :
creationCallback を利用することで、ナビゲーションのバックスタック管理(Lifecycle)を壊すことなく、安全にランタイム引数を渡すことができます。


// UI(ナビゲーション定義)

 entry<RouteB> { key -> 
    val viewModel = hiltViewModel<RouteBViewModel, RouteBViewModel.Factory>( 
        creationCallback = { factory -> 
            factory.create(key) 
        } 
    ) 
    ScreenB(viewModel = viewModel) 
}

 

🤔 メリット:100%の型安全性を実現し、SavedStateHandle のボイラープレートを完全に排除

このアーキテクチャ(Navigation 3 + @AssistedInject)に移行することで、コードベースの品質は大幅に向上します。

完全な型安全性(100% Type-Safe):
かつての文字列ベースのキー(string のキー指定)は過去のものです。ナビゲーション引数は、Route(Serializable等で定義された型)として、厳密に型指定されたオブジェクトのまま直接安全に渡されます。

SavedStateHandle からの解放 :
単に次の画面へ引数を渡すだけのために、SavedStateHandle を使ってごにょごにょと値を書き出したり読み出したりする定型文(ボイラープレート)はもう一切不要になります。

圧倒的なリファクタリングのしやすさ :
すべてがコンパイル時にチェックされるため、引数の追加・削除・変更といったリファクタリングが、一瞬かつ確実に(ランタイムエラーの心配なく)行えるようになります。

 

🤔 まとめ

Hilt と最新の Jetpack Navigation の相乗効果により、画面間の引数の受け渡しは驚くほどスムーズかつ洗練されたものになりました。もはや、型安全性を犠牲にしたり、不自然なアーキテクチャで回避策(ワークアラウンド)を講じたりする必要はありません。

@AssistedInject と、進化した hiltViewModel() API を組み合わせることで、「完璧な型安全性」と「クリーンなハイブリッド依存性注入(DI)」を今すぐあなたのプロジェクトに導入できます。


【Jetpack Compose Navigation3】EntryDecorator と ViewModel の「key」の深い関係

Jetpack Compose の Navigation3 では、画面遷移の引数として RouteB(val id: String) のようなデータクラスを渡します。

このとき、同じ RouteB でも id が違えば「別の画面(別の ViewModel)」として扱いたいですよね。

ここで重要になるのが EntryDecoratorviewModel(key = ...) の関係です。

 

🤔 結論:Decorator が「鍵」を管理してくれるか否か

一言でいうと、こうなります。

Decorator を使わない場合:
手動で viewModel(key = "unique_id") を指定する必要がある。

Decorator を使えば:
viewModel() の引数は不要。
Decorator が自動で各エントリに個別の ViewModelStore を割り当ててくれる。

 

🤔 1. Decorator を使わないパターン(手動管理)

Navigation3 の基本機能 NavDisplay を使う場合、ViewModel の生存期間はデフォルトの「Activity 全体」に紐づきます。

同じ RouteB でも id ごとに ViewModel を作り分けたい場合、以下のように手動で key を渡して、内部のキャッシュを分ける必要があります。


entry<RouteB> { key ->
    // key(RouteBのインスタンス)の id を使って、ViewModelを区別する
    val vm = viewModel(
        key = key.id, // ← これが必要!
        factory = RouteBViewModel.Factory(key)
    )
    ScreenB(vm)
}

これを忘れると、id = "1" の画面から id = "2" の画面へ遷移しても、同じ ViewModel インスタンスが使い回されてしまい、表示内容が更新されない というバグに繋がります。

 

🤔 2. Decorator を使うパターン(Navigation3 の推奨)

サンプルの BasicViewModelsActivity で採用されている方法です。

NavDisplay の設定に rememberViewModelStoreNavEntryDecorator() を追加します。


NavDisplay(
    backStack = backStack,
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator() // ← これが魔法のスパイス
    ),
    entryProvider = entryProvider {
        entry<RouteB> { key ->
            // key 指定が不要になる!
            val vm = viewModel(factory = RouteBViewModel.Factory(key))
            ScreenB(vm)
        }
    }
)

なぜ key が不要になるのか?

ViewModelStoreNavEntryDecorator は、バックスタックにある 「各エントリ(NavEntry)」ごとに独立した ViewModelStore を生成してくれます。

RouteB("1") のエントリ ➔ 専用の ViewModelStore A が用意される

RouteB("2") のエントリ ➔ 専用の ViewModelStore B が用意される

viewModel() 関数は、その時点の LocalViewModelStoreOwner(Decorator が提供するエントリごとのストア)を参照します。そのため、わざわざ key を指定しなくても、エントリが違えば自動的に別の ViewModel が作られる仕組みです。

 

🤔 まとめ:どっちを使うべき?

基本的には「Decorator を使う」のが正解です。

理由1:
コードがシンプルになる(key の指定漏れがなくなる)。

理由2:
画面を戻ったときに ViewModel が正しく破棄されるなど、ライフサイクル管理が Navigation3 のエントリと完全に同期する。

「この画面だけは特殊な管理をしたい」という場合を除き、rememberViewModelStoreNavEntryDecorator() をセットアップして、型安全な key をそのまま Factory に渡すスタイルを基本にしましょう。


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 アプリ開発のスタンダードな構成と言えるでしょう。


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