inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

Navigation 3 の Shared ViewModel の実現方法について考える

Android、またはKMPエンジニアのみなさま、あけましておめでとうございます yokomii です。

Navigation 3 の Stable リリースが去年の12月にありましたが、早速プロダクト導入は進んでいますでしょうか。
弊社では Stable リリースの直後から導入を開始し、Navigation 2 からの移行を進めている最中です。
(移行にあたっての事前調査について、DroidKaigi 2025 で登壇した のでよろしければそちらもご参照ください。)

本記事は Navigation 2 のネストグラフにおいて、グラフ内のエントリー(画面)間で ViewModel を共有する「Shared ViewModel」を、 Navigation 3 環境で実現する方法について紹介します。
Navigation 3(1.0.0)ではネストグラフの機能がサポートされていないため、自前で実装する必要があります。

(本記事は スマートバンク 新春エンジニア駅伝 2026 企画の十二区目です。
十一区は stefafafan さんによる Node.jsの脆弱性対応を迅速に進めるために実施したこと でした。)


Shared ViewModel とは

Shared ViewModel を用いることで、エントリー(画面)間の状態の受け渡しが可能になります。

例えば「入力情報の表示画面」と「情報の入力画面」間で ViewModel を共用し、

①「入力情報の表示画面」から「情報の入力画面」に遷移

↓

②「情報の入力画面」で入力情報を Shared ViewModel に保存し、画面を閉じる

↓

③「入力情報の表示画面」に戻ると、 Shared ViewModel の情報が反映されている

といった状態の同期が可能です。

Navigation 2 のネストグラフの仕組みを用いると、各内部エントリーよりも生存期間が長いことが保証されている、「ネストグラフのライフサイクル」に紐づいた ViewModel(StoreOwner) を生成することが可能です。

fun NavGraphBuilder.informationInputGraph(
    navController: NavController,
) {
    navigation<InformationInputGraphRoute>(
        startDestination = InformationRoute,
    ) {
        composable<InformationRoute> { entry ->
            val graphEntry = remember(entry) { navController.getBackStackEntry<InformationInputGraphRoute>() }
            // ネストグラフのホストの NavBackStackEntry を StoreOwner として Shared ViewModel を生成
            val sharedViewModel = viewModel<InformationInputViewModel>(viewModelStoreOwner = graphEntry)
            InformationScreen(
                sharedViewModel = sharedViewModel,
                onClickButton = { navController.navigate(InputRoute) },
            )
        }
        composable<InputRoute> { entry ->
            val graphEntry = remember(entry) { navController.getBackStackEntry<InformationInputGraphRoute>() }
            val sharedViewModel = viewModel<InformationInputViewModel>(viewModelStoreOwner = graphEntry)
            InputScreen(
                onClickSave = { info ->
                    sharedViewModel.saveInfo(info)
                    navController.popBackStack()
                },
            )
        }
    }
}

このように「ネストグラフのライフサイクル」の ViewModel を各エントリーで共用することで、状態の同期を図れます。

しかし、Navigation 3(1.0.0)においては、同様のネストグラフの仕組みが存在しません。
したがって、別のアプローチをとる必要があります。
今回はその方法を2つ紹介します。

方法①: ViewModelStoreOwner 共有

Navigation 3 では、NavEntryそれぞれ独立した ViewModelStoreOwner を持つ設計になっています。 そのため、

  • ある画面(A)で生成した ViewModel を
  • 別の画面(B)でも 同じインスタンスとして使いたい

という場合、B画面側で A画面の ViewModelStore を明示的に参照する必要があります。

Navigation 3 には NavEntryDecorator という拡張ポイントがあり、各 NavEntryContent を描画する直前に処理を差し込むことができます。
この仕組みを使って、

  • 共有元エントリーでは、自身の ViewModelStore を登録する
  • 共有先エントリーでは、登録済みの ViewModelStore を参照し
  • 自分の ViewModelStoreOwner の代わりに、それを使うよう差し替える

という処理を行います。
そのために用意したのが、次の SharedViewModelStoreNavEntryDecorator です。
(コード量が多いため、まず全体を掲載し、その後に補足します。)

val LocalSharedViewModelStoreOwner =
    staticCompositionLocalOf<ViewModelStoreOwner> {
        error(
            "LocalSharedViewModelStoreOwner is not provided. " +
                "Did you forget to add SharedViewModelStoreNavEntryDecorator " +
                "or are you accessing it from a non-shared entry?",
        )
    }

class SharedViewModelStoreNavEntryDecorator<T : Any>(
    private val viewModelStore: ViewModelStore,
) : NavEntryDecorator<T>(
    onPop = { contentKey ->
        viewModelStore.getRegistryHolderViewModel().onPop(contentKey)
    },
    decorate = { entry: NavEntry<T> ->
        val selfOwner =
            checkNotNull(LocalViewModelStoreOwner.current) {
                "SharedViewModelStoreNavEntryDecorator requires " +
                    "ViewModelStoreNavEntryDecorator to provide LocalViewModelStoreOwner."
            }
        val selfStore = selfOwner.viewModelStore
        val selfSavedState = selfOwner as SavedStateRegistryOwner

        val sourceKey = entry.metadata[META_KEY_SOURCE] as? String
        val fromKey = entry.metadata[META_KEY_FROM] as? String
        require(sourceKey == null || fromKey == null || sourceKey != fromKey) {
            "sourceKey and fromKey must not be the same. Please use different shared keys."
        }

        val registryHolderViewModel = viewModelStore.getRegistryHolderViewModel()
        if (sourceKey != null) {
            registryHolderViewModel.register(sourceKey, entry.contentKey, selfStore)
        }

        val sharedStore: ViewModelStore? = fromKey?.let { registryHolderViewModel.get(it) }
        if (fromKey != null && sharedStore == null) {
            error(
                "Shared ViewModelStore not found for fromKey=$fromKey. " +
                    "contentKey=${entry.contentKey}, " +
                    "sourceKey=$sourceKey, " +
                    "selfOwner=${selfOwner::class.qualifiedName}",
            )
        }

        if (sharedStore != null) {
            val sharedOwner: ViewModelStoreOwner =
                remember(sharedStore, selfSavedState) {
                    object :
                        ViewModelStoreOwner,
                        SavedStateRegistryOwner by selfSavedState {
                        override val viewModelStore: ViewModelStore
                            get() = sharedStore
                    }
                }
            CompositionLocalProvider(LocalSharedViewModelStoreOwner provides sharedOwner) {
                entry.Content()
            }
        } else {
            entry.Content()
        }
    },
) {
    companion object {
        const val META_KEY_SOURCE = "shared_vm_store_source"
        const val META_KEY_FROM = "shared_vm_store_from"

        inline fun <reified K : NavKey> source(): Map<String, Any> =
            mapOf(META_KEY_SOURCE to defaultSharedVmKey<K>())

        inline fun <reified K : NavKey> from(): Map<String, Any> =
            mapOf(META_KEY_FROM to defaultSharedVmKey<K>())

        inline fun <reified K : NavKey> defaultSharedVmKey(): String {
            val base = K::class.qualifiedName
                ?: K::class.simpleName
                ?: error("NavKey ${K::class} has no name")
            return base
        }
    }
}

private class SharedRegistryHolderViewModel : ViewModel() {
    private val storesBySharedKey = mutableMapOf<String, ArrayDeque<ViewModelStore>>()
    private val ownerByContentKey = mutableMapOf<Any, OwnerRecord>()

    private data class OwnerRecord(
        val sharedKey: String,
        val store: ViewModelStore,
    )

    fun register(sharedKey: String, contentKey: Any, store: ViewModelStore) {
        if (ownerByContentKey.containsKey(contentKey)) return
        storesBySharedKey.getOrPut(sharedKey) { ArrayDeque() }.addLast(store)
        ownerByContentKey[contentKey] = OwnerRecord(sharedKey, store)
    }

    fun onPop(contentKey: Any) {
        val record = ownerByContentKey.remove(contentKey) ?: return
        val deque = storesBySharedKey[record.sharedKey] ?: return
        deque.remove(record.store)
        if (deque.isEmpty()) {
            storesBySharedKey.remove(record.sharedKey)
        }
    }

    fun get(sharedKey: String): ViewModelStore? =
        storesBySharedKey[sharedKey]?.lastOrNull()
}

private fun ViewModelStore.getRegistryHolderViewModel(): SharedRegistryHolderViewModel {
    val provider = ViewModelProvider.create(
        store = this,
        factory = viewModelFactory { initializer { SharedRegistryHolderViewModel() } },
    )
    return provider[SharedRegistryHolderViewModel::class]
}

@Composable
internal fun <T : Any> rememberSharedViewModelStoreNavEntryDecorator(
    viewModelStoreOwner: ViewModelStoreOwner =
        checkNotNull(LocalViewModelStoreOwner.current) {
            "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner."
        },
): SharedViewModelStoreNavEntryDecorator<T> {
    return remember(viewModelStoreOwner) {
        SharedViewModelStoreNavEntryDecorator(viewModelStoreOwner.viewModelStore)
    }
}

まず、このデコレータは NavEntry.metadata に付与された情報をもとに、エントリーごとに振る舞いを切り替える構造になっています。

val sourceKey = entry.metadata[META_KEY_SOURCE] as? String
val fromKey = entry.metadata[META_KEY_FROM] as? String
  • sourceKey が指定されているエントリーは「共有元」
  • fromKey が指定されているエントリーは「共有先」

として扱われます。

両方が同時に、かつ同一キーで指定されることは想定していないため、ガードを入れています。

require(sourceKey == null || fromKey == null || sourceKey != fromKey)

共有元エントリーの ViewModelStore を登録する

sourceKey が指定されている場合、そのエントリー自身の ViewModelStore を共有対象として登録します。

if (sourceKey != null) {
    registryHolderViewModel.register(sourceKey, entry.contentKey, selfStore)
}

ここで使われている SharedRegistryHolderViewModel は、
共有キーと ViewModelStore の対応関係を保持するための ViewModelです。
この ViewModel は、NavDisplay を描画している親コンポーザブルの ViewModelStore に紐づいて生成されるため、個々のエントリーよりも長いライフサイクルで状態を保持できます。

共有先エントリーで ViewModelStore を引き当てる

fromKey が指定されているエントリーでは、先ほど登録された ViewModelStore を取得します。

val sharedStore: ViewModelStore? =
    fromKey?.let { registryHolderViewModel.get(it) }

SharedRegistryHolderViewModel 内部では、

sharedKey -> ArrayDeque<ViewModelStore>

という形で Store を保持しており、get() では常に 最後に登録された Store を返します。
これにより、同じ共有キーを持つエントリーが複数積まれた場合、共有先は常に「もっとも直近の共有元」を参照できます。

ViewModelStoreOwner を差し替えて Content を描画する

共有対象の ViewModelStore が見つかった場合は、ViewModelStore だけを差し替えた ViewModelStoreOwner を作成します。

val sharedOwner: ViewModelStoreOwner =
    remember(sharedStore, selfSavedState) {
        object :
            ViewModelStoreOwner,
            SavedStateRegistryOwner by selfSavedState {
            override val viewModelStore: ViewModelStore
                get() = sharedStore
        }
    }

ここでは、

  • ViewModelStore → 共有されたものを使用
  • SavedStateRegistryOwner → 元のエントリーのものを使用

という構成にしています。
これにより ViewModel だけを共有し、SavedState はエントリー単位で分離できます。

作成した owner は CompositionLocal を通してエントリーの Content に渡されます。

CompositionLocalProvider(
    LocalSharedViewModelStoreOwner provides sharedOwner
) {
    entry.Content()
}

このスコープ内で LocalSharedViewModelStoreOwner を使って ViewModel を取得すれば、共有元エントリーと同一の ViewModel インスタンスが返ります。

エントリーが pop されたときの後始末

最後に、エントリーが backstack から外れた際の処理です。

onPop = { contentKey ->
    viewModelStore.getRegistryHolderViewModel().onPop(contentKey)
}

register() 時に保存しておいた contentKey を使って、

  • どの共有キーに属する Store か
  • どの Store を削除すべきか

を特定し、ArrayDeque から取り除きます。
これにより、すでに破棄されたエントリーの ViewModelStore が共有対象として残り続けることを防いでいます。

実装例

StoreOwner 共有構成の具体的な実装例は下記の通りです。

@Composable
fun InformationInputGraph() {
    val backStack = rememberNavBackStack()
    NavDisplay(
        backStack = backStack,
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator(),
            // 追加
            rememberSharedViewModelStoreNavEntryDecorator(),
        ),
        entryProvider = entryProvider {
            // ✅ 共有元(source)
            entry<InformationRoute>(
                    // source メタデータに自身のキーを設定
                metadata = SharedViewModelStoreNavEntryDecorator.source<InformationRoute>(),
            ) {
                    // source 側は “自分の Owner” で ViewModel を作る
                val sharedViewModel = viewModel<InformationInputViewModel>()
                InformationScreen(
                    sharedViewModel = sharedViewModel,
                    onClickButton = { backStack.add(InputRoute) },
                )
            }
            
            // ✅ 共有先(from)
            entry<InputRoute>(
                // from メタデータに共有元のキーを設定
                metadata = SharedViewModelStoreNavEntryDecorator.from<InformationRoute>(),
            ) {
                    // from 側は “共有された Owner” から ViewModel を生成
                val sharedViewModel =
                    viewModel<InformationInputViewModel>(viewModelStoreOwner = LocalSharedViewModelStoreOwner.current)
                InputScreen(
                    onClickSave = { info ->
                        sharedViewModel.saveInfo(info)
                        backStack.removeLast()
                    },
                )
            }
        }
    )
}

ViewModelStoreOwner 共有構成のメリットとデメリット

ここまでで見てきたように、NavEntryDecorator を用いて ViewModelStoreOwner を共有することで、Navigation 3 環境でも Shared ViewModel を実現できます。
一方で、この方式には明確な利点と制約が存在します。

メリット

  • AOSP の実装サンプルに沿っている
  • Navigation 3 の設計モデルを崩さずに Shared ViewModel を導入できる
    • NavEntry が独立した ViewModelStoreOwner を持つ、という前提自体は維持したまま、必要なエントリーにのみ StoreOwner の差し替えを行うため、NavDisplay や backstack 管理の考え方と衝突しにくい構成になっています。

デメリット

  • 共有元エントリーは、共有先エントリーよりも長命である必要がある
    • ViewModelStore はエントリーが破棄されると同時に clear() されます。
    • そのため、共有先エントリーが生きている間は、共有元エントリーが backstack 上に残っていなければなりません。
  • Navigation 2 のネストグラフのような「親子関係」ではない
    • この方式での共有は、あくまで「backstack 上で先に存在しているエントリーの ViewModelStore を参照している」という関係です。
    • ネストグラフのように「グラフ単位のライフサイクルが保証される」わけではないため、親が消えても子が生き続ける、といった構成は取れません。

方法②: Nest NavDisplay

Navigation 2 では、ネストグラフを単位として、エントリー単位ではなく 「グラフ単位のライフサイクル」 に Shared ViewModel を紐づけることができました。
Navigation 3 にはネストグラフという仕組みは存在しませんが、NavDisplay 自体が Composable であるという点に注目すると、同じ考え方を自前で再現できます。

このパターンでやることを整理すると、次の通りです。

  • ネストグラフに相当する単位を NavDisplay として表現する
  • その NavDisplay を表示するエントリーを Shared ViewModel の ViewModelStoreOwner とする

結果として、Navigation 2 のネストグラフと同様に、「グラフに入ってから抜けるまでの間」 で状態を共有する、という感覚で Shared ViewModel を扱うことができます。

実装準備

まず、「グラフ」を表すキーと「グラフ配下の画面」を表すキーを明示的に分けます。

interface NavGraphKey<CK : NavGraphChildKey> :NavKey {
        val startChildKey: CK
}

interface NavGraphChildKey :NavKey

NavGraphKey は擬似ネストグラフそのものを表すキーで、startChildKey によって「このグラフに入ったとき、最初に表示する画面」を定義します。

擬似ネストグラフ用の entry 定義

Nest NavDisplay パターンの中核になるのが、この graphEntry です。
entryProvider の中で「グラフに相当する 1 エントリー」を定義し、その Content 内で NavDisplay を立ち上げます。

inline fun <reified K : NavGraphKey<CK>, CK : NavGraphChildKey> EntryProviderScope<NavKey>.graphEntry(
    crossinline buildEntries: @Composable GraphEntryProvider<CK>.(GraphNavigator<CK>) -> Unit,
) {
    entry<K> { key ->
        val backStack = rememberNavBackStack(key.startChildKey)
        NavDisplay(
            backStack = backStack,
            entryProvider = entryProvider {
                GraphEntryProvider<CK>().apply { buildEntries(backStack) }
                    .childEntries
                    .forEach { it() }
            },
        )
    }
}

グラフ用 NavDisplay の生成

graphEntry の中で生成している backStack は、この entry<K> の Content に閉じた状態で管理されます。

val backStack = rememberNavBackStack(key.startChildKey)

つまり、「グラフに入っている間(= このエントリーが backstack に残っている間)」だけ有効な backStack になり、Shared ViewModel のスコープもここに自然に寄ります。

グラフ配下のエントリー定義

graphEntry の中では、グラフ配下のエントリー(ChildKey)だけを定義したいところですが、entryProvider { ... } の DSL は EntryProviderScope<NavKey> をそのまま受け取るため、そのままだと ChildKey 以外のエントリーも定義できてしまいます。
それを防ぐために、GraphEntryProvider という中間クラスを用意しています。

class GraphEntryProvider<CK : NavGraphChildKey> {
    val childEntries = mutableListOf<EntryProviderScope<NavKey>.() -> Unit>()
    
    inline fun <reified T : CK> childEntry(
        crossinline content: @Composable () -> Unit,
    ) {
        childEntries += { entry<T> { content() } }
    }
}

このクラスを経由することで、graphEntry の利用側に対して ChildKey のエントリー定義のみ を強制できます。

実際の entryProvider への登録は、graphEntry 側でまとめて行われます。

GraphEntryProvider<CK>().apply { buildEntries(backStack) }
    .childEntries
    .forEach { it() }

実装例

Nest NavDisplay 構成の具体的な実装例は下記の通りです。

@Serializable data object InformationInputGraphRoute : NavGraphKey<InformationInputGraphChildRoute> {
    override val startChildKey = InformationRoute
}

sealed interface InformationInputGraphChildRoute : NavGraphChildKey
@Serializable data object InformationRoute : InformationInputGraphChildRoute
@Serializable data object InputRoute : InformationInputGraphChildRoute

@Composable
fun NestNavDisplay() {
    val backStack = rememberNavBackStack()
    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            graphEntry<InformationInputGraphRoute, InformationInputGraphChildRoute> { graphBackStack -> 
                // Nest NavDisplay を内包するエントリーを Owner として Shared ViewModel を生成
                val sharedViewModel = viewModel<InformationInputViewModel>()
                childEntry<InformationRoute> {
                    InformationScreen(
                        sharedViewModel = sharedViewModel,
                        onClickButton = { graphBackStack.add(InputRoute) },
                    )
                }
                childEntry<InputRoute> {
                    InputScreen(
                        onClickSave = { info ->
                            sharedViewModel.saveInfo(info)
                            graphBackStack.removeLast()
                        },
                    )
                }
            }
        },
    )
}

Nest NavDisplay 構成のメリットとデメリット

Nest NavDisplay 構成は、Navigation 2 のネストグラフに近い形で Shared ViewModel を実現できる一方で、構造上の制約もいくつか存在します。
ここでは、この構成単体として見たときの利点と注意点を整理します。

メリット

  • Shared ViewModel のライフサイクルが直感的
    • Shared ViewModel は「グラフに相当する NavDisplay が backstack 上に存在している間」生存します。
    • 子エントリー(画面)が親よりも長生きする、といった構成はそもそも作れないため、ViewModel の破棄タイミングについての考慮が比較的シンプルです。
  • 自前実装が最小限で済む
    • NavEntryDecoratorViewModelStoreOwner の差し替えといった仕組みは使わず、NavDisplay の構造だけで Shared ViewModel を実現できます。
    • 実装量が少ない分、読みやすく、保守コストも低く抑えられます。

デメリット

  • Navigator(backstack)がスコープごとに分離される
    • Nest NavDisplay 構成では、親と子で backstack が完全に別物になります。
    • そのため、親スコープの Navigator から子スコープのエントリーへ直接遷移しようとすると、エントリー未登録としてエラーになります。
    • 逆に、子スコープの Navigator から親スコープのルートへ遷移することもできません。
  • マルチペイン構成に制約が生じる
    • マルチペインは、それぞれの NavDisplay スコープ内でのみ成立します。
    • 親と子の NavDisplay を跨いだマルチペイン構成は取れないため、画面構成によっては設計上の制約になります。
  • NavDisplay の設定が分断される
    • Nest NavDisplay 内部で立ち上げた NavDisplay には、親 NavDisplay の設定は自動では引き継がれません。
    • NavEntryDecorator や画面遷移アニメーション、バックハンドリングなどは、Nest NavDisplay 側でも改めて設定する必要があります。

各構成の比較

Navigation 3 環境で Shared ViewModel を実現する方法として、ここまでに以下の 2 つの構成を紹介しました。

  • ViewModelStoreOwner 共有構成

    NavEntryDecorator を用いて ViewModelStoreOwner を差し替える方法

  • Nest NavDisplay 構成

    NavDisplay をグラフ単位として扱い、そのライフサイクルに ViewModel を紐づける方法

それぞれの特徴を整理すると、次のようになります。

観点 ViewModelStoreOwner 共有構成 Nest NavDisplay 構成
Shared ViewModel のスコープ backstack 上の任意のエントリー NavDisplay(擬似ネストグラフ)単位
ライフサイクルの分かりやすさ 共有元と共有先の関係を意識する必要あり グラフに入ってから抜けるまでで明確
ViewModel の破棄タイミング 実装次第で注意が必要 親 NavDisplay の破棄と常に一致
ナビゲーションの自由度 高い(単一 backstack) 親子で backstack が分離される
マルチペイン対応 柔軟に構成可能 NavDisplay スコープ内に限定される
実装コスト やや高い(Decorator / Owner 管理) 比較的低い(構造のみで実現)
保守性・将来対応 拡張性は高いが実装理解が必要 シンプルで追従しやすい

ワンバンクでの採用判断

弊社のアプリ「ワンバンク」では、Navigation 3 移行にあたり Nest NavDisplay 構成を採用しています。
主な理由は次の 2 点です。

  • ViewModel の破棄タイミングが安全で分かりやすい
    • Shared ViewModel の寿命が NavDisplay(グラフ)単位で明確に決まるため、画面構成が複雑になってもライフサイクルの把握が容易
  • 実装・保守コストが低い
    • NavEntryDecoratorViewModelStoreOwner の差し替えといった自前実装を最小限に抑えられる
    • 将来的に Navigation 3 側でネストグラフに相当する公式機能が提供された場合でも、似たような構成になると予想

Navigation 3 自体がまだ新しいライブラリで、頻繁に更新があることを見越した上で、最小限の対応で変更に追従しやすいであろう方法を採用することにしました。

まとめ

本記事では、Navigation 3 環境において Shared ViewModel を実現するための2つの構成を紹介しました。

Navigation 2 では、ネストグラフという仕組みによって「グラフ単位のライフサイクルに ViewModel を紐づける」という設計が自然に行えていましたが、Navigation 3 ではその前提がなくなり、状態共有の方法を改めて選ぶ必要があります。

もっとも、エントリー間で状態を共有したいという要件自体は、必ずしも ViewModel を必要としません。
例えば AOSP のレシピでは、状態ストアに結果を保持し、各画面からそれを参照するというアプローチも紹介されています。

Navigation 3 では、Shared ViewModel を使うかどうかも含めて、「状態管理の責務をどこに置くのか」を明示的に考えることが求められます。
逆に言えば、ネストグラフという単一の正解が用意されていない分、アプリの画面構成や状態管理の粒度に応じて、構成を選択できる余地が広がっているとも言えそうです。

本記事が、Navigation 3 への移行や Shared ViewModel の設計を考える際の一助になれば幸いです。


明日の駅伝第十三区走者は、nissyiさんです。
がんばれ〜〜🏃💨

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.