恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
AAC架构与Kotlin协程:ViewModel、Lifecycle与Flow实战指南
首页
资讯中心
/
AAC架构与Kotlin协程:ViewModel、Lifecycle与Flow实战指南
AAC架构与Kotlin协程:ViewModel、Lifecycle与Flow实战指南
发布时间:2026/10/17 17:15:29
1. AAC架构为什么缺不了协程先交代一个背景。我最初接触AAC即 Android Architecture Components官方架构组件时项目里大量用到了ViewModel、LiveData、Room这套东西但代码写起来依然是“回调里有回调”网络请求结果拿到之后转一手LiveData再转一手ViewModel稍微复杂一点儿的交互流程回调嵌套能到四五层。当时AsyncTask已经被放弃RxJava又显得过于重型整个团队在“线程切换”这件事上花费了大量心思却仍然经常踩到生命周期泄漏、任务空跑的坑。后来引入 Kotlin 协程情况就完全不一样了。协程解决了 A 架构里最麻烦的两个问题一是把异步代码从回调地狱里救出来让业务逻辑能以近乎同步的写法编排二是提供了一个天然的“作用域”概念可以跟ViewModel的onCleared()、Lifecycle的状态变化深度绑定任务随界面销毁而自动取消不必再手动维护一堆CompositeDisposable或者Disposable。换句话说协程补上了 AAC 这套架构在异步任务管理上缺失的最后一块拼图。这篇内容我整理自一个实际项目改造的完整记录涉及的项目代号就用“模拟项目X”代替。整个改造的思路、踩坑、代码细节都在下面展开。适合正在使用 AAC 组件、想用协程重构项目异步层的开发朋友。如果你只用过GlobalScope或者协程只是停留在写 Demo 阶段这篇文章也能帮你建立一套完整的安全使用心智。我当时在项目里落地这套方案时最先做的一件事就是把协程的依赖加到build.gradleimplementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2) implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.6.2)注意lifecycle-*系列版本最好和项目中androidx主版本对齐我用的是 2.6.2对应compileSdk 34没有任何问题。如果你项目还在用老的 1.x 版本建议升级因为 2.5 之后协程与 Lifecycle 的集成才真正成熟。2. ViewModel 与协程的绑定viewModelScope 的作用域设计2.1 viewModelScope 的生命周期原理viewModelScope是lifecycle-viewmodel-ktx包中为ViewModel扩展出来的一个属性。它内部返回一个CloseableCoroutineScope作用域与ViewModel绑定在一起。具体逻辑是当ViewModel的onCleared()被调用时协程作用域自动取消。这就意味着我在ViewModel里发起的网络请求、数据库读写、文件操作等任务一旦对应的页面销毁全部都会随之停止不会再出现“页面关了请求还在跑”的情况。从源码层面看viewModelScope的SupervisorJobDispatchers.Main.immediate组合非常值得琢磨。先说SupervisorJob它的特点是某一个子协程因为异常失败不会导致整个Scope里其他子协程被连带取消。这一点在页面加载场景尤其重要——列表请求挂了并不希望用户头像的加载、某个本地缓存读取的协程也一起崩溃。基于这个设计我在项目里普遍采用viewModelScope.launch起多个并行任务互不干扰。另一个关键点是默认调度器。很多人误以为viewModelScope.launch里的代码是跑在 IO 线程的其实不是它默认跑在Dispatchers.Main。所以我在ViewModel里写代码的时候凡是耗时操作比如网络请求、Room查询都必须用withContext(Dispatchers.IO)显式切换到 IO 线程拿到结果之后再回到主线程更新 UI 状态。这套机制的好处是只要遵循协程的调度规则就不会出现主线程卡顿也避免了回调式写法里忘记切线程的隐患。提示不要把耗时操作直接裸写在viewModelScope.launch {}里否则阻塞的是主线程界面照样卡。正确姿势是外层launch在主线程内层用withContext(Dispatchers.IO)切走耗时代码。2.2 标准网络请求写法与状态管理在改造模拟项目X的登录模块时我把之前基于回调的登录流程全部重写了一遍。先看最终代码class LoginViewModel( private val repository: AuthRepository ) : ViewModel() { private val _uiState MutableStateFlowLoginUiState(LoginUiState.Idle) val uiState: StateFlowLoginUiState _uiState.asStateFlow() fun login(username: String, password: String) { viewModelScope.launch { _uiState.value LoginUiState.Loading runCatching { repository.login(username, password) }.onSuccess { result - _uiState.value LoginUiState.Success(result) }.onFailure { e - _uiState.value LoginUiState.Error(e.message ?: 登录失败) } } } }这段代码里有几个细节值得展开。第一使用StateFlow而不是MutableLiveData因为协程生态里Flow与StateFlow的整合更顺畅配合Flow操作符做数据流变换非常自然。第二runCatching包裹网络请求避免了显式try-catch嵌套带来的缩进灾难如果repository.login内部抛出的异常被runCatching捕获通过onFailure分发到 UI 状态。第三LoginUiState是一个sealed class或接口包含Idle、Loading、Success、Error四种状态把所有 UI 可能性用类型表达清楚不会有“丢一个布尔值isLoading 一个字符串errorMsg到处同步”的坑。关于runCatching有一点要说清楚它捕获的是Throwable类型如果业务本身需要区分“网络异常”和“业务异常”建议在onFailure里做异常类型的when分支比如.onFailure { e - val msg when (e) { is IOException - 网络连接异常请检查网络 is HttpException - 服务端返回错误${e.code()} else - e.message ?: 未知错误 } _uiState.value LoginUiState.Error(msg) }另外Dispatchers.IO的切换问题在repository.login内部处理更妥当Repository层的挂起函数内部自行决定线程调度ViewModel层只关心业务编排。我见过不少朋友在ViewModel里到处写withContext(Dispatchers.IO)其实Retrofit的suspend支持已经是异步的了Room的挂起查询也自动切到了 IO 线程池外层再包一层withContext属于重复劳动还容易造成线程切换开销。2.3 ViewModel 中并行请求与结果合并实际业务里更常见的场景是“一个页面需要同时请求多个接口”。以前的做法是等待 A 接口回调再请求 B 接口或者用RxJava的zip合并。引入协程后最简单的是用asyncawaitviewModelScope.launch { val userInfo async { repository.getUserInfo() } val bannerList async { repository.getBannerList() } val config async { repository.getAppConfig() } _uiState.value MainUiState.Ready( user userInfo.await(), banner bannerList.await(), config config.await() ) }这里要说明async的行为特征三个块在同一个SupervisorJob下并行启动但它们在哪个线程执行取决于内部实现。如果repository.getUserInfo()内部没有切线程那async就依然跑在主线程。所以推荐做法是async(Dispatchers.IO)val userInfo async(Dispatchers.IO) { repository.getUserInfo() }再提一个性能细节如果同时启动的请求过多且全部落在Dispatchers.IO有可能触发 IO 线程池的快速并发策略也就是当任务数量超过corePoolSize时线程池会创建更多线程执行。协程默认对这种情况有调度优化——Dispatchers.IO的tasks数量上限为 64超出部分会排队。因此我在实际开发中不会盲目地一次性async几十个请求而是根据业务需要控制在合理数量内。对于确实存在大量并行任务的场景更合适的做法是分批处理或者使用Flow的并发扁平化操作符比如flatMapMerge。3. LifecycleScope 与生命周期感知启动的正确姿势3.1 lifecycleScope 的基本使用与限制lifecycleScope是LifecycleOwner的扩展属性它的取消时机和Lifecycle的onDestroy绑定。对于 Activity 来说onDestroy触发时lifecycleScope取消对于 Fragment 来说则需要区分fragment.lifecycleScope绑定的是 Fragment 自身的生命周期而viewLifecycleOwner.lifecycleScope绑定的是 Fragment 视图的生命周期。这里有一个非常常见的坑在 Fragment 的onViewCreated里启动一个协程但却用了fragment.lifecycleScope最后视图销毁onDestroyView的时候协程不会自动取消只有整个 Fragment 销毁时才取消。对于只涉及视图操作的任务来说这会导致内存泄漏。正确写法应该这样override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { // 这里启动的协程在视图销毁时自动取消 observeData() } }实际上官方文档也明确建议在 Fragment 中操作视图相关协程时一律使用viewLifecycleOwner。我团队在 Code Review 的时候甚至有了一条硬性规则Fragment内部禁止出现fragment.lifecycleScope除非确实需要跟随 Fragment 整体生命周期的任务。3.2 repeatOnLifecycle自动停止与恢复的姿势lifecycleScope.launch只是解决了“取消”的问题但如果你想在界面进入STARTED状态时开始收集Flow退到后台时暂停收集回到前台再恢复直接用launch是无法做到的。这时候就需要repeatOnLifecycle。典型的场景是监听一个来自数据层的FlowviewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - render(state) } } }repeatOnLifecycle的原理是当生命周期处于指定状态之上时执行 block 中的代码当生命周期降到状态之下时取消当前的协程真正取消的是内部新创建的协程作用域而不是外部这个launch。回到前台后block 会被重新执行。这种写法的好处是整个收集只发生在前台可见期间避免后台时 UI 被无意义地刷新手。比如模拟项目X中有一个实时行情页面行情数据每秒更新一次但是在repeatOnLifecycle的保护下用户退到后台时收集自动暂停既不浪费带宽也不会做无意义的 UI 更新。我在写过一段代码之后回过头来看发现repeatOnLifecycle最大的心智负担在于“为什么不能直接把collect写在launch里”。原因很简单collect是一个挂起函数它是“无限”的如果不对它做生命周期限制lifecycleScope.launch { flow.collect {} }会一直收集到这个协程被取消而普通的launch没有一次启动/停止的循环机制。repeatOnLifecycle从字面上理解就是“在生命周期满足条件的这段时间里反复执行”当条件不满足时取消并清理可用资源条件重新满足时又重新执行。理解了这个机制就能明白为什么它是官方推荐的收集方式。3.3 LiveData 的协程配合observe 之后真的安全吗有一些老项目还在坚持用LiveData作为 UI 数据载体这并没有问题。LiveData的好处是能感知生命周期、支持粘性事件、对新手友好。协程和LiveData也不是对立的二者可以共存。在LiveData中启动协程规范做法是使用liveData {}协程构建器val userLiveData: LiveDataUser liveData { val user withContext(Dispatchers.IO) { repository.getUserById(id) } emit(user) }liveData构建器有一个很有意思的特性它会自动把Dispatchers.Main.immediate作为默认调度器并且会在Lifecycle处于DESTROYED状态时自动取消内部块。它本质上是一个处理器安全的MediatorLiveData在订阅者活跃时执行代码块在订阅者全部消失时取消后启动的协程。也就是说如果 UI 不可见且没有观察者代码块中的代码就不会执行。在我改造模拟项目X的时候一开始把liveData {}用在了所有从数据库或网络加载数据的地方后来发现一个问题liveData {}只适合“一次计算、一次 emit”的场景。如果你需要基于一个上游Flow做持续变换每次emit的结果还要继续收集那么liveData {}写起来就相当别扭比较好在外面用viewModelScope把Flow转成LiveData再暴露。社区里常见做法是借助Flow.asLiveData()扩展函数val uiState repository.observeUser(id) .map { it.toUiModel() } .asLiveData()asLiveData()底层其实也会利用liveData的协程机制进行收集但我这里想强调的是Flow - LiveData的转换本身就存在线程和背压的适配问题协程内部会处理好你不需要关心。如果你已经在全项目统一用StateFlow就不再需要这个转换了。4. Flow 与 LiveData 的选择同一套架构里的两种数据通道4.1 Flow 的操作符优势在协程和 AAC 结合之后Flow几乎成了处理数据流的标准选择。Flow提供了极其丰富的操作符比如map、filter、flatMapLatest、debounce、distinctUntilChanged配合StateFlow做 UI 状态管理非常自然。举一个实际场景来说明。模拟项目X的搜索页面用户每次输入关键字都需要请求服务端。如果不用Flow就要自己做防抖、取消旧请求、处理竞态条件。用Flow的话viewModel.searchQuery .debounce(300) .filter { it.length 2 } .distinctUntilChanged() .flatMapLatest { query - flow { emit(repository.search(query)) }.flowOn(Dispatchers.IO) } .collect { result - _searchState.value SearchUiState.Success(result) }这里debounce负责防抖distinctUntilChanged保证相同关键字只发一次请求flatMapLatest会在新的关键字到来时取消上一次还没有完成的搜索。这一段如果用回调写大概要写三四十行且容易出 bug用Flow操作符则是声明式的一目了然。Flow操作符的完整能力来自kotlinx-coroutines-core包在实际项目中我通常还会引入kotlinx-coroutines-play-services来适配 Firebase 任务转Flow但这是后话本文不展开。4.2 StateFlow 与 LiveData 的对比很多开发者初期容易产生一个困惑既然LiveData已经有observe 生命周期感知能力为什么还要用StateFlow。表格对比非常直观特性LiveDataStateFlow生命周期感知自带观察者感知生命周期本身不感知需要配合repeatOnLifecycle或asLiveData()初始值默认无需要postValue设置构造时必须传入初始值粘性事件默认粘性新观察者会收到上次的值会保留最新值新收集者立即收到当前值线程切换postValue可以在任意线程调用需要手动Dispatchers控制操作符支持不支持需要借助Transformations完整的Flow操作符体系背压处理没有背压处理本身是热流发射速率过高时会挂起发射者与协程的配合需要通过liveData {}构建器原生支持flowOn、stateIn、whileSubscribed在实际项目里我现在的选择基准非常简单纯 UI 层的简单数据、只在页面级使用的单向数据可以继续用LiveData凡是涉及数据加工、合并、复杂变换或者需要防抖、节流、切线程的场景统一用FlowStateFlow。统一的最终结果就是项目里LiveData越来越少只要合入协程体系直接全链路Flow会让数据管道更统一。4.3 stateIn 与 whileSubscribed 的正确理解stateIn是把普通Flow转换成StateFlow的操作符它需要三个参数scope、started策略、初始值。刚开始用的时候我踩了一个坑把started参数填成SharingStarted.Eagerly结果导致Flow上游一启动就立即执行无论有没有订阅者都开启数据流页面不可见时依然在消耗资源。推荐在 UI 层使用的启动策略是SharingStarted.WhileSubscribed(5000)val uiState: StateFlowUiState repository.observeData() .map { it.toUiState() } .stateIn( scope viewModelScope, started SharingStarted.WhileSubscribed(5000), initialValue UiState.Loading )WhileSubscribed(5000)的含义是当有订阅者开始收集时上游Flow开始运行当所有订阅者取消收集后5 秒之后上游才停止。这个 5 秒的延迟非常有用比如用户从详情页返回列表页再立刻回来上游的请求不需要重新发起直接复用之前缓存的值。但在 ViewModel 内部使用stateIn时由于viewModelScope本身就跟随 ViewModel 生命周期因此 5 秒延迟有时反而让底下数据流迟迟不释放。对于这类场景我更推荐直接在ViewModel里使用MutableStateFlow 手动viewModelScope.launch管理数据流因为你更清楚上游的启动时机。这个决策点经常成为架构评审时的争议点我的习惯是如果Flow的源头是冷流例如每次收集都会重新执行的网络请求那用stateIn并在 UI 层配合whileSubscribed比较合理如果源头是热流比如数据库实时更新的Flow就直接暴露冷流让 UI 层用repeatOnLifecycle控制收集节奏不用额外转换。5. 协程与 AAC 组件实战模拟项目X的完整改造记录5.1 架构分层与依赖方向模拟项目X原本是一个典型的 MVC 结构Activity 里塞了网络请求、数据处理、UI 更新还有各种内部类。改造后整体分为四层View层Activity / Fragment / Adapter只负责渲染 UI 和转发用户事件。ViewModel层持有StateFlow调用Repository通过viewModelScope管理协程任务。Repository层面向ViewModel暴露挂起函数或Flow内部组合数据源。Data层Retrofit服务、Room数据库、SharedPreferences等。依赖方向是单向的View - ViewModel - Repository - Data各层之间通过接口解耦。协程在这里的价值不是改变分层而是让每一层的异步边界更清晰。依赖注入这块我选了手动工厂方式配合ViewModelProvider.Factory没有引入重量级框架。每个ViewModel的构造参数传入Repository接口测试时传入 Mock 实现。在实际业务代码中我推荐至少用Hilt管理依赖因为当项目膨胀后手动工厂的类数量会爆炸。5.2 从 Repository 到 ViewModel 的数据流改造中最典型的模块是列表页。列表页的Repository对外暴露一个Flowinterface ArticleRepository { fun observeArticles(category: String): FlowArticlePage suspend fun refreshArticles(category: String): ResultUnit }observeArticles的实现内部混合了Room数据库的Flow和Retrofit的网络请求通过flatMapLatest和catch处理class ArticleRepositoryImpl( private val api: ArticleApi, private val articleDao: ArticleDao ) : ArticleRepository { override fun observeArticles(category: String): FlowArticlePage { return articleDao.observeByCategory(category) .flatMapLatest { cached - flow { emit(cached) val remote api.fetchArticles(category) articleDao.insertAll(remote) emit(articleDao.observeByCategory(category).first()) }.catch { e - // 缓存兜底网络失败不影响展示 emit(cached) emit(ArticlePage.Error(e)) } } } }这段代码的逻辑是先读数据库缓存有数据就直接发射然后请求网络更新缓存最后再把最新数据库结果发射一遍。如果网络失败不会抛出异常中断整个流而是发射当前缓存和错误标记。这种“缓存优先、网络更新、错误兜底”的模式在模拟项目X的新闻资讯模块里实测很稳。需要特别注意的是flatMapLatest的使用。如果用户切换了分类上游observeByCategory发射了新数据flatMapLatest会取消之前分类的数据库监听重新执行新的块。这样避免了对数据库监听的叠加——如果不使用flatMapLatest而用flatMap数据库的Flow上游会累积多个监听既浪费资源还可能导致数据错乱。5.3 ViewModel 层如何消费 Repository 的 FlowViewModel层拿到Repository的Flow后通常会长期收集转换为StateFlow暴露给 UI。class ArticleListViewModel( private val repository: ArticleRepository ) : ViewModel() { private val _category MutableStateFlow(all) val category: StateFlowString _category.asStateFlow() private val _articles MutableStateFlowArticlePage.Success?(null) val articles: StateFlowArticlePage.Success? _articles.asStateFlow() private val _loading MutableStateFlow(false) val loading: StateFlowBoolean _loading.asStateFlow() fun selectCategory(category: String) { _category.value category } fun refresh() { viewModelScope.launch { _loading.value true repository.refreshArticles(_category.value) .onSuccess { _loading.value false } .onFailure { _loading.value false } } } init { viewModelScope.launch { repository.observeArticles(_category.value) .collect { page - _articles.value page as? ArticlePage.Success } } } }这段代码看起来简单有一个隐藏问题init里启动的协程不可能自动感知_category的变化。如果用户切换了分类observeArticles的Flow依然是旧的 category 参数。修正办法是让收集代码观察_category的状态init { viewModelScope.launch { _category.flatMapLatest { category - repository.observeArticles(category) }.collect { page - _articles.value page as? ArticlePage.Success } } }_category是StateFlow天然具备收集能力。当分类变化时flatMapLatest自动取消旧分类的数据流并启动新分类的数据流。这个写法是我在模拟项目X改造中最大的心得之一把“状态变化重新触发数据流”这个逻辑交给Flow组合而不是在selectCategory里手动重启协程。5.4 完整代码演示一个带刷新与分页的示例为了更直观展示整套架构怎么串起来我把模拟项目X中的一个简化版会员中心模块完整贴出来并附上关键点注释。首先是数据层用的Retrofit接口interface MemberApi { GET(v1/member/info) suspend fun getMemberInfo(): MemberInfoResponse GET(v1/member/orders) suspend fun getMemberOrders( Query(page) page: Int, Query(size) size: Int 20 ): MemberOrderPage }注意suspend函数配合Retrofit时不需要额外引入线程调度Retrofit内部会通过挂起接口的执行器在后台线程发起请求返回时切回主线程完全不需要enqueue回调。然后是Repositoryinterface MemberRepository { suspend fun getMemberInfo(): MemberInfo fun observeOrders(page: Int): FlowMemberOrderUiState } class MemberRepositoryImpl( private val api: MemberApi, private val orderStore: OrderLocalStore ) : MemberRepository { override suspend fun getMemberInfo(): MemberInfo { val response api.getMemberInfo() return response.toDomain() } override fun observeOrders(page: Int): FlowMemberOrderUiState { return flow { val cached orderStore.getOrders(page) emit(MemberOrderUiState.Cache(cached)) val remote api.getMemberOrders(page).orders orderStore.saveOrders(page, remote) emit(MemberOrderUiState.Success(remote)) }.flowOn(Dispatchers.IO) } }flowOn(Dispatchers.IO)保证了 flow 块内所有耗时操作都在 IO 线程执行发射出来的值传递到ViewModel时已经是主线程因为flowOn只会切换上游执行线程下游收集操作符默认运行在收集者的上下文而ViewModel收集时在viewModelScope.launch的Main上下文里。接着是ViewModelclass MemberViewModel( private val repository: MemberRepository ) : ViewModel() { private val _infoState MutableStateFlowMemberInfo?(null) val infoState: StateFlowMemberInfo? _infoState.asStateFlow() private val _orderState MutableStateFlowMemberOrderUiState(MemberOrderUiState.Loading) val orderState: StateFlowMemberOrderUiState _orderState.asStateFlow() fun loadInfo() { viewModelScope.launch { runCatching { repository.getMemberInfo() } .onSuccess { _infoState.value it } .onFailure { /* 可以写作 _infoState.value null */ } } } fun loadOrders(page: Int) { viewModelScope.launch { repository.observeOrders(page).collect { state - _orderState.value state } } } }最后是 Fragment 中的订阅class MemberFragment : Fragment() { private val viewModel: MemberViewModel by viewModels { MemberViewModelFactory() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.infoState.collect { info - if (info ! null) binding.tvMemberName.text info.name } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.orderState.collect { state - when (state) { is MemberOrderUiState.Loading - showLoading() is MemberOrderUiState.Cache - showOrders(state.orders) is MemberOrderUiState.Success - showOrders(state.orders) is MemberOrderUiState.Error - showError(state.message) } } } } viewModel.loadInfo() viewModel.loadOrders(1) } }这套代码在模拟项目X中落地之后最明显的感受是ViewModel不再持有任何 UI 相关的引用Fragment中也没有任何直接调用api的代码所有异步边界通过协程和Flow划定得清清楚楚。我后来协同开发的其他同事接手这个模块时第一句话是“这模块代码风格怎么跟别的不一样”第二句话是“原来这样写还挺好懂的”。6. 协程与 AAC 组合实战常见问题排查与调优实录6.1 不处理异常与崩溃问题协程中异常处理是新手最容易踩的坑之一。在viewModelScope.launch的协程块里如果出现了未捕获的异常协程会自动取消并把异常交给该协程的CoroutineExceptionHandler。如果没有设置该 Handler且你不在代码内做try-catch异常就会往顶层传播应用直接崩溃。在模拟项目X里我遇到过的一个实际情况一个用户反馈页面在弱网下经常闪退日志定位到是ResolvedException: IOException追根溯源就是某个 Repository 的挂起函数直接抛异常ViewModel 没有处理全局也没有设置未捕获异常拦截器。修复自然是补上catch或runCatching但更深层的思考是——协程提供了结构化并发的强大能力同时也要求你对异常有全面的掌控。很多人习惯了回调里有个onFailure来兜底换成协程后以为挂起函数内部会“自己处理”其实不会。一个安全的设计是在ViewModel中统一封装一个安全启动器fun ViewModel.safeLaunch( onError: (Throwable) - Unit {}, block: suspend CoroutineScope.() - Unit ): Job { return viewModelScope.launch { try { block() } catch (cancelException: CancellationException) { throw cancelException // 取消不能被吞掉 } catch (e: Exception) { onError(e) } } }这个封装的核心点是CancellationException要重新抛出否则会破坏协程的结构化取消机制。如果在catch块里把CancellationException也吞掉了协程的作用域会因为“取消操作未完成”而出现诡异行为最典型的表现是页面销毁后协程还在继续执行网络请求虽然已被取消但回调无法正常返回。提示不要对CancellationException做runCatching式的吞掉处理。如果你察觉到协程被取消比如网络库返回了取消回调要遵循“取消即退出”的规则不要继续执行耗时代码。6.2 Memory Leak内存泄漏与作用域泄露排查排查协程泄漏有一个通用思路如果页面销毁后它的ViewModel或者lifecycleScope不应该再存在的任务还在往后执行说明作用域用错了。最常见的原因有三个使用了GlobalScope或者自定义了非生命周期绑定的CoroutineScope。在 Fragment 中用错了lifecycle对象用了fragment.lifecycleScope而没注意viewLifecycleOwner。在Repository或者更下层借用了上层传递的CoroutineScope导致生命周期延后。模拟项目X中曾经有个“banner 轮播”模块Banner 数据需要每隔 5 秒轮询一次。最初实现是在Application层创建了一个全局Scope每次启动都不取消导致退出 App 后BannerService还在后台轮询。改成viewModelScope之后Banner 轮询跟随页面的销毁而终止问题解决。如果你需要排查内存泄漏可以用LeakCanary它会直接提示泄漏对象是谁。更重要的是在 Code Review 时做约束所有协程作用域必须由最靠近生命周期的对象创建Repository层不得自己创建Scope只能暴露挂起函数或者Flow。这条规则几乎能杜绝绝大多数协程泄漏。6.3 在 Fragment 里 collect 数据却收不到值的问题有段时间团队里好几个同事反馈“在 Fragment 里 collectViewModel的StateFlow页面第一次打开能收到返回再进入收不到了”。排查后定位到原因在onViewCreated中直接写成lifecycleScope.launch { viewModel.uiState.collect { render(it) } }collect的协程虽然跟随lifecycle销毁了但这个协程也“忘记”了重新订阅。第二次进入 Fragment 时新的lifecycleScope.launch重新启动StateFlow会立刻发射当前值理论上是可以收到的。但如果上面这段代码写在init或者onCreate里协程可能已经提前销毁或还未绑定正确的生命周期因此迟迟收不到。本质原因是生命周期感知的收集行为没有被正确表达。正确做法是用repeatOnLifecycle并不需要特别复杂的修复逻辑只是要理解收集时机和生命周期状态的绑定关系。关于这一点前面已经详细解释过不再赘述。6.4 主线程调度与性能调优协程很强大但不代表它自动帮你优化线程使用。如果你在viewModelScope.launch里直接写了大量的 JSON 解析、列表排序即便这些操作只有几十毫秒堆积多了也会造成掉帧。我在模拟项目X的性能优化阶段用Systrace抓过一次主线程耗时结果发现“协程化改造后主线程反而更忙了”——原因是很多开发者在ViewModel里用launch包住了整个业务逻辑把本应放在 IO 线程的耗时任务留在了主线程。调优方法很简单遵循几个原则任何超过 16ms 的操作都要切到Dispatchers.IO或Default。Default适合 CPU 密集任务JSON 解析、集合排序。IO适合阻塞型任务网络、磁盘读写、数据库。不要在ViewModel里做大量对象转换如果Repository返回的 DTO 需要映射成 UI 模型建议放在flowOn(Dispatchers.Default)的上游做。写一个稍微复杂的例子说明切线程的权衡viewModelScope.launch { val result withContext(Dispatchers.Default) { // 大量数据转换、去重、排序 transformData(rawList) } _uiState.value result }withContext虽然会挂起当前协程但不会切换当前协程所在的线程上下文。也就是说如果外部已经跑在Dispatchers.MainwithContext(Dispatchers.Default)会把内部代码调度到 Default 线程池执行执行完回到 Main不会阻塞主线程。这正是协程“把异步代码写同步逻辑同时又保持主线程顺畅”的功力所在。6.5 依赖注入与协程的配合细节如果项目里使用了Hilt或者手动ViewModelProvider.FactoryViewModel的构造参数注入是常规操作。模拟项目X中ViewModel的构造依赖了Repository而Repository构造又依赖了ApiService和Dao。为了在测试时可以替换Repository为 Fake 实现我定义了一个简单的接口。用Hilt时注意一点不要直接注入CoroutineScope因为那样会导致作用域不明确。正确的做法是让ViewModel使用自己的viewModelScope或者注入一个带有ApplicationScope的自定义CoroutineScope专门用于 App 级的长时任务如日志上报而不是在页面级代码中使用。6.6 协程依赖升级带来的兼容性问题我在项目后期从协程 1.6.x 升级到 1.7.x最需要关注的是Dispatchers.IO的并发调整行为limitedParallelism相关优化以及生命周期库 2.6 引入的repeatOnLifecycle的边界行为。升级后建议跑一遍全量回归重点检查stateIn的启动策略是否有变化。flatMapLatest在快速切换时的取消延迟。Room和协程组合时数据库Flow的线程调度是否正常。6.7 常见问题速查表问题现象可能原因解决方案页面销毁后日志仍有网络请求使用了GlobalScope或自定义长作用域改为viewModelScope/lifecycleScope协程中异常导致崩溃未捕获异常正确使用runCatching或CoroutineExceptionHandler页面返回后数据不刷新repeatOnLifecycle使用不正确改用生命周期感知收集主线程卡顿、列表滚动掉帧耗时操作留在主线程使用withContext(Dispatchers.IO/Default)Flow收集不到新值上游是冷流且没有重新订阅用stateIn或保持Lifecycle处于活跃CancellationException被吞掉catch块处理不当重新抛出CancellationException多个收集者导致重复网络请求LiveData观察者生命周期混乱使用StateFlowshareIn或统一ViewModel收集快速点击按钮触发多次请求没有做防抖或请求取消防抖使用debounce或检查协程 Job 是否 active7. 我在模拟项目X中的三条核心心得项目改造到这里总结一下我个人在实际操作中沉淀下来的经验。第一协程与 AAC 的结合重点不在于写多少个launch而在于是不是把“生命周期”和“异步作用域”真正绑定清楚了。viewModelScope用对了崩溃和泄漏会少一半repeatOnLifecycle用对了后台空跑的问题也会消失。如果项目中有些协程任务在页面销毁后还在执行优先怀疑作用域绑定而不是业务逻辑。第二数据层尽量暴露Flow而不是直接暴露挂起函数。挂起函数适合一次性请求比如提交表单、获取详情但只要数据会变化、需要多次推送比如本地数据库变化、远端推送状态冷流或热流的Flow能让上层消费方式非常一致。ViewModel收集一次Flow通过StateFlow对外暴露UI 层无需关心数据来源和刷新机制。第三短期来看学习协程确实有门槛尤其是Flow的操作符体系。但掌握之后它对代码结构的改善是立竿见影的。我在模拟项目X里写完ViewModel数据流之后发现几乎没有回调几乎没有全局状态单元测试也变得极其好写——因为逻辑是纯挂起函数可以同步调用。这是我从这一轮改造中获得的最大收益。最后再分享一个小技巧用flow做缓存更新时尽可能在flow块里处理完所有数据转换在下游只做 UI 渲染。这样既保证了线程安全也减少了 UI 层对数据类型转换的依赖数据流会更干净。如果你正打算在项目里落地这套方案建议先选一个小模块改造把viewModelScopeStateFlowrepeatOnLifecycle这套组合练熟再逐步推广到全项目。