从基础注入到多页面共享、带参与避坑(可直接做学习笔记)
本篇专注于 Jetpack Compose + Koin 下 ViewModel 的正确使用姿势,覆盖基础注入、独立 VM、页面共享、带参构造、SavedStateHandle、验证共享、Navigation 嵌套导航共享、常见错误,全部为实战可运行写法,适合长期查阅。
一、前置说明
1. 作用域核心概念
-
页面独立 VM:每个 Compose 页面单独实例,页面退出后销毁
-
共享 VM :多个页面共用同一个实例,生命周期由
ViewModelStoreOwner控制- Activity 级别:整个 App 内共享
- NavGraph 级别:仅当前路由流程内共享
-
Compose 中获取 VM 统一使用函数调用形式
2. 依赖配置
gradle
arduino
// 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
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
kotlin
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApp)
modules(appModule)
}
}
}
二、Compose 中获取 ViewModel 的标准写法
1. 页面独立 VM(不共享)
每个页面独立实例,互不干扰,最常用。
kotlin
kotlin
@Composable
fun SpeechPage() {
val vm: SpeechRecognitionViewModel = koinViewModel()
}
2. 全局共享 VM(绑定 Activity)
整个 Activity 下所有 Compose 页面共享同一个实例。
kotlin
kotlin
@Composable
fun SharedSpeechPage() {
val vm: SpeechRecognitionViewModel = koinViewModel(
viewModelStoreOwner = LocalContext.current as ViewModelStoreOwner
)
}
3. 导航图内共享 VM(推荐规范)
只在当前导航流程内共享,退出路由自动回收,生命周期更合理。
kotlin
kotlin
@Composable
fun NavGraphSharedPage(navBackStackEntry: NavBackStackEntry) {
val vm: SpeechRecognitionViewModel = koinViewModel(
viewModelStoreOwner = navBackStackEntry
)
}
4. 最稳共享方案:CompositionLocal 提供
不依赖 Koin Compose 扩展,无版本兼容问题,100% 不崩溃。
步骤 1:Activity 创建并提供 VM
kotlin
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
go
val LocalSpeechVM = compositionLocalOf<SpeechRecognitionViewModel> {
error("请在 Activity 中通过 LocalSpeechVM 提供实例")
}
步骤 3:页面直接使用
kotlin
kotlin
@Composable
fun PageA() {
val vm = LocalSpeechVM.current
}
@Composable
fun PageB() {
val vm = LocalSpeechVM.current // 同一实例
}
三、ViewModel 带参注入(实战高频)
1. Koin 模块定义(支持动态参数)
kotlin
ini
viewModel { (id: Int) ->
DetailViewModel(
id = id,
repo = get(),
savedStateHandle = get()
)
}
2. Compose 中传入参数
kotlin
kotlin
@Composable
fun DetailPage(itemId: Int) {
val vm: DetailViewModel = koinViewModel(
parameters = { parametersOf(itemId) }
)
}
3. 配合 Navigation 传参
kotlin
scss
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
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
scss
viewModel { SearchViewModel(get()) }
3. Compose 使用
kotlin
kotlin
@Composable
fun SearchPage() {
val vm: SearchViewModel = koinViewModel()
}
五、Compose Navigation 完整路由 + 嵌套导航共享 VM 实战
下面是可直接复制运行的完整导航结构,包含:
- 根导航
- 嵌套导航(如 "用户中心" 模块)
- 嵌套导航内页面共享 VM
1. 定义路由表
kotlin
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
kotlin
class UserViewModel : ViewModel() {
val userName = mutableStateOf("张三")
}
3. Koin 注册
kotlin
ini
val appModule = module {
viewModel { UserViewModel() }
}
4. 根导航 + 嵌套导航实现
kotlin
scss
@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
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
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
ini
VM_DEBUG: Profile VM hash = 12345678
VM_DEBUG: Setting VM hash = 12345678
hashCode 完全一致 → 共享成功 ✅
七、为什么不推荐把 VM 当作参数传入 Compose?
很多人习惯这样写:
kotlin
kotlin
@Composable
fun PageA(vm: SpeechRecognitionViewModel)
存在问题
- 容易触发无意义重组Compose 根据参数变化判断重组,父组件重组会导致子页面无辜重组。
- **参数透传地狱(Prop Drilling)**多层嵌套时需要层层传递 VM,代码混乱、耦合极高。
- 生命周期不清晰无法直观判断 VM 属于 Activity、NavGraph 还是局部组件。
更好的方式
- 页面内部直接获取:
koinViewModel(...) - 全局共享:
CompositionLocal - 导航共享:
koinViewModel(navBackStackEntry)
八、常见错误与崩溃原因(避坑重点)
1. 错误强转类型导致崩溃
kotlin
scss
// 崩溃代码
koinViewModel(LocalViewModelStoreOwner.current as Qualifier?)
原因:Qualifier 是 Koin 命名标识,与 ViewModelStoreOwner 无关,强转必然类型异常。
2. 参数名写错(owner /viewModelStoreOwner)
kotlin
scss
// 报错:No parameter with name 'owner'
koinViewModel(owner = xxx)
Koin 3.x 正确参数名为:viewModelStoreOwner。
3. 作用域为空导致崩溃
LocalViewModelStoreOwner.current 可能在某些场景(弹窗、嵌套导航)下为 null,直接使用会崩。更安全写法:
kotlin
ini
val vm: SpeechRecognitionViewModel = koinViewModel(
viewModelStoreOwner = LocalContext.current as ViewModelStoreOwner
)
4. 多次创建导致 "看起来没共享"
每个页面都直接写:
kotlin
scss
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共享方案