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明确切换,而不是依赖隐式传递。

三、协程的代码组织与规范

代码规范这东西,说起来枯燥,但没它真不行。尤其是协程,写法太灵活了,每个人都有自己的风格。没有规范,代码就是一团浆糊。

我团队里定了几条硬性规则,分享给你参考:

  1. 所有协程启动必须指定Scope。禁止使用GlobalScope,禁止在非协程环境里直接调用launch。
  2. 挂起函数必须用suspend关键字标记。不要用runBlocking包装同步方法,那是在自欺欺人。
  3. Flow的收集必须在合适的生命周期内。UI层用repeatOnLifecycle,不要自己手动cancel。
  4. 异常处理必须到位。每个协程块里至少有一个try-catch或者CoroutineExceptionHandler。
  5. 命名规范要统一。挂起函数用动词开头,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就行。不用每个人都去写一遍重试逻辑,既省事又统一。

嗯,最后说一句。协程本身不难,难的是在团队里形成共识。技术选型只是开始,真正的挑战在于——大家能不能用同一套思维去写代码。

总结一句话:协程在大型项目中的成功,三分靠技术,七分靠规范。

协程在大型项目中的架构设计 UI层 viewModelScope / lifecycleScope · 状态收集 · 用户交互 Domain层 UseCaseScope · 业务编排 · 数据转换 Data层 CoroutineScope(IO) · 网络请求 · 数据库操作 跨层共享 ApplicationScope · 定时任务 · 全局监听 核心原则:Scope生命周期 ≤ 宿主生命周期 · 接口隔离 · 结构化并发 · 统一规范
公众号:蓝海资料掘金营,微信deep3321