1. 为什么要在架构组件里引入协程
Kotlin 协程进入 Android 官方推荐栈已经有好几年了,但真正把协程和 Jetpack 架构组件揉在一起用,很多人还是停留在“能跑就行”的阶段。我见过不少项目,ViewModel 里直接GlobalScope.launch,Repository 里runBlocking包一切,LiveData 的value和postValue混着用,最后线上偶发崩溃查半天。这篇内容就是围绕“协程 + 架构组件”这个组合,把设计思路、关键细节、实操步骤和踩坑经验完整梳理一遍,适合已经会写协程基础语法、但想把项目结构做扎实的 Android 开发者参考。
先说清楚这个组合到底解决什么问题。架构组件(ViewModel、Lifecycle、LiveData、Room、DataStore 等)负责的是状态管理和生命周期感知,协程负责的是异步任务的编排和线程调度。两者单独用都不难,难的是边界:协程该在哪一层启动、什么时候取消、结果怎么回传给 UI、配置变更时怎么不丢数据。把这几个问题处理干净,代码的可维护性会有质的提升。
我个人的判断标准很简单:如果一个异步任务的生命周期应该跟界面绑定,那它就该在viewModelScope里;如果它应该跟数据层绑定、不随界面销毁而中断,那它就该在 Repository 自己的作用域里。这个判断贯穿全文,后面所有细节都是围绕它展开的。
2. 整体架构设计与作用域划分思路
2.1 三层结构里协程该放在哪
典型的 MVVM 分层是 UI 层(Activity/Fragment/Compose)、ViewModel 层、数据层(Repository + DataSource)。协程的启动点选择,本质上是在回答“这个任务归谁管”。
UI 层只做两件事:触发事件、渲染状态。它不应该直接launch协程去请求数据,因为界面随时可能被销毁,而协程如果挂在 UI 层,销毁时机和取消逻辑会变得非常难控。我见过在 Fragment 的onViewCreated里直接启动网络请求的写法,旋转屏幕后旧请求还在跑,回来又发一次,重复请求和内存泄漏一起来。
ViewModel 层是协程的主战场。viewModelScope是官方提供的扩展属性,它绑定的是 ViewModel 的生命周期。ViewModel 在配置变更(比如旋转屏幕)时不会销毁,所以挂在它上面的协程不会因为界面重建而中断,数据也不会丢。这一点是它比 UI 层启动协程最大的优势。
数据层要不要自己启动协程,取决于任务性质。如果这个任务的结果只服务于当前这次界面请求,那交给 ViewModel 的作用域就够了,Repository 只暴露suspend函数。如果任务需要“发起后不管界面死活都要完成”,比如写日志、上传埋点、同步本地数据库,那 Repository 应该持有自己的CoroutineScope,用SupervisorJob加合适的调度器。
2.2 为什么优先用 suspend 函数而不是返回 Deferred
很多人喜欢让 Repository 返回Deferred<T>,然后在 ViewModel 里await()。这种写法能用,但我不推荐。原因是Deferred把“任务已经启动”这件事暴露给了调用方,调用方拿到它的时候协程可能已经在跑了,取消和异常传播的边界变得模糊。
更干净的做法是 Repository 暴露suspend函数,协程的启动权完全交给 ViewModel。这样调用方决定什么时候启动、在哪个作用域启动、异常怎么处理,职责非常清晰。下面是一个对比:
| 方案 | 启动时机 | 取消控制 | 异常传播 | 推荐度 |
|---|---|---|---|---|
| 返回 Deferred | Repository 内部启动 | 调用方难控制 | 需手动 await 才抛出 | 低 |
| 暴露 suspend 函数 | 调用方启动 | 跟随调用方作用域 | 直接抛出,可 try/catch | 高 |
| 返回 Flow | 收集时启动 | 跟随收集作用域 | 通过 catch 操作符 | 高(适合流式数据) |
2.3 调度器的选择逻辑
协程的调度器选择经常被忽略,但它直接影响性能和正确性。我的经验是:
- Main:只在需要直接操作 UI 的时候用,比如更新 StateFlow 的值(其实 StateFlow 本身线程安全,但习惯上在 Main 更新更直观)。
- IO:网络、文件、数据库操作。Room 的 suspend 函数内部已经切到自己的线程池,不需要你再包一层
withContext(Dispatchers.IO),包了反而多一次线程切换。 - Default:CPU 密集型计算,比如解析大 JSON、图片处理、加密解密。
一个常见的错误是“无脑 IO 包一切”。实际上Dispatchers.IO的线程池是有限的(默认 64 个),如果大量任务都堆在 IO 上,反而会造成排队。CPU 密集的任务应该走 Default,让 IO 池专注做阻塞式 IO。
3. ViewModel 与协程配合的核心细节
3.1 viewModelScope 的取消机制
viewModelScope底层是一个CloseableCoroutineScope,它绑定了一个SupervisorJob加Dispatchers.Main.immediate。当 ViewModel 的onCleared()被调用时,这个作用域会被取消,所有子协程收到取消信号。
这里有个关键点:SupervisorJob意味着一个子协程失败不会影响其他兄弟协程。这跟普通的Job不一样。举个例子,你在 ViewModel 里同时启动了两个请求,一个失败一个成功,用SupervisorJob的话成功的那个不受影响;如果用普通Job,一个失败会取消整个作用域,另一个也被干掉。这个差异在并发请求场景下非常关键。
class UserViewModel( private val repo: UserRepository ) : ViewModel() { private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading) val uiState: StateFlow<UserUiState> = _uiState.asStateFlow() fun loadUser(userId: String) { viewModelScope.launch { _uiState.value = UserUiState.Loading try { val user = repo.getUser(userId) _uiState.value = UserUiState.Success(user) } catch (e: CancellationException) { throw e } catch (e: Exception) { _uiState.value = UserUiState.Error(e.message) } } } }注意上面catch里对CancellationException的处理。这是一个非常容易踩的坑:协程取消时会抛出CancellationException,如果你用catch (e: Exception)一把抓,会把取消异常也吞掉,导致协程无法正常取消,甚至引发难以排查的问题。正确做法是先捕获CancellationException并重新抛出,再处理其他异常。
3.2 用 StateFlow 还是 LiveData
架构组件里 LiveData 是元老,StateFlow 是后起之秀。两者都能做状态持有,但在协程场景下我更倾向 StateFlow,原因有几个。
LiveData 的setValue必须在主线程调用,postValue虽然可以在后台线程调用,但它是“合并式”的——连续多次postValue只有最后一次会生效,中间值会丢。这在快速更新的场景下会造成状态丢失。StateFlow 没有这个问题,它的值更新是即时的,而且天然支持协程的挂起收集。
另一个原因是 StateFlow 可以配合stateIn操作符,把冷流转换成热流,配合SharingStarted.WhileSubscribed实现“有订阅者才活跃”的智能生命周期管理。这个能力在数据层做缓存和共享时非常有用。
val uiState: StateFlow<UserUiState> = repo.observeUser() .map { UserUiState.Success(it) } .catch { emit(UserUiState.Error(it.message)) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = UserUiState.Loading )WhileSubscribed(5_000)里的 5000 毫秒是给配置变更留的缓冲。屏幕旋转时订阅者会短暂断开,5 秒内重新订阅不会重启上游流,避免了重复请求。这个参数我一般设 5000,实测下来对大多数场景都合适。
3.3 一次性事件的处理
状态和事件要分开。状态是“界面现在长什么样”,事件是“发生了一件事,处理完就没了”,比如弹 Toast、导航跳转。用 StateFlow 承载一次性事件会出问题:配置变更后重新收集,事件会被重放。
处理一次性事件我常用两种方案。一种是Channel配合receiveAsFlow,Channel 的每个元素只会被消费一次,天然适合事件。另一种是在 StateFlow 里用一个带 ID 的事件对象,消费后标记已处理。前者更简单,后者更适合需要跨进程或持久化的场景。
private val _events = Channel<UserEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() fun onLoginClick() { viewModelScope.launch { val result = repo.login() if (result.isSuccess) { _events.send(UserEvent.NavigateToHome) } else { _events.send(UserEvent.ShowToast("登录失败")) } } }注意:Channel 的
send是挂起函数,如果缓冲区满了会挂起。用Channel.BUFFERED可以缓解,但事件量特别大时要考虑背压策略,别让发送方被卡住。
4. 数据层与协程的深度配合
4.1 Room 的 suspend 与 Flow 支持
Room 从 2.1 开始支持协程,@Dao里的方法可以直接声明为suspend或返回Flow。这是数据层和协程配合最舒服的地方。
suspend方法适合一次性查询,比如按 ID 查一条记录。Room 会自动在它自己的线程池上执行,不需要你手动切调度器。返回Flow的方法适合观察式查询,数据库有变化时 Flow 会自动发射新值,配合 ViewModel 的stateIn就能实现界面自动刷新。
@Dao interface UserDao { @Query("SELECT * FROM users WHERE id = :id") suspend fun getUserById(id: String): User? @Query("SELECT * FROM users ORDER BY name") fun observeAll(): Flow<List<User>> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(user: User) }这里有个细节:Flow查询默认在 Room 的查询执行器上运行,但收集它的协程在哪个调度器上,取决于你在哪里收集。如果直接在viewModelScope(Main)里收集,Room 内部会做线程切换,不会阻塞主线程。但如果你在 Flow 上做了map之类的操作,那些操作会在收集的调度器上执行,所以重计算要记得flowOn(Dispatchers.Default)。
4.2 事务与协程的边界
Room 的@Transaction注解可以加在suspend函数上,保证事务内的操作原子执行。但要注意,事务的边界和协程的取消是有关联的。如果事务执行到一半协程被取消,Room 会回滚事务,这是正确行为。但如果你的业务逻辑里手动开了多个suspend调用,它们不在同一个事务里,中途取消就会留下不一致的数据。
我的做法是:凡是需要原子性的多步写操作,全部收进一个@Transaction的suspend函数里,让 Room 来管事务边界,而不是在 Repository 里手动拼。
4.3 Repository 自己的作用域怎么建
前面提到,有些任务不该随界面销毁而中断。这类任务需要 Repository 持有自己的作用域。建这个作用域有几个要点。
第一,用SupervisorJob()而不是Job(),避免一个任务失败拖垮整个作用域。第二,调度器按任务类型选,写数据库用 IO,计算用 Default。第三,要提供取消入口,比如应用退出时取消整个作用域,否则任务会一直挂着。
class SyncRepository( private val dao: SyncDao, private val api: SyncApi ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun syncInBackground() { scope.launch { val pending = dao.getPendingItems() pending.forEach { item -> try { api.upload(item) dao.markSynced(item.id) } catch (e: CancellationException) { throw e } catch (e: Exception) { dao.markFailed(item.id, e.message) } } } } fun cancelAll() { scope.cancel() } }注意:这个作用域是应用级的,不要在里面持有 Activity 或 View 的引用,否则会造成内存泄漏。所有需要回传给界面的数据,通过 Flow 或回调传出,不要直接引用 UI 对象。
4.4 并发请求的编排
实际业务里经常需要并发请求多个接口,然后合并结果。协程的async配合awaitAll是标准做法。但要注意异常处理:async的异常在await时才抛出,如果某个async失败了但你没await它,异常会被静默吞掉。
suspend fun loadDashboard(): Dashboard = coroutineScope { val userDeferred = async { api.getUser() } val ordersDeferred = async { api.getOrders() } val couponsDeferred = async { api.getCoupons() } Dashboard( user = userDeferred.await(), orders = ordersDeferred.await(), coupons = couponsDeferred.await() ) }用coroutineScope包裹的好处是:任何一个子协程失败,整个作用域会取消,其他兄弟协程也会被取消,不会留下孤儿任务。这比在viewModelScope里直接async更安全,因为viewModelScope是SupervisorJob,子协程失败不会自动取消兄弟。
5. 生命周期感知与协程的协作
5.1 repeatOnLifecycle 的正确用法
在 UI 层收集 Flow,官方推荐用repeatOnLifecycle。它会在生命周期进入指定状态时启动协程,退出时取消,避免界面不可见时还在收集数据浪费资源。
lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> render(state) } } }这里的关键是Lifecycle.State.STARTED。用STARTED意味着界面可见时才收集,onStop时取消。如果用RESUMED,那弹窗覆盖、分屏等场景下收集会中断,可能不符合预期。大多数场景STARTED就够了。
repeatOnLifecycle和flowWithLifecycle的区别在于:前者可以在一个协程里重复启动多个收集,后者是单个 Flow 的包装。多个 Flow 要收集时,用repeatOnLifecycle包一层更省事。
5.2 配置变更时的数据保持
ViewModel 在配置变更时存活,这是它相比 Presenter 的核心优势。但前提是数据存在 ViewModel 里,而不是存在 Activity 的成员变量里。我见过把请求结果存在 Activity 字段里的写法,旋转屏幕后数据全丢,只能重新请求。
配合SavedStateHandle还能做到进程被杀后恢复。SavedStateHandle可以存基本类型和可序列化对象,适合存用户 ID、筛选条件这类轻量状态。大数据不要往里塞,它底层是 Bundle,有大小限制。
class DetailViewModel( private val repo: DetailRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { private val itemId: String = checkNotNull(savedStateHandle["itemId"]) val detail: StateFlow<DetailUiState> = repo.observeDetail(itemId) .map { DetailUiState.Success(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetailUiState.Loading) }5.3 界面销毁时的取消边界
界面销毁时,viewModelScope会取消,但这里有个时序问题需要注意。onCleared()的调用时机是在 ViewModel 真正被销毁时,而不是每次界面销毁时。配置变更时onCleared()不会调用,所以协程不会取消,这是符合预期的。
但如果你的协程里持有了一些需要手动释放的资源,比如文件句柄、网络连接,光靠作用域取消可能不够,需要在onCleared()里做额外清理。我一般会重写onCleared(),把需要显式释放的东西处理掉。
override fun onCleared() { super.onCleared() // 作用域会自动取消,这里处理需要显式释放的资源 fileWatcher.close() }6. 常见问题与排查技巧实录
6.1 协程不取消的几种典型情况
协程不取消是最常见的问题,表现是界面销毁后请求还在跑,或者日志还在打。原因通常有几类。
第一类是用了GlobalScope。GlobalScope的生命周期跟应用进程绑定,不跟任何界面或 ViewModel 绑定,所以永远不会自动取消。除非你确实需要应用级任务,否则不要用它。
第二类是在协程里调用了阻塞式 API。比如Thread.sleep、runBlocking、阻塞式 IO。这些调用不响应协程的取消信号,协程被取消时它们还在跑。正确做法是用delay代替Thread.sleep,用suspend版本的 IO API。
第三类是catch (e: Exception)吞掉了CancellationException。前面提过,取消异常被吞掉后,协程的取消流程会被打断。排查时可以看日志里有没有“Job was cancelled”之类的信息,如果有但协程还在跑,基本就是这个原因。
6.2 异常传播与崩溃排查
协程里的异常如果没被捕获,会传播到作用域的异常处理器。viewModelScope用的是SupervisorJob,未捕获异常会走到默认的CoroutineExceptionHandler,最终导致应用崩溃。
排查这类崩溃时,堆栈里会看到CoroutineExceptionHandler相关的帧。我的做法是在 ViewModel 的基类里统一加一个异常处理器,把未捕获异常记下来,同时上报,避免直接崩溃。
val handler = CoroutineExceptionHandler { _, throwable -> Log.e("Coroutine", "未捕获异常", throwable) // 上报 } viewModelScope.launch(handler) { ... }但更好的做法是在每个launch里显式 try/catch,把异常转换成 UI 状态。异常处理器是兜底,不是主要手段。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 界面销毁后请求还在跑 | 用了 GlobalScope 或阻塞 API | 检查作用域和 API 类型 | 改用 viewModelScope,替换阻塞调用 |
| 旋转屏幕后重复请求 | 在 UI 层启动协程 | 检查 launch 位置 | 移到 ViewModel,用 stateIn 缓存 |
| 状态更新丢失 | 用了 postValue 或普通 Flow | 检查状态容器 | 改用 StateFlow |
| 协程取消后代码还在执行 | 吞掉了 CancellationException | 检查 catch 块 | 先捕获并重抛取消异常 |
| 并发请求一个失败全挂 | 用了普通 Job | 检查作用域 Job 类型 | 改用 SupervisorJob |
| 数据库操作阻塞主线程 | 没用 suspend 或 Flow | 检查 DAO 方法签名 | 改为 suspend 或返回 Flow |
6.4 几个我踩过的坑
第一个坑是stateIn的initialValue设成了null,结果界面第一次渲染时拿到 null 崩溃。后来我改成用一个明确的Loading状态作为初始值,界面永远有东西可渲染。
第二个坑是在map里做了耗时操作,结果主线程卡顿。map默认在收集的调度器上执行,如果在 Main 收集,map里的代码就在 Main 跑。重计算要加flowOn(Dispatchers.Default)。
第三个坑是Channel的缓冲区设得太小,事件发送方被挂起,导致点击响应变慢。后来改成Channel.BUFFERED或者用trySend做非阻塞发送,问题解决。
第四个坑是repeatOnLifecycle里做了collect之外的操作,比如直接launch新协程,结果生命周期退出时新协程没被取消。repeatOnLifecycle只管理它代码块内的协程,块内再launch的要自己管好作用域。
7. 测试与调试的实操建议
7.1 用 TestDispatcher 替换真实调度器
协程代码的单元测试,核心是把调度器替换成可控的TestDispatcher。Dispatchers.setMain可以把 Main 调度器替换掉,让viewModelScope在测试里用测试调度器。
@Before fun setup() { Dispatchers.setMain(StandardTestDispatcher()) } @After fun tearDown() { Dispatchers.resetMain() } @Test fun `加载用户成功时状态为 Success`() = runTest { val repo = FakeUserRepository() val vm = UserViewModel(repo) vm.loadUser("1") advanceUntilIdle() assertEquals(UserUiState.Success(...), vm.uiState.value) }advanceUntilIdle会推进测试调度器,把所有挂起的任务执行完。用StandardTestDispatcher时协程不会立即执行,需要手动推进;用UnconfinedTestDispatcher则会立即执行,适合简单场景。
7.2 测试 Flow 的收集
测试返回 Flow 的 Repository 时,用turbine库会方便很多,它能帮你收集 Flow 并断言发射的值。如果不想引入额外依赖,也可以用toList把有限 Flow 转成列表再断言。
@Test fun `用户流发射正确`() = runTest { val flow = repo.observeUser("1") val values = flow.take(2).toList() assertEquals(2, values.size) }注意:测试无限 Flow 时不要直接
toList,会永远挂起。用take限制数量,或者用 Turbine 的test块。
7.3 调试协程的实用技巧
调试协程时,给协程起名字能省很多事。launch(CoroutineName("loadUser"))之后,日志和调试器里都能看到这个名字,排查问题时一眼就能定位。
另外,开启协程的调试模式(在 JVM 参数里加-Dkotlinx.coroutines.debug)能让堆栈里显示协程的创建位置,对排查“这个协程是谁启动的”特别有用。
8. 一些个人体会
协程和架构组件的组合,说到底是在管理“生命周期”和“并发”这两件事。生命周期决定了任务什么时候该活、什么时候该死,并发决定了多个任务怎么协调。把作用域划分清楚,把异常处理写对,把状态容器选好,大部分问题都能避免。
我现在的习惯是:ViewModel 里只放viewModelScope启动的协程,Repository 暴露suspend或Flow,需要后台长任务时单独建作用域并明确取消入口。状态一律用 StateFlow,事件用 Channel。这套组合用下来,项目里跟协程相关的崩溃和泄漏基本绝迹。
最后分享一个小技巧:如果你不确定某个协程该放哪一层,就问自己“界面销毁后这个任务还该不该继续”。该继续的放数据层作用域,不该继续的放viewModelScope。这个判断标准简单但有效,能帮你避开大多数作用域选择的坑。