☰
AAC架构与Kotlin协程:ViewModel、Lifecycle与Flow实战指南
2026/10/17 17:15:22 网站建设 项目流程

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.gradle:

implementation("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的SupervisorJob+Dispatchers.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 = MutableStateFlow<LoginUiState>(LoginUiState.Idle) val uiState: StateFlow<LoginUiState> = _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合并。引入协程后,最简单的是用async+await:

viewModelScope.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。

典型的场景是监听一个来自数据层的Flow:

viewLifecycleOwner.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: LiveData<User> = 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;凡是涉及数据加工、合并、复杂变换,或者需要防抖、节流、切线程的场景,统一用Flow+StateFlow。统一的最终结果就是项目里LiveData越来越少,只要合入协程体系,直接全链路Flow会让数据管道更统一。

4.3 stateIn 与 whileSubscribed 的正确理解

stateIn是把普通Flow转换成StateFlow的操作符,它需要三个参数:scope、started策略、初始值。刚开始用的时候我踩了一个坑:把started参数填成SharingStarted.Eagerly,结果导致Flow上游一启动就立即执行,无论有没有订阅者都开启数据流,页面不可见时依然在消耗资源。

推荐在 UI 层使用的启动策略是SharingStarted.WhileSubscribed(5000):

val uiState: StateFlow<UiState> = 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对外暴露一个Flow:

interface ArticleRepository { fun observeArticles(category: String): Flow<ArticlePage> suspend fun refreshArticles(category: String): Result<Unit> }

observeArticles的实现内部混合了Room数据库的Flow和Retrofit的网络请求,通过flatMapLatest和catch处理:

class ArticleRepositoryImpl( private val api: ArticleApi, private val articleDao: ArticleDao ) : ArticleRepository { override fun observeArticles(category: String): Flow<ArticlePage> { 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 的 Flow

ViewModel层拿到Repository的Flow后,通常会长期收集,转换为StateFlow暴露给 UI。

class ArticleListViewModel( private val repository: ArticleRepository ) : ViewModel() { private val _category = MutableStateFlow("all") val category: StateFlow<String> = _category.asStateFlow() private val _articles = MutableStateFlow<ArticlePage.Success?>(null) val articles: StateFlow<ArticlePage.Success?> = _articles.asStateFlow() private val _loading = MutableStateFlow(false) val loading: StateFlow<Boolean> = _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回调。

然后是Repository:

interface MemberRepository { suspend fun getMemberInfo(): MemberInfo fun observeOrders(page: Int): Flow<MemberOrderUiState> } 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): Flow<MemberOrderUiState> { 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上下文里)。

接着是ViewModel:

class MemberViewModel( private val repository: MemberRepository ) : ViewModel() { private val _infoState = MutableStateFlow<MemberInfo?>(null) val infoState: StateFlow<MemberInfo?> = _infoState.asStateFlow() private val _orderState = MutableStateFlow<MemberOrderUiState>(MemberOrderUiState.Loading) val orderState: StateFlow<MemberOrderUiState> = _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.Main,withContext(Dispatchers.Default)会把内部代码调度到 Default 线程池执行,执行完回到 Main,不会阻塞主线程。这正是协程“把异步代码写同步逻辑,同时又保持主线程顺畅”的功力所在。

6.5 依赖注入与协程的配合细节

如果项目里使用了Hilt或者手动ViewModelProvider.Factory,ViewModel的构造参数注入是常规操作。模拟项目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观察者生命周期混乱使用StateFlow+shareIn或统一ViewModel收集
快速点击按钮触发多次请求没有做防抖或请求取消防抖使用debounce或检查协程 Job 是否 active

7. 我在模拟项目X中的三条核心心得

项目改造到这里,总结一下我个人在实际操作中沉淀下来的经验。

第一,协程与 AAC 的结合重点不在于写多少个launch,而在于是不是把“生命周期”和“异步作用域”真正绑定清楚了。viewModelScope用对了,崩溃和泄漏会少一半;repeatOnLifecycle用对了,后台空跑的问题也会消失。如果项目中有些协程任务在页面销毁后还在执行,优先怀疑作用域绑定,而不是业务逻辑。

第二,数据层尽量暴露Flow而不是直接暴露挂起函数。挂起函数适合一次性请求(比如提交表单、获取详情),但只要数据会变化、需要多次推送(比如本地数据库变化、远端推送状态),冷流或热流的Flow能让上层消费方式非常一致。ViewModel收集一次Flow,通过StateFlow对外暴露,UI 层无需关心数据来源和刷新机制。

第三,短期来看,学习协程确实有门槛,尤其是Flow的操作符体系。但掌握之后,它对代码结构的改善是立竿见影的。我在模拟项目X里写完ViewModel数据流之后,发现几乎没有回调,几乎没有全局状态,单元测试也变得极其好写——因为逻辑是纯挂起函数,可以同步调用。这是我从这一轮改造中获得的最大收益。

最后再分享一个小技巧:用flow做缓存更新时,尽可能在flow块里处理完所有数据转换,在下游只做 UI 渲染。这样既保证了线程安全,也减少了 UI 层对数据类型转换的依赖,数据流会更干净。如果你正打算在项目里落地这套方案,建议先选一个小模块改造,把viewModelScope+StateFlow+repeatOnLifecycle这套组合练熟,再逐步推广到全项目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询