
1. 从回调地狱到协程这篇手册到底在聊什么Kotlin 协程上手之后很多代码就再也回不去以前那种回调嵌套的写法了。这篇内容是整理出来的一个持续更新的协程使用手册不是官方文档的翻译而是我在真实项目里反复用到、反复踩坑之后沉淀下来的思路和套路。适合已经开始用协程但还没完全吃透的开发者也适合刚入门、想直接看到这东西在业务里到底怎么用的人。协程解决的核心问题总结起来其实就三个一是回调地狱一段网络请求套一段回调逻辑被拆得稀碎二是线程切换太繁琐业务代码到处是 runOnUiThread 或者 handler.post三是生命周期管理难异步任务跑着跑着页面关了还在执行轻则泄漏重则崩溃。协程把这些问题统一成一套语言层面的异步模型代码看起来是同步的执行是异步的取消和异常是结构化的。有一点需要先纠正观念协程不是更轻量的线程。线程是操作系统调度的资源协程是语言运行时实现的可挂起计算状态。一个线程上可以跑成千上万个协程协程的切换不需要操作系统介入本质是把一段代码切分成多个挂起点每次执行到一个挂起点就保存现场、让出线程等条件满足后再恢复现场继续跑。理解了这一点后面调度器、取消机制、异常传播才看得懂。2. 协程上手必会的九个基础 API2.1 启动方式怎么选launch / async / runBlocking协程的启动入口就三个launch、async、runBlocking。下面这段代码把它们的返回值差异一次说清楚fun main() runBlocking { // launch返回 Job适合发出一个任务不关心结果的场景 val job: Job launch { delay(1000) println(launch 任务执行完毕) } job.join() // 等待这个 Job 完成 // async返回 Deferred适合需要拿结果的场景 val deferred: DeferredString async { delay(500) 异步结果 } val result: String deferred.await() println(async 结果: $result) }runBlocking 是一个比较重的入口它会阻塞当前线程直到协程体执行完。它主要是给测试代码、main 函数用的不建议在正式 Android 业务里用也不建议在 UI 线程用因为在主线程调用 runBlocking 等于把主线程卡死等一个异步任务完成这会直接导致 ANR。三者的选择可以记一句口诀发任务不关心结果用 launch需要结果用 async写测试或脚本入口用 runBlocking。还有一个细节async 的异常不会立刻抛出而是在 await() 的时候抛出这个差异在异常处理一章会专门细说。2.2 Dispatchers 调度器怎么用不踩坑调度器决定了协程跑在哪个线程上。常用的就四个调度器线程环境适用场景注意点Dispatchers.Main主线程AndroidUI 操作、更新界面不能随意在它上面执行耗时操作Dispatchers.IO共享线程池网络请求、文件读写、数据库操作适合阻塞型 IODispatchers.DefaultCPU 核心数对应的线程池计算密集型任务、JSON 解析、图片压缩线程数量默认近似 CPU 核数Dispatchers.Unconfined不限线程几乎不用仅测试/调试用容易在非预期线程上恢复执行实际项目里最常见的问题是把耗时任务直接挂到 Dispatchers.Main 上。很多新手会写出这种代码lifecycleScope.launch(Dispatchers.Main) { // 直接在 Main 上跑大循环页面照样卡 val bigResult 拉取大数据() textView.text bigResult }如果拉取大数据这个函数本身是耗时的同步操作那么在 Main 调度器上执行照样卡死主线程。协程能解决的是异步问题不是 CPU 耗时问题。正确的做法是让耗时操作切到 IO 线程lifecycleScope.launch(Dispatchers.Main) { val result withContext(Dispatchers.IO) { 拉取大数据() } textView.text result }withContext 并不会新开一个协程它只是在当前协程内切到别的线程执行完再切回来。这是协程开发里最常用的一个函数比 async await 的写法在语义上更清晰也更符合一段逻辑整体放在一个协程里的结构。2.3 Job 和结构化并发协程的亲缘关系协程是有父子关系的。当一个协程在另一个协程的 CoroutineScope 里启动它就会成为那个作用域的子协程。父协程会等待所有子协程完成后才结束父协程取消时子协程也会跟着取消。这个规则叫结构化并发。看这段代码感受一下fun main() runBlocking { val parentJob launch { launch { repeat(5) { delay(100); println(子任务1: $it) } } launch { repeat(5) { delay(100); println(子任务2: $it) } } } delay(250) parentJob.cancel() // 父协程取消两个子协程全部停止 println(父协程已取消) }这里取消 parentJob 后两个子协程不会自己硬扛着把任务跑完而是会同时被取消。这套机制就是崩溃现场最常见的协程泄漏解法的设计基础只要把协程挂在正确的作用域上页面销毁时把作用域 cancel 掉所有后代协程都被收拾干净。还有一个容易被忽略的点Job.join() 和 Job.cancel() 的组合使用。cancel() 是立即返回的它只是发出取消信号不会等到协程真正停止。如果 cancel 之后立刻调 join()才是真正等待协程完成取消流程。很多线上问题就是因为 cancel 后马上往下走而协程还在释放资源导致状态被跳过。3. 协程取消这块我看十个项目有九个写错3.1 协作式取消到底是怎么运作的协程的取消不是暴力停掉线程。协程的取消是协作式的当协程收到取消信号后它只会在下一个挂起点抛出一个 CancellationException。所以取消要生效前提是协程代码里得有响应取消的地方。能响应取消的典型位置包括delay、withContext、withTimeout、挂起 IO 操作等。这些挂起函数在恢复执行时会检查当前协程是否处于取消状态如果是就直接抛异常退出。但如果协程体里写了一个死循环循环里是 CPU 计算没有任何挂起函数那 cancel 信号发了也白发。举个最简单的例子val job launch(Dispatchers.Default) { var i 0 while (true) { i // 纯计算不停无法响应取消 } } delay(100) job.cancel() println(job is cancelled: ${job.isCancelled}) // true但协程还在跑Job 的 isCancelled 已经是 true 了但那个 while(true) 还在疯狂消耗 CPU。这种取消不生效的问题在真实项目里绝大多数是这种场景。3.2 取消不生效的排查实录两个典型场景之前排查过一个问题现象是页面已经销毁但后台的日志还在不断打印轮询中。查到代码发现是一个 while(isActive) 没写对。原始代码长这样scope.launch(Dispatchers.IO) { // 错误的写法没有检查协程是否还活着 while (true) { doSomething() delay(1000) } }这段代码其实是可以取消的因为 delay 会响应取消。真正的坑是另一种写法scope.launch(Dispatchers.Default) { // 里面有一大段 CPU 计算for 循环几百万次中间没有挂起点 while (true) { heavyCompute() Thread.sleep(20) // 用线程休眠代替 delay这是原罪 } }Thread.sleep 不是协程的挂起函数它不能响应取消。协程挂起必须用 delay而不是 Thread.sleep。前者让出的是协程的执行权后者让出的是线程的执行权语义完全不同。排查这类问题时优先检查三处协程体里是否用 Thread.sleep 冒充 delay、循环条件是否用了 isActive、耗时操作是否包在挂起函数外面没检查取消状态。3.3 清理资源时必须用的 NonCancellable协程被取消时finally 块会执行但如果 finally 块里有挂起函数代码就会编译不通过。原因是协程已经处于取消状态不能继续挂起。这时需要 NonCancellable 来借一个新的上下文执行清理逻辑scope.launch { try { delay(1000) println(任务完成) } finally { // 这里不能直接调用 delay 等挂起函数 withContext(NonCancellable) { // 必要的清理操作比如关闭数据库连接、上报日志 closeConnection() } } }NonCancellable 是一个特殊身份它会让代码块在取消状态下也能继续执行。但是要注意它只能用于清理场景不能拿它包裹核心业务逻辑否则取消机制就白设计了。4. 异常处理从裸奔到全副武装4.1 launch 和 async 的异常传播差异协程的异常传播机制是最容易让人困惑的地方。核心区别就一句话launch 启动的协程里未捕获异常会被当作未处理异常向上传播最终交给父协程的异常处理机制async 启动的协程则傻傻地存着异常直到有人调用 await() 时才抛出。fun main() runBlocking { // launch 风格的异常 val job launch { throw IllegalStateException(launch 里的异常) } job.join() // 此刻协程内的异常已经被传播给了父级 // async 风格的异常 val deferred async { throw IllegalArgumentException(async 里的异常) } // 不调用 await 的话这个异常会被吞掉 try { deferred.await() } catch (e: IllegalArgumentException) { println(捕获 async 异常: ${e.message}) } }这个差异还会影响父协程。如果一个用 launch 启动的子协程抛出了未捕获异常默认行为是把整个父作用域都取消。很多页面闪退就是这么来的某个子任务异常了结果同级的兄弟协程全部被取消页面数据集体消失。要避免这种一个异常带走全家的情况得引入 SupervisorJob 或 supervisorScope。4.2 全局异常处理只适合兜底CoroutineExceptionHandler 是协程的全局异常处理器但它只对 launch 启动的协程生效async 的异常不归它管。项目里的最佳实践是把全局处理当最后一道防线记录日志、弹出通用错误具体业务异常还是应该放在业务代码里 try-catch。val handler CoroutineExceptionHandler { _, throwable - // 打日志、上报崩溃追踪 println(未处理异常: ${throwable.message}) } val scope CoroutineScope(Dispatchers.Main handler) scope.launch { throw RuntimeException(测试未处理异常) }全局 handler 的坑在于它是最后一道机制一旦你把异常自己 try-catch 了handler 就不会收到。所以不要依赖 handler 处理业务错误它就是个兜底网。4.3 SupervisorJob 和 supervisorScope 怎么选SupervisorJob 和普通 Job 的区别当父作用域里某个子协程异常失败时SupervisorJob 不会取消其他子协程。这个特性在多任务并行、互不影响的场景特别有用比如一个页面同时拉取用户信息、拉取商品列表、拉取配置信息其中任何一个失败不应该影响另外两个。// 不推荐直接用 SupervisorJob CoroutineScope 手写作用域 val scope CoroutineScope(SupervisorJob() Dispatchers.Main) scope.launch { throw RuntimeException(任务1失败) } scope.launch { println(任务2仍然执行) }注意上面的写法有一个隐藏问题直接用 supervisorScope() 的写法更适合业务代码内部使用lifecycleScope.launch { supervisorScope { launch { loadUserInfo() } launch { loadProductList() } launch { loadConfig() } } }supervisorScope 是挂起函数它等所有子协程完成才返回子协程之间互相隔离异常。在这个 scope 里 launch 的协程异常不会影响兄弟协程使用起来比手写 SupervisorJob 更安全因为手写构造的 SupervisorJob 容易出现根 Job 和传入 Scope 的 Job 不一致的问题。4.4 手动封一个统一错误处理挂起函数实际项目里我会给网络请求的协程套一层防护壳核心思路是内部 try-catch 吞掉异常、返回业务结果不让异常冒到上层suspend fun T safeCall(block: suspend () - T): ResultT { return try { Result.success(block()) } catch (cancel: CancellationException) { // 协程取消异常不能吞必须继续抛 throw cancel } catch (e: Exception) { Result.failure(e) } } // 用起来 lifecycleScope.launch { val result safeCall { api.getUserInfo() } result.onSuccess { showUser(it) } .onFailure { showError(it) } }注意这个封装里捕获了 CancellationException 之后必须要重新抛出否则取消了协程又被当成普通异常处理业务上会出现极难排查的页面销毁了但回调还在跑的问题。CancellationException 在协程体系里不是普通异常它代表的是控制流吞掉它等于破坏了取消机制。5. 实战套路从串行请求到并发限流5.1 串行请求与并行请求的标准写法最直白的场景先拿用户信息再根据用户信息去拿订单列表。如果用户信息不返回就拿不到订单列表这就是串行依赖scope.launch { val user withContext(Dispatchers.IO) { api.getUser() } val orders withContext(Dispatchers.IO) { api.getOrders(user.id) } render(orders) }代码看起来是同步的但每一步都在 IO 线程执行完再切回主线程这就是协程最舒服的地方。反过来如果两个请求之间没有依赖关系比如一个页面同时要展示今日推荐和公告那就应该并行scope.launch { val recommendDeferred async { api.getRecommendList() } val noticeDeferred async { api.getNoticeList() } val recommend recommendDeferred.await() val notice noticeDeferred.await() render(recommend, notice) }注意这里 async{} 默认会在当前作用域的调度器上执行如果不加 Dispatchers.IO如果当前是 Main 调度器网络请求就直接发生在 Main 线程上了。所以并行请求建议这样写scope.launch { val list async(Dispatchers.IO) { api.getRecommendList() } val notice async(Dispatchers.IO) { api.getNoticeList() } render(list.await(), notice.await()) }还有个性能细节两个 async 并行并不能保证比串行快一倍因为最终要等最慢的那个。如果其中一个是秒回另一个是 3 秒总耗时约等于 3 秒这是正常的。5.2 用 withTimeoutOrNull 给请求加超时保护网络请求是最容易卡住的地方协程提供了一个非常实用的超时工具withTimeout 和 withTimeoutOrNull。前者超时抛出 TimeoutCancellationException后者超时返回 null业务上更常用的是 withTimeoutOrNull。scope.launch { val result withTimeoutOrNull(3000) { api.fetchSomething() } if (result null) { showTimeout() } else { showSuccess(result) } }这个 3000 的粒度可以根据接口情况定制。更严谨的写法是把超时时间抽成常量按接口差异配置。withTimeout 还有一个隐藏特性它会在超时时主动取消子协程所以即便 api.fetchSomething() 内部里有耗时操作也会在触发超时的时候被打断这个对防止僵尸任务很有帮助。5.3 重试机制与退避策略协程本身没有自带重试 API但用 suspend 函数可以很方便地封装。一个简单的重试模板suspend fun T retry( times: Int 3, initialDelayMillis: Long 1000, block: suspend () - T ): T { var currentDelay initialDelayMillis repeat(times - 1) { try { return block() } catch (e: Exception) { delay(currentDelay) currentDelay * 2 // 指数退避 } } return block() } // 使用 val data retry(3, 1000) { api.getConfig() }重试的坑有两个。第一CancellationException 会被上面的 catch(Exception) 捕获必须在 catch 里重新抛出 CancellationException否则用户取消了协程还在傻傻重试。第二网络错误要区分像 401、参数错误这种再试多少次都一样的错误就不要重试设置重试条件suspend fun T retryWhen( times: Int 3, shouldRetry: (Exception) - Boolean, block: suspend () - T ): T { repeat(times - 1) { attempt - try { return block() } catch (e: Exception) { if (!shouldRetry(e)) throw e delay(interpret(attempt)) // 按次数递增等待 } } return block() }5.4 Mutex 与 Semaphore给并发加上限流协程里多个任务同时操作同一个资源时线程安全仍然需要关注。比如多个协程同时往同一个 List 里添加数据仍然会出现并发问题。常用的工具是 Mutex互斥锁和 Semaphore信号量。val mutex Mutex() var count 0 scope.launch { repeat(100) { mutex.withLock { count } } }Mutex 的 withLock 里可以挂起这一点比 Java 的 synchronized 自然得多因为持锁期间不会死占一个线程。Semaphore 用于限制同时执行的任务数量比如同时最多允许 3 个下载任务val semaphore Semaphore(3) scope.launch { repeat(20) { index - launch { semaphore.withPermit { downloadFile(index) } } } }Semaphore 在 kotlinx.coroutines 的同步包下从 1.6.0 开始稳定可用。这套限流方案在直播间、图片批量上传、批量文件下载场景里都实测过比 Java 的 Semaphore 好用因为支持挂起函数调用。5.5 Android 里和生命周期组件协作的协程Android 开发绕不开 ViewModel 和 Lifecycle。老一套做法是手动维护 CompositeDisposable协程时代直接用官方扩展lifecycleScope 和 viewModelScope。class MainViewModel : ViewModel() { fun loadData() { viewModelScope.launch { val data withContext(Dispatchers.IO) { loadFromDb() } _state.value data } } }viewModelScope 会在 ViewModel 被 clear 的时候自动取消所有协程lifecycleScope 会在对应的 Lifecycle 生命周期销毁时取消。这俩扩展直接解决了协程泄漏问题比自己在 onDestroy 里手动 cancel 可靠得多。唯一要注意的是业务里如果需要页面被销毁但任务仍然继续的场景比如长上传任务不能简单挂在这些作用域上得单独用进程级作用域管理。6. 高频踩坑记录这些代码我每隔一段时间就想改一次6.1 GlobalScope 是泄漏的第一大源头GlobalScope 是一个进程级的协程作用域它没有父级、不随页面销毁自动取消。在 Android 代码里直接写 GlobalScope.launch { ... }页面销毁后任务还在后台跑轻则白耗资源重则触发对已销毁 View 的引用导致内存泄漏。看一下这类问题的典型修法class MainActivity : AppCompatActivity() { // 错误GlobalScope GlobalScope.launch { fetchData() } // 正确用 lifecycleScope private fun loadData() { lifecycleScope.launch { val result withContext(Dispatchers.IO) { fetchData() } updateUi(result) } } }如果确实需要一个非页面绑定的作用域比如定时上报任务请自己创建 CoroutineScope 并维护好它的取消时机至少不要每次都 new 一个 scope 然后不取消。6.2 主线程误用挂起函数新手最常见的问题挂起函数不一定在主线程执行但大家经常误以为协程就是异步的所以在协程里做什么都安全。实际上挂起函数只在遇到真正的挂起点时才会让出线程中间的普通同步代码依旧是当前线程执行的。scope.launch(Dispatchers.Main) { val text heavyParse() // 这个函数不是挂起函数执行时照样卡主线程 textView.text text }如果 heavyParse() 是耗时操作这段代码就会卡 UI。正确的做法是包进 withContext(Dispatchers.Default) 里。判断标准很简单这个函数内部有没有挂起等待 IO 或者计算密集如果有就必须配调度器。6.3 协程并不保证线程安全协程本质是线程调度框架不是并发安全锁。多个协程共享一个可变对象如果不加锁就有竞态条件。某些人在协程里大胆地写class ApiClient { private var token: String? null suspend fun refreshToken() { token api.getToken() // 多个协程同时调用时这里会互相覆盖 } }如果两个协程同时走到这行代码token 被赋值的顺序不可控。解决办法是用 Mutex 保护关键区或者干脆用不可变数据模式、把状态集中到 StateFlow 里做合并处理。协程只是帮你在异步世界里更好地写代码不是帮你免了并发安全这门课。6.4 withContext 和 async-await 别混着用经常看到有人把 withContext 当成 async 来用scope.launch { val deferred withContext(Dispatchers.IO) { api.getData() } // 错误的用法withContext 的结果就是直接值不是 Deferred }withContext 返回的是代码块里最后一行表达式的值是直接结果不需要 await。而 async 返回 Deferred需要 await 取结果。把 withContext 的结果当成 Deferred 调用 await会直接编译报错。这种错误发生的原因是对两者的语义没分清withContext 是当前协程切换上下文执行一段代码async 是开启一个新协程。在同一个协程里想并行多个任务时用 async只想切线程执行完再回来时永远优先用 withContext。7. 手册持续更新的方向这篇手册目前覆盖了启动方式、调度器、取消、异常、并发限流、生命周期协作和坑点总结但 Kotlin 协程的内容远远不止这些。接下来我会按优先级补充下面几块Flow 响应式编程flow、flowOn、map、catch、flatMapConcat、debounce 在业务里的组合套路协程单元测试怎么写runTest、假时钟、testDispatcher 的正确打开方式常见中间件源码级的钩子原理比如 Retrofit 的 suspend 支持Actor 模式的协程实现协程与 Reactor/流式框架的取舍这些内容都是项目里踩过坑之后总结出来的而不是粘贴官方文档。我会边写边更新保持代码风格一致尽量贴近真实业务场景每一条都带上能直接跑的 Kotlin 代码。最后再分享一个个人习惯写协程代码时保持职责边界意识。每个协程只做一件事作用域命名清楚不用一个 scope 包打天下。页面级任务挂 lifecycleScope数据层任务视情况单独建 scope尽量不把业务逻辑的异常吞成空白让失效的代码尽早发声。这套习惯帮我少排查了很多诡异问题。协程是好东西但它在并发、线程、异常之间的复杂性仍然真实存在需要我们用纪律性去约束它。希望这篇手册对你也有用。