1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Navigation Compose における ViewModel での引数の受け取り方 4 選と比較

Last updated at Posted at 2025-06-14

はじめに

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 の関数を呼び出すことで routeViewModel に渡します。

以下の例では LifecycleEventEffect を利用し、ON_CREATE イベントのタイミングで ViewModelroute を渡しています。

✅ メリット

  • 最小の知識で実装可能
  • 引数を渡すタイミングを制御でき、初期化も柔軟に行える

⚠️ デメリット

  • 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. ViewModelSavedStateHandle から受け取る

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 を定義します。これにより、ViewModelroute を直接インジェクトできるため、ViewModelandroid.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 を主軸に置いており、画面遷移についてが大きな変更があります。本記事で紹介した中では、特に序盤の方法が今後の標準的なアプローチになる可能性が高いと予想しています。

参考文献

1
2
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?