Compose + Koin ViewModel 实战完全手册

0 阅读5分钟

从基础注入到多页面共享、带参与避坑(可直接做学习笔记)

本篇专注于 Jetpack Compose + Koin 下 ViewModel 的正确使用姿势,覆盖基础注入、独立 VM、页面共享、带参构造、SavedStateHandle、验证共享、Navigation 嵌套导航共享、常见错误,全部为实战可运行写法,适合长期查阅。


一、前置说明

1. 作用域核心概念

  • 页面独立 VM:每个 Compose 页面单独实例,页面退出后销毁

  • 共享 VM:多个页面共用同一个实例,生命周期由 ViewModelStoreOwner 控制

    • Activity 级别:整个 App 内共享
    • NavGraph 级别:仅当前路由流程内共享
  • Compose 中获取 VM 统一使用函数调用形式

2. 依赖配置

gradle

// Koin 核心
implementation "io.insert-koin:koin-android:3.5.3"
implementation "io.insert-koin:koin-androidx-viewmodel:3.5.3"
implementation "io.insert-koin:koin-androidx-compose:3.5.3"

// Compose 生命周期
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"

// Compose Navigation
implementation "androidx.navigation:navigation-compose:2.7.7"

3. Koin 模块定义

kotlin

// 仓库
class SpeechRepository

// 普通 VM
class SpeechRecognitionViewModel(
    val repo: SpeechRepository
) : ViewModel()

// 带参数 + SavedStateHandle 的 VM
class DetailViewModel(
    val id: Int,
    val repo: SpeechRepository,
    val savedStateHandle: SavedStateHandle
) : ViewModel()

// Koin 模块
val appModule = module {
    // 单例仓库
    single { SpeechRepository() }

    // 普通 ViewModel
    viewModel { SpeechRecognitionViewModel(get()) }

    // 带参数 + 自动注入 SavedStateHandle
    viewModel { (id: Int) ->
        DetailViewModel(
            id = id,
            repo = get(),
            savedStateHandle = get() // Koin 自动提供
        )
    }
}

4. Application 初始化

kotlin

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@MyApp)
            modules(appModule)
        }
    }
}

二、Compose 中获取 ViewModel 的标准写法

1. 页面独立 VM(不共享)

每个页面独立实例,互不干扰,最常用。

kotlin

@Composable
fun SpeechPage() {
    val vm: SpeechRecognitionViewModel = koinViewModel()
}

2. 全局共享 VM(绑定 Activity)

整个 Activity 下所有 Compose 页面共享同一个实例。

kotlin

@Composable
fun SharedSpeechPage() {
    val vm: SpeechRecognitionViewModel = koinViewModel(
        viewModelStoreOwner = LocalContext.current as ViewModelStoreOwner
    )
}

3. 导航图内共享 VM(推荐规范)

只在当前导航流程内共享,退出路由自动回收,生命周期更合理。

kotlin

@Composable
fun NavGraphSharedPage(navBackStackEntry: NavBackStackEntry) {
    val vm: SpeechRecognitionViewModel = koinViewModel(
        viewModelStoreOwner = navBackStackEntry
    )
}

4. 最稳共享方案:CompositionLocal 提供

不依赖 Koin Compose 扩展,无版本兼容问题,100% 不崩溃。

步骤 1:Activity 创建并提供 VM

kotlin

class MainActivity : ComponentActivity() {
    private val sharedVm: SpeechRecognitionViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CompositionLocalProvider(LocalSpeechVM provides sharedVm) {
                AppNavHost() // 整个导航都能共享
            }
        }
    }
}

步骤 2:定义 CompositionLocal

kotlin

val LocalSpeechVM = compositionLocalOf<SpeechRecognitionViewModel> {
    error("请在 Activity 中通过 LocalSpeechVM 提供实例")
}

步骤 3:页面直接使用

kotlin

@Composable
fun PageA() {
    val vm = LocalSpeechVM.current
}

@Composable
fun PageB() {
    val vm = LocalSpeechVM.current // 同一实例
}

三、ViewModel 带参注入(实战高频)

1. Koin 模块定义(支持动态参数)

kotlin

viewModel { (id: Int) ->
    DetailViewModel(
        id = id,
        repo = get(),
        savedStateHandle = get()
    )
}

2. Compose 中传入参数

kotlin

@Composable
fun DetailPage(itemId: Int) {
    val vm: DetailViewModel = koinViewModel(
        parameters = { parametersOf(itemId) }
    )
}

3. 配合 Navigation 传参

kotlin

composable("detail/{id}") { backStack ->
    val id = backStack.arguments?.getInt("id") ?: 0
    val vm: DetailViewModel = koinViewModel(
        parameters = { parametersOf(id) }
    )
    DetailPage(vm)
}

四、SavedStateHandle 自动注入

Koin 可以自动注入 SavedStateHandle,无需手动处理。

1. ViewModel

kotlin

class SearchViewModel(
    val savedStateHandle: SavedStateHandle
) : ViewModel() {
    // 保存页面状态
    var keyword by mutableStateOf(savedStateHandle["keyword"] ?: "")
        private set

    fun updateKeyword(text: String) {
        keyword = text
        savedStateHandle["keyword"] = text
    }
}

2. Koin 注册

kotlin

viewModel { SearchViewModel(get()) }

3. Compose 使用

kotlin

@Composable
fun SearchPage() {
    val vm: SearchViewModel = koinViewModel()
}

五、Compose Navigation 完整路由 + 嵌套导航共享 VM 实战

下面是可直接复制运行的完整导航结构,包含:

  • 根导航
  • 嵌套导航(如 “用户中心” 模块)
  • 嵌套导航内页面共享 VM

1. 定义路由表

kotlin

object Route {
    const val HOME = "home"
    const val SEARCH = "search"
    const val DETAIL = "detail/{id}"

    // 嵌套导航
    const val USER_GRAPH = "user_graph"
    const val USER_PROFILE = "user_profile"
    const val USER_SETTING = "user_setting"
}

2. 嵌套导航共享的 ViewModel

kotlin

class UserViewModel : ViewModel() {
    val userName = mutableStateOf("张三")
}

3. Koin 注册

kotlin

val appModule = module {
    viewModel { UserViewModel() }
}

4. 根导航 + 嵌套导航实现

kotlin

@Composable
fun AppNavHost(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = Route.HOME
    ) {
        // 首页
        composable(Route.HOME) {
            HomePage(navController)
        }

        // 搜索页
        composable(Route.SEARCH) {
            SearchPage()
        }

        // 详情页(带参)
        composable(Route.DETAIL) { backStack ->
            val id = backStack.arguments?.getInt("id") ?: 0
            val vm: DetailViewModel = koinViewModel(
                parameters = { parametersOf(id) }
            )
            DetailPage(vm)
        }

        // =============== 嵌套导航:用户模块 ===============
        navigation(
            startDestination = Route.USER_PROFILE,
            route = Route.USER_GRAPH
        ) {
            composable(Route.USER_PROFILE) { backStackEntry ->
                // 获取嵌套导航作用域的 VM → 嵌套内所有页面共享
                val userVm: UserViewModel = koinViewModel(
                    viewModelStoreOwner = backStackEntry
                )
                UserProfilePage(userVm)
            }

            composable(Route.USER_SETTING) { backStackEntry ->
                // 和上面同一个 UserViewModel 实例
                val userVm: UserViewModel = koinViewModel(
                    viewModelStoreOwner = backStackEntry
                )
                UserSettingPage(userVm)
            }
        }
    }
}

5. 页面示例

kotlin

@Composable
fun HomePage(nav: NavController) {
    Column {
        Text("首页")
        Button(onClick = { nav.navigate(Route.USER_GRAPH) }) {
            Text("去用户中心")
        }
    }
}

@Composable
fun UserProfilePage(vm: UserViewModel) {
    Text("用户名:${vm.userName.value}")
}

@Composable
fun UserSettingPage(vm: UserViewModel) {
    Text("设置页 - 用户名:${vm.userName.value}")
}

六、如何验证 ViewModel 是否真的共享?

唯一可靠方法:打印 hashCode /identityHashCode

  • 两个页面 hashCode 相同 = 真正共享
  • 不同 = 独立实例

kotlin

import android.util.Log

@Composable
fun UserProfilePage(vm: UserViewModel) {
    Log.d("VM_DEBUG", "Profile VM hash = ${vm.hashCode()}")
}

@Composable
fun UserSettingPage(vm: UserViewModel) {
    Log.d("VM_DEBUG", "Setting VM hash = ${vm.hashCode()}")
}

成功日志示例

plaintext

VM_DEBUG: Profile VM hash = 12345678
VM_DEBUG: Setting VM hash = 12345678

hashCode 完全一致 → 共享成功 ✅


七、为什么不推荐把 VM 当作参数传入 Compose?

很多人习惯这样写:

kotlin

@Composable
fun PageA(vm: SpeechRecognitionViewModel)

存在问题

  1. 容易触发无意义重组Compose 根据参数变化判断重组,父组件重组会导致子页面无辜重组。
  2. **参数透传地狱(Prop Drilling)**多层嵌套时需要层层传递 VM,代码混乱、耦合极高。
  3. 生命周期不清晰无法直观判断 VM 属于 Activity、NavGraph 还是局部组件。

更好的方式

  • 页面内部直接获取:koinViewModel(...)
  • 全局共享:CompositionLocal
  • 导航共享:koinViewModel(navBackStackEntry)

八、常见错误与崩溃原因(避坑重点)

1. 错误强转类型导致崩溃

kotlin

// 崩溃代码
koinViewModel(LocalViewModelStoreOwner.current as Qualifier?)

原因:Qualifier 是 Koin 命名标识,与 ViewModelStoreOwner 无关,强转必然类型异常。

2. 参数名写错(owner /viewModelStoreOwner)

kotlin

// 报错:No parameter with name 'owner'
koinViewModel(owner = xxx)

Koin 3.x 正确参数名为:viewModelStoreOwner

3. 作用域为空导致崩溃

LocalViewModelStoreOwner.current 可能在某些场景(弹窗、嵌套导航)下为 null,直接使用会崩。更安全写法:

kotlin

val vm: SpeechRecognitionViewModel = koinViewModel(
    viewModelStoreOwner = LocalContext.current as ViewModelStoreOwner
)

4. 多次创建导致 “看起来没共享”

每个页面都直接写:

kotlin

koinViewModel()

这会创建独立实例,不是共享。


九、场景与最佳用法速查表

表格

场景推荐写法是否共享特点
单个页面独立使用koinViewModel()最简单、无耦合
整个 Activity 全局共享koinViewModel(LocalContext as Owner)全局通用
业务流程内共享(下单 / 搜索)koinViewModel(navBackStackEntry)生命周期合理,推荐
嵌套导航内共享koinViewModel(backStackEntry)模块化、自动回收
跨页面且兼容所有版本CompositionLocal 提供最稳、不依赖 Koin 版本
页面恢复状态(旋转 / 后台)SavedStateHandle 自动注入状态不丢失
动态 ID / 参数页面parametersOf(id)带参 VM 标准用法

十、总结

  • Compose 中获取 VM 统一使用 koinViewModel() 函数形式
  • 共享 VM 核心是提升作用域:Activity / NavGraph / 嵌套导航
  • 验证共享只看 hashCode 是否一致
  • 尽量避免把 VM 作为参数传递,减少重组与耦合
  • 复杂状态保存使用 SavedStateHandle,Koin 可自动注入
  • 模块化业务优先使用嵌套导航共享 VM,生命周期最合理
  • 追求极致稳定 → 使用 CompositionLocal 共享方案