21、协程与依赖注入(Hilt/Dagger):协程作用域与组件生命周期、在Hilt模块中提供协程调度器、ViewModel的协程注入、协程与作用域绑定
依赖注入和协程,是Android开发中两个绕不开的核心话题。我见过不少团队,把这两套东西分开用得很溜,但一旦要整合,就各种踩坑。说白了,协程的生命周期管理,天然就和依赖注入的组件生命周期绑定在一起。你想想看,一个ViewModel被销毁时,它里面的协程如果还在跑,轻则内存泄漏,重则应用崩溃。Hilt/Dagger正好能帮我们优雅地解决这个问题。
协程作用域与组件生命周期
先聊一个基础但关键的问题:协程作用域应该和谁绑定?
我个人习惯,在Android中,协程作用域的生命周期应该和以下组件保持一致:
- Application:全局作用域,应用进程存活期间有效
- Activity:界面作用域,Activity销毁时自动取消
- Fragment:片段作用域,Fragment销毁时自动取消
- ViewModel:视图模型作用域,ViewModel清除时自动取消
- Scoped:自定义作用域,比如某个业务模块的生命周期
Hilt的组件层级,恰好和这些生命周期一一对应。我画了一张图,帮你理清这个关系:
看到这张图,你应该能理解:Hilt的组件层级天然就是协程作用域的「宿主」。我在项目中遇到过一个问题:有人把网络请求的协程作用域绑定到了GlobalScope,结果Activity退出了,请求还在跑,回调时更新UI直接崩溃。嗯,这就是典型的生命周期错配。
在Hilt模块中提供协程调度器
调度器是协程的「执行引擎」。我建议你把调度器也通过Hilt来提供,这样整个应用的调度策略就统一了。来看一个典型的Hilt模块:
@Module
@InstallIn(SingletonComponent::class)
object CoroutineDispatchersModule {
@Provides
@Singleton
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@MainDispatcher
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
@Provides
@Singleton
@DefaultDispatcher
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}
这里我用了自定义的注解来区分不同的调度器。为什么要这么做?因为直接注入Dispatchers.IO的话,测试时很难替换。我曾经在写单元测试时,因为调度器没法mock,导致测试用例跑得特别慢。后来改成注解注入,测试时直接替换成TestCoroutineDispatcher,问题就解决了。
自定义注解也很简单:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MainDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
这样,你在任何需要调度器的地方,直接注入即可:
class UserRepository @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val apiService: ApiService
) {
suspend fun fetchUsers(): List<User> = withContext(ioDispatcher) {
apiService.getUsers()
}
}
TestDispatcher的模块,只在Debug或Test构建变体中生效。这样生产代码不受影响。
ViewModel的协程注入
ViewModel是协程在Android中最常见的「宿主」。Hilt对ViewModel的协程注入支持得非常好。你想想看,以前我们要在ViewModel里启动协程,得手动创建CoroutineScope,还要在onCleared()里取消。现在有了@HiltViewModel,一切都变得简单了。
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher
) : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
init {
loadUsers()
}
private fun loadUsers() {
viewModelScope.launch {
// 这里自动使用 viewModelScope,ViewModel销毁时自动取消
val result = withContext(ioDispatcher) {
userRepository.fetchUsers()
}
_users.value = result
}
}
}
这里有个细节:viewModelScope是AndroidX提供的扩展属性,它内部绑定的是ViewModel的生命周期。当ViewModel被清除时,viewModelScope会自动取消所有子协程。我在项目中遇到过有人自己创建了一个CoroutineScope,结果忘记在onCleared()里取消,导致协程泄漏。用viewModelScope就完全不用担心这个问题。
GlobalScope或lifecycleScope。前者会导致协程无法取消,后者会让协程的生命周期和Activity绑定,而不是ViewModel。一旦屏幕旋转,Activity重建,协程也会被取消,但ViewModel可能还活着。
协程与作用域绑定
最后,我们来聊聊更高级的用法:自定义作用域绑定。有时候,一个业务模块的生命周期既不是Application级别,也不是Activity级别,而是介于两者之间。比如,一个登录模块,用户登录后创建,登出后销毁。
这种情况下,我们可以自定义一个Hilt组件:
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class UserSessionScope
@Module
@InstallIn(SingletonComponent::class)
object UserSessionModule {
@Provides
@UserSessionScope
fun provideUserSessionScope(): CoroutineScope {
// 创建一个和用户会话绑定的协程作用域
return CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
}
然后在业务模块中注入这个作用域:
class UserSessionManager @Inject constructor(
@UserSessionScope private val sessionScope: CoroutineScope,
private val userRepository: UserRepository
) {
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
fun login(username: String, password: String) {
sessionScope.launch {
val result = userRepository.login(username, password)
_isLoggedIn.value = result.isSuccess
}
}
fun logout() {
// 取消整个作用域,清理所有协程
sessionScope.cancel()
}
}
这样做的好处是,当用户登出时,我们只需要调用sessionScope.cancel(),所有和该用户会话相关的协程都会被取消,不需要逐个去管理。我曾经在一个电商项目中用过这个模式,用户切换账号时,所有正在进行的网络请求、数据库操作、数据同步任务,全部自动取消,体验非常好。
嗯,这里要注意一点:自定义作用域一定要记得在合适的时机取消。如果你创建了一个CoroutineScope但从不取消,那它就和GlobalScope没什么区别了。我建议在模块的destroy()或clear()方法中显式调用cancel(),或者使用SupervisorJob来隔离异常。
好了,这一章的内容就到这里。协程和依赖注入的整合,说白了就是让两套生命周期管理体系「对齐」。你只要记住:作用域跟着组件走,调度器统一提供,ViewModel用viewModelScope,基本就不会出大问题。