21、协程与依赖注入(Hilt/Dagger):协程作用域与组件生命周期、在Hilt模块中提供协程调度器、ViewModel的协程注入、协程与作用域绑定

依赖注入和协程,是Android开发中两个绕不开的核心话题。我见过不少团队,把这两套东西分开用得很溜,但一旦要整合,就各种踩坑。说白了,协程的生命周期管理,天然就和依赖注入的组件生命周期绑定在一起。你想想看,一个ViewModel被销毁时,它里面的协程如果还在跑,轻则内存泄漏,重则应用崩溃。Hilt/Dagger正好能帮我们优雅地解决这个问题。

协程作用域与组件生命周期

先聊一个基础但关键的问题:协程作用域应该和谁绑定?

我个人习惯,在Android中,协程作用域的生命周期应该和以下组件保持一致:

  • Application:全局作用域,应用进程存活期间有效
  • Activity:界面作用域,Activity销毁时自动取消
  • Fragment:片段作用域,Fragment销毁时自动取消
  • ViewModel:视图模型作用域,ViewModel清除时自动取消
  • Scoped:自定义作用域,比如某个业务模块的生命周期

Hilt的组件层级,恰好和这些生命周期一一对应。我画了一张图,帮你理清这个关系:

Hilt组件层级与协程作用域绑定关系 @Singleton / ApplicationComponent 生命周期:应用进程存活期间 → 绑定 ApplicationScope (全局协程作用域) @ActivityScoped / ActivityComponent 生命周期:Activity创建到销毁 → 绑定 ActivityScope (界面协程作用域) @FragmentScoped / FragmentComponent 生命周期:Fragment创建到销毁 → 绑定 FragmentScope (片段协程作用域) @HiltViewModel / ViewModelComponent 生命周期:ViewModel存活期间 → 绑定 viewModelScope (自动取消)

看到这张图,你应该能理解:Hilt的组件层级天然就是协程作用域的「宿主」。我在项目中遇到过一个问题:有人把网络请求的协程作用域绑定到了GlobalScope,结果Activity退出了,请求还在跑,回调时更新UI直接崩溃。嗯,这就是典型的生命周期错配。

核心原则:协程作用域的生命周期,绝对不能长于其依赖的Hilt组件的生命周期。否则,你就是在制造内存泄漏。

在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的模块,只在DebugTest构建变体中生效。这样生产代码不受影响。

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就完全不用担心这个问题。

注意:不要在ViewModel中直接使用GlobalScopelifecycleScope。前者会导致协程无法取消,后者会让协程的生命周期和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(),所有和该用户会话相关的协程都会被取消,不需要逐个去管理。我曾经在一个电商项目中用过这个模式,用户切换账号时,所有正在进行的网络请求、数据库操作、数据同步任务,全部自动取消,体验非常好。

总结一下:协程和依赖注入的结合,本质上是「生命周期管理」的优雅解耦。Hilt提供组件层级,协程提供异步能力,两者通过作用域绑定,让代码既安全又简洁。

嗯,这里要注意一点:自定义作用域一定要记得在合适的时机取消。如果你创建了一个CoroutineScope但从不取消,那它就和GlobalScope没什么区别了。我建议在模块的destroy()clear()方法中显式调用cancel(),或者使用SupervisorJob来隔离异常。

好了,这一章的内容就到这里。协程和依赖注入的整合,说白了就是让两套生命周期管理体系「对齐」。你只要记住:作用域跟着组件走,调度器统一提供,ViewModel用viewModelScope,基本就不会出大问题。


公众号:蓝海资料掘金营,微信deep3321