ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android协程完全指南:从挂起机制到生命周期绑定,告别回调地狱

Android协程完全指南:从挂起机制到生命周期绑定,告别回调地狱

先说个我自己的感受。做了几年Android开发,处理异步任务的手段从最早的Thread + Handler,到后来的AsyncTask、HandlerThread,再到引入RxJava全家桶,每一种方案都解决了一部分问题,但总会留下新的麻烦。直到我把Coroutines协程真正用进项目里,才有一种“这玩意儿早该出现”的感觉。尤其当你需要在主线程和子线程之间来回切换、又不想把代码写成一坨嵌套回调的时候,协程带来的改变几乎是降维打击。

这篇文章我不会去抄官方文档,而是从实际项目的角度,聊聊协程在Android里到底怎么落地、怎么替代传统多线程写法,以及那些文档里不会告诉你的坑。无论你是刚接触协程的新手,还是已经在用但偶尔被各种Scope绕晕的老手,这篇文章应该都能给你一些启发。

1. 传统多线程方案的痛点:为什么我们需要协程

先别急着看协程的API,我们得先搞清楚它到底解决了什么问题。如果你只是用Thread写个后台任务,其实也不是不能用,但一旦项目复杂度上来,传统方案的毛病就会暴露得非常彻底。

1.1 回调地狱只是表象,真正的痛点是状态管理

想象一个最常见的业务场景:用户登录成功后,需要拉取用户信息,然后根据用户信息去刷新界面。用传统方式写,大概是这样:

login(object : Callback<LoginResponse> { override fun onSuccess(response: LoginResponse) { fetchUserInfo(token = response.token, object : Callback<UserInfo> { override fun onSuccess(userInfo: UserInfo) { refreshUI(userInfo) } override fun onFailure(t: Throwable) { handleError(t) } }) } override fun onFailure(t: Throwable) { handleError(t) } })

这段代码能跑,但只在理想情况下能跑。真实项目里,登录可能要拼参数、要校验缓存、要判断是否过期,每加一层判断,嵌套就多一层。你不仅要追踪数据流向,还要追踪异常在哪一层被吃掉。更难受的是,你根本没办法直观地看到“先做A,再做B,然后做C”这个顺序逻辑,因为代码结构完全被回调压扁了。

协程解决这个问题的思路很直接:把异步代码写成同步代码的样子。同样的场景用协程写,逻辑顺序肉眼可见,中间哪怕有100个步骤,也只是顺序往下排,不会产生金字塔形的缩进。这不是语法糖,而是一种编程范式的改变——你从“回调驱动”变成“顺序表达”。

1.2 线程切换的成本与生命周期泄漏

传统多线程还有个容易忽视的问题:线程的创建和销毁是有成本的,频繁创建线程会导致内存压力和CPU消耗。很多早期项目喜欢“来一个任务就new Thread()”,任务一多,线程数暴涨,然后再用线程池去限制,但又引入了线程池参数调优、队列策略选择这些新问题。

更隐蔽的问题是生命周期泄漏。后台线程运行时,Activity可能已经销毁了,线程可不管这些,它照样执行完,然后回调里的runOnUiThread去操作一个已经销毁的界面。轻则空指针,重则内存泄漏。于是你不得不在onDestroy里手动去中断线程、去维护一个任务列表、去判断每个回调的安全性。

协程从设计上就想解决这两件事:第一,挂起函数不会阻塞线程,但也不需要你手动创建线程——协程底层有一套精心调度的逻辑;第二,协程的Scope可以绑定生命周期,Activity销毁时一键取消所有正在执行的任务。这两点加上顺序化表达,就是我愿意把项目逐步迁移到协程的根本原因。

2. 协程的三块基石:挂起、作用域与调度器

我见过不少同事一上来就用GlobalScope.launch写代码,跑起来居然也正常,但这种方式隐患很大,后面我会专门讲。要理解协程,建议先吃透三个核心概念,它们就像协程的骨架,其他API都是围绕这三个概念转的。

2.1 挂起函数:不阻塞线程的“暂停”

协程里最容易被误解的就是suspend关键字。很多人以为挂起就是“线程停下来等”,但其实恰好相反——挂起是“让出当前线程”。这里我用一个生活化的类比来解释。

想象你在排队办理业务,窗口有3个柜员(3个线程)。你拿号之后坐在椅子上等(挂起),这时候你并没有占着柜员不放,柜员可以去处理下一个人的业务。等叫到你的号(任务完成),你再回到某个柜员面前继续办理。传统的方式是:你一个人占着一个柜员,直到你的业务办完,其他人都得等着——前者就是协程的挂起,后者就是阻塞。

在代码层面,suspend函数在执行到耗时操作时会“暂停”并释放当前线程,当耗时操作完成后,协程会被自动调度回合适的线程继续执行。这个过程中,你的业务代码看起来就像一行行顺序执行,完全不需要手动管理线程。

suspend fun fetchData(): Data { // 这里不阻塞线程,但会挂起协程 return withContext(Dispatchers.IO) { api.fetchData() } }

关键点是:挂起函数只能在协程里调用(或者被其他挂起函数调用),普通函数里不能直接调用suspend函数。这是编译器层面的约束,也是协程结构化的基础。

2.2 作用域(Scope):决定协程的生死归属

CoroutineScope可以理解为协程的“容器”。你在一个Scope里启动的协程,会跟随这个Scope的取消而统一取消。Android开发中常见的几个Scope:

  • GlobalScope:全局作用域,生命周期跟随整个App,几乎不该用
  • ViewModelScope:跟随ViewModel,ViewModel销毁时自动取消
  • LifecycleScope:跟随Activity/Fragment,销毁时自动取消
  • MainScope:基于主线程的Scope,可以手动控制取消时机

如果你不指定Scope,协程就没有归属,很容易变成“孤儿协程”——在后台默默执行,你既不知道它什么时候结束,也没办法在界面销毁时把它停掉。这就像你雇了一个员工,但不告诉他归谁管,最后他可能一直在干活,你却无法解雇他。

我自己在项目里通常只在两个地方创建Scope:ViewModel里用viewModelScope,在需要手动控制的场景用rememberCoroutineScope()或自己构造的CoroutineScope。在Activity里直接用lifecycleScope。凡是见到GlobalScope.launch,我基本都会打回去让同事改。

2.3 调度器(Dispatcher):协程跑在哪个线程

协程摆脱了“手动管线程”的束缚,但线程的选择依然有意义。Dispatchers就是用来指定协程运行线程池的:

调度器用途类比说明
Dispatchers.Main界面刷新、用户交互前台柜台,只接待直接服务客户的操作
Dispatchers.IO网络请求、数据库、文件读写后台仓库,处理需要排队等待的外部资源
Dispatchers.DefaultCPU密集型计算计算车间,适合做复杂运算
Dispatchers.Unconfined不指定线程,跟随调用者不建议日常使用,行为难以预期

打个比方,Main是餐厅的前厅,IO是厨房,Default是中央厨房的粗加工区。你的服务员(主线程)不能跑到厨房里炒菜,但可以通过withContext(Dispatchers.IO)把“炒菜”这个任务交给厨房,等菜好了再端回前厅。这个过程里,服务员(主线程)没有一直站在厨房等菜,而是转头去招呼其他客人了。

3. launch与async:两种最常见的协程类型

知道了三块基石,接下来是实际操作。启动一个协程最常见的两种方式就是launch和async,它们之间的区别,很多人学了很久还搞混。我建议从一个场景去理解:你要不要这个协程的返回值。

3.1 launch:启动了就不管,适合“做完就完”的任务

launch的语义是“启动一个协程,执行完就算完成任务”,它的返回类型是Job,你通过持有Job来取消任务。适合日志上报、数据预加载、缓存清理这类不关心返回值、只关心执行过程的任务。

lifecycleScope.launch { // 在IO线程执行文件清理 withContext(Dispatchers.IO) { cleanCacheFiles() } // 回到主线程更新界面 cacheCleanedLiveData.postValue(true) }

这段代码的核心逻辑是:耗时的清理操作交给withContext(Dispatchers.IO),代码块执行完自动回到原来的线程(这里是主线程),所以后面的postValue操作不需要手动切线程。这也是协程最直观的优势之一:不需要runOnUiThread,不需要Handler.post,切线程就是一句话的事。

3.2 async:要拿结果的等待机制

async的语义是“启动一个协程,并预期返回一个结果”,返回值是Deferred<T>,你可以理解为“未来的结果凭证”。当你调用await()时,协程会挂起,直到拿到结果。

lifecycleScope.launch { val userInfoDeferred = async(Dispatchers.IO) { api.getUserInfo() } // 这里会挂起等待结果,但主线程不会卡住 val userInfo = userInfoDeferred.await() renderUserInfo(userInfo) }

async + await还有个妙用:并行执行多个独立任务,然后统一收集结果。比如同时请求用户信息和好友列表,两个都不依赖对方,那就没必要串行执行。

lifecycleScope.launch { val userDeferred = async(Dispatchers.IO) { api.getUserInfo() } val friendDeferred = async(Dispatchers.IO) { api.getFriends() } val user = userDeferred.await() val friends = friendDeferred.await() // 两个网络请求并发执行,总耗时接近较慢的那个 }

这种写法对传统方式的改进非常明显。在RxJava里用zip组合多个请求,新手经常搞不清楚zip的发射逻辑和类型合并方式;在协程里就是两个async加两个await,思路简单清晰。

3.3 并发数与性能:协程真的不会卡死吗

理论上,协程可以在单线程上运行成千上万个,因为挂起操作不占线程。但实际项目里,没人会无限制地开协程,因为底层线程池的资源还是有限的。用launch批量处理列表任务时,如果要控制并发数,可以用Semaphore或者Flow的flatMapMerge(concurrency = n)。这里面有个容易被忽略的细节:async在大量并发创建时,如果每个都执行同样的IO操作,可能会出现资源竞争,所以压测一下你的场景很必要。

我自己遇到过一个问题:同时开50个协程做图片批量上传,结果因为每个都要经过同一个网络连接池,反而比之前的线程池方案更慢。后面加了并发限制才好转。不是说协程能开很多就可以真的开很多,资源和业务约束照样存在。

4. withContext与Dispatchers:线程切换的正确姿势

withContext是协程里最常用、也最容易被误用的函数之一。它做的事情是:在指定调度器的线程上执行一个代码块,执行完后恢复原来的调度器线程。你可以像“换工具”一样切换执行环境,而不用改变整个协程的上下文。

我刚用协程时犯过一个经典错误:在IO线程执行了耗时操作,又手动调用runOnUiThread去更新UI。其实完全没必要,withContext回到Main是自动的。只要你的协程是在主线程下启动的,那么withContext(Dispatchers.IO)执行完,后面的代码就在主线程执行。

4.1 网络请求的关键写法:不要包裹整个网络库

很多人会把整个网络请求封装在一个suspend函数里,函数内部用withContext(Dispatchers.IO)包住OkHttp的同步请求调用。这种方式没问题,但有个小建议:如果你的网络库本身支持异步回调(比如Retrofit返回Call),最好用suspendCancellableCoroutine把它改造成协程友好的函数,而不是粗暴地用withContext包住整个同步请求。

原因是:同步请求会阻塞线程,虽然协程挂起后线程不阻塞,但如果你是在viewModelScope中调用一个不挂起的同步网络库,线程池资源依然会被占用。正确做法是把网络库的回调和协程做桥接,让真正的耗时操作在挂起中等待,而不是占着线程。

suspend fun Call<NoticeResponse>.awaitResult(): NoticeResponse { return suspendCancellableCoroutine { continuation -> enqueue(object : Callback<NoticeResponse> { override fun onResponse(call: Call<NoticeResponse>, response: Response<NoticeResponse>) { if (response.isSuccessful) { continuation.resume(response.body()!!) } else { continuation.resumeWithException(HttpException(response)) } } override fun onFailure(call: Call<NoticeResponse>, t: Throwable) { continuation.resumeWithException(t) } }) // 处理协程取消时取消网络请求 continuation.invokeOnCancellation { call.cancel() } } }

这段代码把Retrofit的回调封装成了挂起函数,并且当协程被取消时会自动取消网络请求,是一个很实用的基础设施。你可以在项目里定义一套类似的扩展函数,所有网络请求都走这套桥接。

4.2 CPU密集任务用Default,IO任务用IO

Dispatchers.Default和Dispatchers.IO的底层线程池是不同的。Default适合做计算密集型任务,比如图片压缩、复杂的数据处理、JSON解析大数据量;IO适合做阻塞型操作,比如读写文件、数据库查询、网络请求。把CPU密集任务扔到IO线程池,不会出逻辑错误,但性能上不划算,因为IO线程池的调度策略本身不是为高CPU占用设计的。

下面是一个简单但典型的综合示例,展示了三个调度器的配合:

lifecycleScope.launch { val bitmap = withContext(Dispatchers.IO) { // IO线程把图片从文件读出来 loadBitmapFromFile(path) } val compressResult = withContext(Dispatchers.Default) { // Default线程做压缩计算 compressBitmap(bitmap, targetWidth, targetHeight) } // 回到主线程展示结果 imageView.setImageBitmap(compressResult) }

这样分工比一股脑全扔到IO线程池要合理,也能体现你对协程线程模型的理解层次。

5. 异常处理与结构化并发:最容易踩的深水区

协程用起来顺手,但异常处理机制和很多人的直觉相反。刚开始接触协程时,我一度以为launch里的异常捕获方式和try-catch一样,然后把try-catch写在launch外层,结果根本捕获不到。这里面的门道是结构化并发的一部分,必须单独拎出来讲。

5.1 异常的传播路径:为什么launch外层捕获不到

launch启动的协程,异常会直接向上传播给父级Job,而不是抛给启动它的调用方。所以你写这样的代码,异常不会被捕获:

lifecycleScope.launch { // 这里面的异常不会抛到外层 throw IllegalStateException("boom") }

正确的捕获方式是在协程内部的代码块里用try-catch,或者在协程里使用runCatching:

lifecycleScope.launch { try { throw IllegalStateException("boom") } catch (e: Exception) { Log.e("TAG", "捕获到异常", e) } }

或者更简洁的方式是用CoroutineExceptionHandler,但这个处理器只在未捕获异常传播到顶层时才生效。如果你用GlobalScope.launch且没有其他异常传播机制,它才会兜底。在viewModelScope或lifecycleScope中,Handler的作用有限,因为异常可能已经被Scope的上下文消费掉了。

5.2 SupervisorJob:隔离异常的神器

默认情况下,父协程会被子协程的异常影响——一个子协程抛异常,整个父子链会取消。这在某些场景下是好事(比如从一个页面跳转后,一个请求失败就应该清理全部任务),但在有些场景就是灾难。比如你在一个launch里并行发了5个请求,只想让失败的请求自己报错,不想影响另外4个,这时候就需要SupervisorJob。

用一句话总结区别:Job是“有福同享,有难同当”,SupervisorJob是“各自安好,互不牵连”。

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) // 这样启动的两个协程,一个出错不会取消另一个 scope.launch { throw IllegalStateException("task1 failed") } scope.launch { delay(100) println("task2 still running") }

SupervisorJob在Android中最重要的应用场景就是viewModelScope—— 官方实现里用的就是SupervisorJob()加Main.immediate。这意味着一个请求失败不会导致整个ViewModel作用域的所有协程全部取消。这个细节,是面试里常考、也最容易答错的点。

5.3 取消机制:协作式取消

协程的取消不是强制中断,而是协作式的。这意味着,协程只在挂起点(比如delay、withContext、await)检查取消状态并抛出CancellationException。如果你的代码里有一段死循环且没有挂起点,那么即使调用了cancel(),协程也不会停。

val job = lifecycleScope.launch { while (true) { // 这里没有挂起操作,取消无法生效 Thread.sleep(500) } } job.cancel() // 这段代码无法真正停止协程

如果要让循环也能响应取消,需要显式检查:

val job = lifecycleScope.launch { while (isActive) { // 协程活跃状态下才继续执行 doSomeWork() } }

isActive是CoroutineScope的扩展属性,用来判断当前协程是否处于活跃状态。同理,Thread.sleep这种阻塞操作不会响应协程取消,应该用delay()替代。

还有一个容易忽略的点:CancellationException不应该被吞掉。有些同学写了catch (e: Exception),然后把所有异常记了个日志,里面包括CancellationException,结果协程取消后没有正常退出,产生了一堆莫名其妙的后续问题。正确做法是,捕获异常时先判断类型,如果是CancellationException,应该重新抛出或者直接返回。

try { delay(1000) } catch (e: CancellationException) { // 协程被取消了,必须重新抛出 throw e } catch (e: Exception) { handleOtherException(e) }

6. 生命周期绑定:协程与界面同生共死的实践

协程最强的落地价值,是它和生命周期体系的无缝配合。传统线程方案里,我们总是担心Activity销毁后线程还在跑,键盘侠般的排查“为什么这里空指针了”,最后定位到是一个迟到的回调在搞事。协程从框架层把这个隐患干掉了。

6.1 ViewModelScope:网络请求的首选归宿

ViewModel是Android架构组件里的核心成员,它的生命周期比Activity/Fragment更稳定——旋转屏幕、配置变化时依然存活。网络请求放在viewModelScope里,意味着你不会因为屏幕旋转就丢掉一个还没完成的请求。

class MainViewModel : ViewModel() { private val _userInfo = MutableLiveData<UserInfo?>() val userInfo: LiveData<UserInfo?> = _userInfo fun loadUserInfo() { viewModelScope.launch { val result = withContext(Dispatchers.IO) { repository.fetchUserInfo() } _userInfo.value = result } } }

当Activity被销毁,ViewModel也会在onCleared()时让所有协程自动取消。你不需要写任何手动取消代码,不需要维护一个Job列表,不需要在onDestroy里去遍历取消。这一点省下的心力,用过的都知道。

6.2 LifecycleScope与repeatOnLifecycle

在非ViewModel的场景(比如直接在Activity/Fragment里发起一个短任务),可以用lifecycleScope。它和lifecycle绑定,在生命周期达到DESTROYED时会自动取消协程。

还有一个比较新的API叫repeatOnLifecycle,它解决的是“在特定生命周期状态下重复执行”的问题。比如你只想在STARTED状态时持续收集一个Flow:

lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.someFlow.collect { value -> updateUI(value) } } }

这段代码在界面进入STARTED时开始收集,进入STOPPED时自动停止收集,再次回到STARTED时重新开始。省去了很多手动处理前台后台切换的样板代码,也是目前Google推荐的Flow收集姿势。

6.3 不要用GlobalScope:为什么它看起来好用但不该碰

GlobalScope的定位是“全局生命周期”。用它启动的协程,既不会跟随Activity销毁,也不会跟随ViewModel销毁,而是存活到整个进程结束。听起来好像很方便——不用考虑生命周期,想跑就启动。但代价是,你做的一个数据预加载可能比Activity“命还长”,最后变成泄漏;更麻烦的是,你无法控制这些全局协程的取消,它们会成为项目里的“野任务”。

我在代码评审里看到异常的协程写法,十有八九是GlobalScope.launch。除非你是在写一个真正进程级的组件、明确知道这个任务必须跨界面存活,否则我建议保持克制,永远用有边界的作用域。

7. 协程与Flow的联动:从状态管理到响应式编程

协程解决了“异步顺序化”的问题,Flow则进一步解决了“数据流”的响应式问题。Flow可以理解成一个“冷流”——只有被收集时才开始发射数据,和Reactive Streams的规范类似。它与协程天然兼容,可以在协程里用挂起的方式处理数据流。

7.1 用Flow替代LiveData的状态分发

在MVVM架构里,LiveData是常用的状态容器。但当你需要做数据流的变换、过滤、重试时,LiveData就有些力不从心。Flow本身有丰富的操作符,转成LiveData也很简单:

class MainViewModel : ViewModel() { val newsList: LiveData<List<News>> = flow { emit(repository.fetchNewsFromCache()) emit(repository.fetchNewsFromNetwork()) } .flowOn(Dispatchers.IO) .catch { e -> // 处理异常,避免数据流中断 emit(emptyList()) } .asLiveData() }

flowOn(Dispatchers.IO)表示这个Flow的上游在IO线程执行,下游(collect的地方)自动回到收集者所在的线程。如果你用LiveData,要么自己处理线程切换,要么只能在主线程做所有事,非常局限。

7.2 Flow的收集与背压

Flow的收集默认是“冷”的,而且每个值都是按顺序处理的。如果上游发射速度很快、下游处理速度很慢,Flow会自动等待下游处理完再发射下一个,相当于天然帮你处理了背压。注意这几点:

  • 如果使用collect,它默认是顺序处理的,不适合用在一个需要并行处理的场景
  • buffer可以给上游和下游之间加缓冲,减少等待
  • conflate只保留最新值,如果上游发射太快、下游处理不过来,就丢弃中间值
  • debounce很适合搜索框场景,用户停止输入后再发起请求

我自己常用的一个模式是把用户输入事件变成一个Flow,然后用debounce+distinctUntilChanged+flatMapLatest组合,实现搜索防抖。这在以前的写法里要写一堆Timer和Handler,用Flow就是几行声明式代码。

7.3 StateFlow与SharedFlow:状态型与事件型

Flow有形态之分。StateFlow适合表达状态,要求有初始值,只会保留最新值,被收集时立即拿到当前值;SharedFlow适合表达一次性事件,比如Toast展示、导航跳转,可以不重放给后订阅的观察者。很多团队用LiveData处理事件时,会遇到“事件被消费后旋转屏幕又触发一遍”的经典问题,用SharedFlow配合WhileSubscribed策略就干净很多。

private val _toastEvent = MutableSharedFlow<String>() val toastEvent: SharedFlow<String> = _toastEvent fun showMessage(msg: String) { viewModelScope.launch { _toastEvent.emit(msg) } }

收集事件的地方用repeatOnLifecycle(Lifecycle.State.STARTED)收集,这样事件在STARTED到STOPPED之间触发,不会因为屏幕旋转造成重复消费。

8. 协程与线程池:底层原理与性能取舍

新手学协程,学到能写出业务代码就停下了,但真要追求性能,还是得看一眼底层。协程本身不是线程,它是运行在线程上的“轻量级任务”。调度器底层依然依赖线程池,只是这个线程池对开发者透明了。

8.1 协程为什么轻量

线程是操作系统的资源,创建和切换都要经过内核;协程是用户态的概念,切换时不涉及内核态切换,开销小很多。有人做过一个简单测试:创建10万个线程大概率直接OOM,但创建10万个协程可以正常跑起来。这个数量级差异解释了为什么协程叫“轻量级线程”。

但注意,这不是说你可以无节制地启动协程。协程最终还是要在线程池里执行,线程池的线程数有限,协程数量太多时,它们只是排队的任务,并不会同时跑。所以该做并发限制的地方还得限制。

8.2 调度器的实际行为

Dispatchers.Main在Android上通过Handler消息机制实现,实质上是把协程的继续执行包装成Runnable投递到主线程Looper。Dispatchers.IO和Dispatchers.Default底层用的是不同的线程池,IO池的线程数上限默认是64,Default池的线程数上限是CPU核心数。这也是为什么IO密集和CPU密集要分开——IO池线程多但不擅长长时间计算,Default池线程数少但单线程的CPU能力更靠谱。

8.3 自定义调度器:什么时候需要

团队里曾经有个场景:多个协程共享一个线程池,但某个模块的特殊任务不希望被其他模块的任务挤掉。这时候可以自定义ExecutorCoroutineDispatcher,用Executors.newFixedThreadPool配合asCoroutineDispatcher()扩展方法转成调度器。

val heavyDispatcher = Executors.newFixedThreadPool(4) .asCoroutineDispatcher()

要记得在不用的时候调用close(),否则线程池会一直存活。自定义调度器能给你精细的控制力,但也增加了资源管理成本,建议只在确有性能隔离需求时使用,默认调度器在绝大多数场景已经够用了。

9. 协程与Retrofit、Room的集成:少写一半样板代码

Android开发绕不开网络请求和数据库读写,而这两个场景恰好是协程受益最大的地方。以前写Retrofit接口,要么返回Call<T>,要么包一层Observable,配合RxJava还要引入一堆依赖;现在Retrofit官方支持suspend函数,Room也支持suspend查询,写法极其清爽。

9.1 Retrofit接口直接返回挂起函数

从Retrofit 2.6.0开始,接口方法可以直接返回suspend函数的结果,不用Call<T>包裹:

interface ApiService { @GET("user/info") suspend fun getUserInfo(@Query("id") id: String): UserInfo }

调用时:

viewModelScope.launch { val userInfo = withContext(Dispatchers.IO) { apiService.getUserInfo(id) } ... }

当然,如果你用了Kotlinx Serialization或Moshi做解析,都可以兼容。这种写法配合我们在4.1节提到的suspendCancellableCoroutine方案,网络请求的取消、重试、超时都能做得很优雅。

9.2 Room的挂起查询

Room从2.1.0开始支持suspend函数,DAO里这样写:

@Dao interface UserDao { @Query("SELECT * FROM user WHERE id = :id") suspend fun getUserById(id: String): User? @Insert suspend fun insertUser(user: User) }

最主要的收益是这些操作不会阻塞调用线程。以前你必须在AsyncTask或线程池中执行DAO操作,现在直接在协程里用就好。复杂点的事务操作可以用withTransaction,Room官方也提供了协程支持。

9.3 WorkManager与协程的配合

后台任务处理中,CoroutineWorker是个好选择。你可以把原来用Worker实现的异步任务改成CoroutineWorker,这样既能用协程的优势,又保持WorkManager对任务调度、重试、约束的管控:

class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val data = remoteApi.fetchData() localDb.saveData(data) return Result.success() } }

CoroutineWorker中的doWork是挂起函数,所以可以在里面直接调用其他suspend函数,不需要手动包一层线程切换。任务被取消时,WorkManager会取消协程,相关网络请求也会释放,这比老式的Worker要安全太多。

10. 迁移到协程的一些建议:从项目实际角度出发

如果你现在正在一个老项目里,想逐步把异步代码迁移到协程,我的建议是不要一口气全改。协程虽然好,但大面积重构代码,风险和成本都不小,尤其是涉及网络层和数据层的时候,一步到位往往意味着失控。

10.1 从最痛的地方开始切入

个人经验是,最容易看到明显收益的场景有三个:

  • 嵌套回调最深的网络请求链(比如一个请求依赖另一个返回的数据)
  • 界面销毁后回调还能触发的场景(用协程Scope自动取消)
  • 需要并发请求再合并结果的场景(async/await)

先选这几个场景作为试点,对比新旧代码的维护体验,团队自然会有动力继续改。

10.2 建立团队规范,避免风格分裂

协程本身API不算复杂,但“怎么用”确实有讲究。项目里应该有规范,至少约定这么几点:

  • 启动协程必须指定Scope,禁止GlobalScope
  • 耗时操作必须切到合适的Dispatcher,主线程不能碰网络和数据库
  • 捕获异常时不要吞掉CancellationException
  • 网络请求统一封装成挂起函数,不允许直接Thread
  • Flow的收集必须绑定生命周期,不要无限收集

这些规范写进代码评审清单里,比在代码里逐个纠正高效得多。

10.3 保留其他异步方案的适用场景

最后说点中肯的话。协程很强,但不代表它要替代你项目里的所有异步技术。RxJava在某些场景下还是有优势,比如需要复杂操作符链、需要大量线程调度控制、或者已经有成熟RxJava基建的团队。协程和RxJava也可以共存,它们不是有你没我的关系,而是看团队习惯和业务复杂度。

对我来说,协程最大的价值是:用最接近人类直觉的方式表达异步逻辑,同时从框架层面解决生命周期安全和异常传播问题。它不神奇,也没有取代多线程,但确实是Android异步编程里值得拥抱的成熟方案。如果你还在犹豫要不要学,我的答案始终是:早点用,早点踩完坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进