29、协程与协程的协程(实战):协程在大型项目中的架构设计、模块化与解耦、代码组织与规范、团队协作
说实话,协程用在小项目里,怎么折腾都行。但一旦项目上了规模——几十个模块、上百号人、代码量几十万行——协程的用法就得讲究了。我见过不少团队,协程用得飞起,结果半年后代码变成一团乱麻,谁都不敢动。
这一章,我就聊聊在大型项目中,协程到底该怎么落地。不是讲API怎么用,而是讲架构、讲规范、讲协作。
一、协程在大型项目中的架构设计
先问一个问题:你的协程Scope放在哪?
很多人的第一反应是「放在Activity里」。嗯,小项目可以。但大型项目里,Activity只是视图层的一个组件。真正的业务逻辑,应该在更底层的地方驱动。
我个人习惯的做法是:按分层定义Scope。
| 层级 | Scope类型 | 生命周期 | 典型用途 |
|---|---|---|---|
| UI层 | viewModelScope | 与ViewModel共存 | 界面状态收集、用户交互 |
| Domain层 | 自定义Scope(如UseCaseScope) | 与用例共存 | 业务编排、数据转换 |
| Data层 | CoroutineScope(Dispatchers.IO) | 与数据源共存 | 网络请求、数据库操作 |
| 跨层共享 | ApplicationScope | 应用进程级别 | 定时任务、全局监听 |
你想想看,如果所有协程都挂在Activity上,一旦Activity重建,所有任务都得重来。这在大型项目里是灾难。
核心原则:Scope的生命周期,必须小于等于它所在组件的生命周期。永远不要让一个Scope活得比它的宿主还长。
我在项目中遇到过一个问题:一个网络请求在ViewModel里启动,结果用户快速切屏,请求还没回来,ViewModel已经被销毁了。但因为Scope没有正确取消,请求回调里还在更新UI,直接崩了。后来我们统一用viewModelScope,配合lifecycleScope做兜底,才彻底解决。
二、协程的模块化与解耦
大型项目最怕什么?模块之间互相依赖,牵一发而动全身。协程本身是轻量的,但如果用不好,反而会加剧耦合。
我推荐的做法是:用接口隔离协程的暴露。
举个例子,一个数据仓库模块对外暴露方法时,不要直接返回Deferred或Job,而是返回Flow或者挂起函数。
// ❌ 不推荐:暴露具体协程实现
interface UserRepository {
fun getUserAsync(): Deferred<User>
}
// ✅ 推荐:暴露挂起函数或Flow
interface UserRepository {
suspend fun getUser(): User
fun observeUser(): Flow<User>
}
为什么?因为Deferred和Job是协程的具体实现。一旦你暴露了它们,调用方就必须知道协程的细节。这破坏了封装。
说白了,模块之间应该只约定「做什么」,而不是「怎么做」。挂起函数和Flow天然就是面向接口的,调用方不需要关心背后是协程还是回调。
小技巧:在模块的公共API中,只使用suspend、Flow、Channel这些协程原语。把Scope、Job、Dispatcher这些实现细节藏在模块内部。
另外,模块之间的协程上下文传递也要注意。我见过一个坑:A模块用Dispatchers.IO启动协程,然后直接调用了B模块的挂起函数。B模块内部又切到了Dispatchers.Main。结果线程来回切换,性能损耗不说,还容易出死锁。
我的建议是:每个模块只负责自己的调度策略,不要假设调用方的上下文。用withContext明确切换,而不是依赖隐式传递。
三、协程的代码组织与规范
代码规范这东西,说起来枯燥,但没它真不行。尤其是协程,写法太灵活了,每个人都有自己的风格。没有规范,代码就是一团浆糊。
我团队里定了几条硬性规则,分享给你参考:
- 所有协程启动必须指定Scope。禁止使用GlobalScope,禁止在非协程环境里直接调用launch。
- 挂起函数必须用suspend关键字标记。不要用runBlocking包装同步方法,那是在自欺欺人。
- Flow的收集必须在合适的生命周期内。UI层用repeatOnLifecycle,不要自己手动cancel。
- 异常处理必须到位。每个协程块里至少有一个try-catch或者CoroutineExceptionHandler。
- 命名规范要统一。挂起函数用动词开头,Flow用observe/listen前缀,Channel用send/receive。
// 规范示例
class UserViewModel(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
try {
val user = userRepository.getUser(userId)
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "未知错误")
}
}
}
}
注意:不要在ViewModel里直接暴露MutableStateFlow。用asStateFlow()转成只读的StateFlow,这是基本素养。
我曾经在一个项目里看到有人把协程的Job存在HashMap里手动管理,说是为了「灵活控制」。结果代码里到处都是job.cancel()和job.join(),维护起来痛不欲生。后来全部改成结构化并发,用scope管理生命周期,代码量直接砍了一半。
四、协程的团队协作
技术问题好解决,人的问题才麻烦。协程在团队里推广,最大的阻力不是学不会,而是「各写各的」。
我建议做三件事:
- 制定协程编码规范文档。白纸黑字写清楚,什么场景用什么API,异常怎么处理,测试怎么写。新人来了直接看文档,不用猜。
- Code Review时重点看协程。Scope对不对?异常处理有没有?生命周期绑定了吗?这些是Review的必查项。
- 建立协程公共组件库。把常用的协程工具类封装好,比如网络请求的Retry机制、Flow的防抖、Channel的背压处理。大家直接用,不用重复造轮子。
举个例子,我们团队封装了一个通用的网络请求协程工具:
// 公共组件库中的网络请求封装
suspend fun <T> safeApiCall(
call: suspend () -> T,
retryCount: Int = 3,
onRetry: (Int, Throwable) -> Unit = { _, _ -> }
): Result<T> {
var lastException: Throwable? = null
repeat(retryCount) { attempt ->
try {
return Result.success(call())
} catch (e: Exception) {
lastException = e
onRetry(attempt + 1, e)
if (attempt < retryCount - 1) {
delay(1000L * (attempt + 1)) // 指数退避
}
}
}
return Result.failure(lastException ?: Exception("未知错误"))
}
有了这样的公共组件,团队里每个人写网络请求时,直接调用safeApiCall就行。不用每个人都去写一遍重试逻辑,既省事又统一。
嗯,最后说一句。协程本身不难,难的是在团队里形成共识。技术选型只是开始,真正的挑战在于——大家能不能用同一套思维去写代码。
总结一句话:协程在大型项目中的成功,三分靠技术,七分靠规范。