第19章:Koin与ViewModel:viewModel DSL、ViewModel注入的最佳实践

ViewModel 的依赖注入,说白了就是解决一个问题:ViewModel 怎么拿到它需要的那些依赖

我刚开始用 Koin 的时候,觉得这玩意儿跟注入普通对象没啥区别。后来踩了几个坑才明白——ViewModel 的生命周期跟 Activity/Fragment 绑定,它的创建方式也跟普通类不一样。嗯,这里要注意的东西其实不少。

19.1 为什么 ViewModel 的注入需要特殊处理?

你想想看,普通的类我们直接用 get() 或者构造函数注入就行。但 ViewModel 呢?它是由 ViewModelProvider 管理的,系统负责创建和缓存。如果我们自己 new 一个 ViewModel,那配置旋转时数据就丢了——这显然不行。

所以 Koin 提供了专门的 viewModel DSL,让你既能享受依赖注入的便利,又不破坏 ViewModel 的生命周期管理。

核心区别:

  • single { } → 全局单例,跟 Activity 生命周期无关
  • factory { } → 每次注入都创建新实例
  • viewModel { } → 由 ViewModelProvider 管理,生命周期感知

19.2 viewModel DSL 的基本用法

先看一个最简单的例子。假设我们有一个 UserViewModel,它依赖 UserRepository

// 1. 定义模块
val appModule = module {
    single { UserRepository() }
    viewModel { UserViewModel(get()) }
}

// 2. 在 Activity 中注入
class MainActivity : AppCompatActivity() {
    private val viewModel: UserViewModel by viewModel()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 直接用,不用管创建和缓存
        viewModel.loadUser()
    }
}

这里 by viewModel() 是 Koin 提供的委托属性。它背后调用了 ViewModelProvider,所以 ViewModel 的缓存机制完全不受影响。

个人习惯:我一般把 ViewModel 的声明放在 Activity/Fragment 的顶部,用 private val 修饰。这样整个类都能访问,而且生命周期清晰。

19.3 带参数的 ViewModel 注入

实际项目中,ViewModel 经常需要接收一些运行时参数。比如用户 ID、订单 ID 之类的。Koin 也支持这种情况:

// 定义带参数的 ViewModel
class OrderViewModel(
    private val orderId: String,
    private val orderRepository: OrderRepository
) : ViewModel() {
    // ...
}

// 在模块中声明
val appModule = module {
    single { OrderRepository() }
    viewModel { params ->
        OrderViewModel(
            orderId = params.get(),
            orderRepository = get()
        )
    }
}

// 在 Activity 中传参
class OrderActivity : AppCompatActivity() {
    private val viewModel: OrderViewModel by viewModel(
        parameters = { parametersOf(intent.getStringExtra("order_id")) }
    )
}

这里有个细节:params.get() 默认返回的是 Any? 类型,你需要确保传参的类型匹配。我曾经在这里翻过车——传了个 Int 进去,但 ViewModel 期望的是 String,结果运行时直接崩溃。

避坑指南:参数类型一定要严格匹配。Koin 不会帮你做隐式转换。建议在传参时显式指定类型,比如 parametersOf("order_123") 而不是 parametersOf(123)

19.4 ViewModel 注入的最佳实践

用了这么多年 Koin,我总结了几条比较实用的经验:

19.4.1 保持 ViewModel 的构造函数简洁

ViewModel 的构造函数里只放它真正需要的依赖。别把整个 Repository 层都塞进去。我见过有人把 7、8 个参数塞进 ViewModel 的构造函数——那代码简直没法维护。

// ❌ 不推荐
class DashboardViewModel(
    private val userRepo: UserRepository,
    private val orderRepo: OrderRepository,
    private val productRepo: ProductRepository,
    private val analyticsRepo: AnalyticsRepository,
    private val configRepo: ConfigRepository
) : ViewModel()

// ✅ 推荐:只放必要的
class DashboardViewModel(
    private val userRepo: UserRepository,
    private val orderRepo: OrderRepository
) : ViewModel()

19.4.2 使用 SavedStateHandle 保存状态

如果 ViewModel 需要保存 UI 状态(比如表单输入),建议用 SavedStateHandle。Koin 也支持:

class FormViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val formRepository: FormRepository
) : ViewModel() {
    
    val userName = savedStateHandle.getLiveData<String>("user_name", "")
    
    fun saveName(name: String) {
        savedStateHandle["user_name"] = name
    }
}

// 模块声明
val appModule = module {
    single { FormRepository() }
    viewModel { FormViewModel(get(), get()) }
}

注意:SavedStateHandle 不需要你手动传参,Koin 会自动注入。但前提是 ViewModel 必须通过 viewModel DSL 声明。

19.4.3 避免在 ViewModel 中持有 Activity 引用

这个其实是个老生常谈的问题。但用 Koin 的时候更容易犯——因为注入太方便了,一不小心就把 Context 传进去了。

我的建议:如果 ViewModel 确实需要 Context,用 AndroidViewModel 而不是直接传 Context。Koin 对 AndroidViewModel 的支持也是开箱即用的:

class MyViewModel(application: Application) : AndroidViewModel(application) {
    // 可以通过 getApplication() 获取 Context
}

// 模块声明
viewModel { MyViewModel(get()) }

19.5 多模块项目中的 ViewModel 注入

项目大了以后,ViewModel 可能分散在不同的模块里。比如 feature:loginfeature:home 各自有自己的 ViewModel。这时候怎么组织模块?

我习惯的做法是:每个 feature 模块自己声明自己的 ViewModel 模块,然后在主模块里统一加载。

// feature/login 模块
val loginModule = module {
    viewModel { LoginViewModel(get()) }
}

// feature/home 模块  
val homeModule = module {
    viewModel { HomeViewModel(get()) }
}

// app 主模块
startKoin {
    modules(
        loginModule,
        homeModule,
        // ... 其他模块
    )
}

这样做的好处是:每个模块的依赖关系清晰,不会出现跨模块的循环依赖。而且测试的时候可以只加载需要的模块。

19.6 测试时的 ViewModel 注入

测试 ViewModel 的时候,Koin 反而帮不上太多忙——因为测试环境里我们通常直接 new 出来,而不是通过 Koin 容器获取。

class UserViewModelTest {
    
    private lateinit var viewModel: UserViewModel
    private val mockRepo = mock<UserRepository>()
    
    @Before
    fun setup() {
        // 直接 new,不经过 Koin
        viewModel = UserViewModel(mockRepo)
    }
    
    @Test
    fun `test load user`() {
        // 测试逻辑
    }
}

但如果你用的是 AndroidViewModel 或者需要 SavedStateHandle,那就得稍微麻烦一点:

class FormViewModelTest {
    
    private lateinit var viewModel: FormViewModel
    private val mockRepo = mock<FormRepository>()
    
    @Before
    fun setup() {
        // 创建 SavedStateHandle 的模拟实例
        val handle = SavedStateHandle(mapOf("user_name" to "test"))
        viewModel = FormViewModel(handle, mockRepo)
    }
}

小技巧:如果 ViewModel 的构造函数参数太多,可以考虑用 @Inject 注解配合 Dagger Hilt 来测试。但既然我们在讲 Koin,那就保持简单——直接 new 是最清晰的。

19.7 本章小结

ViewModel 的注入,说白了就是两件事:

  • viewModel DSL 声明,而不是 singlefactory
  • 在 Activity/Fragment 里用 by viewModel() 获取

记住这两点,大部分场景都不会出问题。至于参数传递、状态保存、多模块组织这些,都是锦上添花的东西。先把基础打牢,再慢慢优化。

我个人觉得,Koin 在 ViewModel 注入这块做得比 Dagger 更直观。没有那么多注解,没有那么多模板代码。你只要记住 ViewModel 的生命周期是跟 Activity 绑定的,别把它当成普通单例来用就行。

Koin ViewModel 注入核心流程 Module 中声明 viewModel DSL single { } 全局单例,不感知生命周期 viewModel { } 由 ViewModelProvider 管理 factory { } 每次注入都创建新实例 by inject() / get() by viewModel() by inject() / get() 最佳实践 构造函数简洁 | 使用 SavedStateHandle | 避免持有 Activity 引用
公众号:蓝海资料掘金营,微信deep3321