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)」を今すぐあなたのプロジェクトに導入できます。


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


Mac Book の 日本語 JIS キーボードを 英語 US キーボードになるべく違和感なく変える方法

日本語 JIS キーボードが馴染めない場合とか

ストレスすぎますよね。

逆もしかり。

様々なキーボードマッピングカスタマイズアプリなどありますが、

強引にレイアウトを変更したとて、

そもそもキーそれぞれ一つ一つのサイズが違う。

この方法がベストだと思われます。

 

🧑🏻‍💻 Magic Keyboard を手に入れる

少し高いですが。

👉 Magic Keyboard - 英語(US) - Apple(日本)

これを買うなりして手に入れるのが一番操作感は一番近いです。

日本語 JIS と 英語 US。

指紋センサー付きとなし。

Mac Bookのキーボード。

すべてサイズ同じです。

上に乗せればそれが一番良い感じです。

以上です。

知らなかったわ。


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は便利だが、抽象化が増えるため「内部の仕組み」が見えにくい感じに思います。


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 におけるイベントハンドリングを最もシンプルにする考え方の一つです。