はじめに
Navigation Compose 2.8.0 以降では、シリアル化可能なクラスを用いて、型安全に画面間でデータを渡せるようになりました。
本記事では、「画面遷移時の引数を ViewModel
でどの受け取るか」に焦点を当て、4 つの方法を紹介し比較します。すべて Hilt + Navigation Compose 2.8.0 以降の環境を想定していますが、他の DI フレームワークやバージョンでも応用可能です。
引数の渡し方
Navigation Compose では、引数を含むシリアル化可能なクラスを composable
のルートとして利用できます:
@Serializable
data class SampleRoute(val arg: String)
fun NavGraphBuilder.sample() {
composable<SampleRoute> { /* ... */ }
}
遷移元では、この SampleRoute
を引数として navigate()
を呼び出します:
fun NavController.navigateToSample(arg: String) =
navigate(route = SampleRoute(arg = arg))
引数の受け取り方
遷移先では、NavBackStackEntry
などを通じて route
を取得します。その受け取り方と ViewModel
での扱い方を紹介します。
1. Composable から ViewModel
に渡す(関数呼び出し)
NavGraphBuilder.composable()
の content
ラムダで受け取る NavBackStackEntry
から、toRoute()
により route
を取得します。そして ViewModel
の関数を呼び出すことで route
を ViewModel
に渡します。
以下の例では LifecycleEventEffect
を利用し、ON_CREATE
イベントのタイミングで ViewModel
に route
を渡しています。
✅ メリット
- 最小の知識で実装可能
- 引数を渡すタイミングを制御でき、初期化も柔軟に行える
⚠️ デメリット
-
ViewModel
の初期化時にはまだ引数が使えない - ライフサイクルが複雑な場合は制御が難しい
実装例
fun NavGraphBuilder.sample() {
composable<SampleRoute> { entry ->
val route = entry.toRoute<SampleRoute>()
SampleScreen(route = route)
}
}
@Composable
fun SampleScreen(
route: SampleRoute,
viewModel: SampleViewModel = hiltViewModel(),
) {
LifecycleEventEffect(event = Lifecycle.Event.ON_CREATE) {
viewModel.onCreate(route = route)
}
// ...
}
@HiltViewModel
class SampleViewModel @Inject constructor() : ViewModel() {
private val _arg = MutableStateFlow<String?>(null)
val arg = _arg.asStateFlow()
fun onCreate(route: SampleRoute) {
_arg.value = route.arg
}
}
2. Assisted Injection を使って ViewModel
初期化時に渡す
ViewModel
の初期化タイミングで引数を渡したい場合は、Dagger の Assisted Injection(部分的な DI を可能にする仕組み)が有効です。
Assisted Injection を利用することで、一部の依存関係を Dagger により解決しつつ、別の引数を直接渡すことができます。これにより ViewModel
の初期化時に route
が利用できます。
✅ メリット
- 引数を
ViewModel
初期化に利用できる - テスト時に DI できる
⚠️ デメリット
-
Factory
インターフェースや DI 設定が少し煩雑 - Assisted Injection の理解が必要
実装例
@HiltViewModel(assistedFactory = SampleViewModel.Factory::class)
class SampleViewModel @AssistedInject constructor(
@Assisted route: SampleRoute,
) : ViewModel() {
val arg = route.arg
@AssistedFactory
interface Factory {
fun create(route: SampleRoute): SampleViewModel
}
}
@Composable
fun SampleScreen(
route: SampleRoute,
viewModel: SampleViewModel =
hiltViewModel { factory: SampleViewModel.Factory ->
factory.create(route = route)
},
) {
// ...
}
3. ViewModel
で SavedStateHandle
から受け取る
SavedStateHandle.toRoute()
により route
を取得できます。これにより、Composable を介さずに ViewModel 側で直接引数を受け取ることができます。
toRoute()
は内部で android.os.Bundle
を利用しているため、ユニットテストでは注意が必要です。android.os.Bundle
は Android フレームワークのクラスであり、JVM 単体ではテストできません。シミュレートするには Robolectric が必要になります。また、savedStateHandle["arg"] = "value"
のように引数を指定する必要があります。
✅ メリット
-
ViewModel
の初期化と同時に取得できる - 実装がシンプルでコード量が少ない
⚠️ デメリット
-
android.os.Bundle
に依存しておりユニットテストでは Robolectric が必要 - テストでは
savedStateHandle["arg"] = "value"
のように準備する必要がある
実装例
@HiltViewModel
class SampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val route = savedStateHandle.toRoute<SampleRoute>()
val arg = route.arg
}
@RunWith(RobolectricTestRunner::class)
class SampleViewModelTest {
@Test
fun test_dummy_arg() {
val savedStateHandle = SavedStateHandle()
savedStateHandle["arg"] = "dummy"
val viewModel = SampleViewModel(
savedStateHandle = savedStateHandle,
)
assertEquals(viewModel.arg, "dummy")
}
}
4. SavedStateHandle.toRoute()
を外で呼び、Hilt で注入
テストしやすさを重視する場合、SavedStateHandle.toRoute()
を切り出して、ViewModel
には route
を直接注入する必要があります。
具体的には、SavedStateHandle.toRoute()
を実行する Hilt Module を定義します。これにより、ViewModel
は route
を直接インジェクトできるため、ViewModel
が android.os.Bundle
に依存せず、ユニットテストをより簡単に記述できます。
✅ メリット
-
ViewModel
が Android フレームワークに依存しないため、ユニットテストが容易 - DI による構成がシンプルで、再利用性が高い
⚠️ デメリット
- Hilt Module の定義が必要
- Hilt に慣れていないとやや冗長に感じる
実装例
@Module
@InstallIn(ViewModelComponent::class)
object SampleRouteModule {
@Provides
fun providesSampleRoute(savedStateHandle: SavedStateHandle): SampleRoute =
savedStateHandle.toRoute<SampleRoute>()
}
@HiltViewModel
class SampleViewModel @Inject constructor(
route: SampleRoute,
) : ViewModel() {
val arg = route.arg
}
まとめ
以下は ViewModel
での引数受け取りにおける 4 手法の比較表です。
方法 | 初期化タイミング | テストのしやすさ | 実装の複雑さ |
---|---|---|---|
関数呼び出し | 遅延初期化 | ✅ 初期化を制御できる | 🟡 関数呼び出しが必要 |
Assisted Injection | 即時初期化 | ✅ DI でテスト容易 | 🟡 Factory が必要 |
SavedStateHandle |
即時初期化 | ⚠️ Bundle に依存 | 🟢 最小の実装量 |
Hilt で provides | 即時初期化 | ✅ DI でテスト容易 | 🟡 Module が必要 |
おわりに
本記事で紹介した方法は一例であり、特定のベストプラクティスがあるわけではありません。プロジェクトや開発チームに合わせて選択すべきです。今後の変更にも備え、柔軟な選択が求められます。
本記事の方法は現行の Navigation 2.8.0 に基づいていますが、Navigation 3 のリリースが予定されています。Navigation 3 は Composable を主軸に置いており、画面遷移についてが大きな変更があります。本記事で紹介した中では、特に序盤の方法が今後の標準的なアプローチになる可能性が高いと予想しています。
参考文献