ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kotlin协程从入门到实战:核心原理与工程实践指南

Kotlin协程从入门到实战:核心原理与工程实践指南 1. 协程到底解决了什么问题——先搞清楚再动手说真的我最初接触 Kotlin 协程的时候是带着一肚子疑问的。那会儿项目里还到处是回调嵌套接口请求一个接一个回调里套回调代码丑得自己都不忍直视。后来听说协程能解决这个问题我就去看了官方文档。结果第一感觉是这玩意儿概念也太多了吧launch、async、suspend、CoroutineScope、Dispatcher……看了一下午似懂非懂真正上手写的时候还是不知道怎么组织代码。后来我才明白问题出在我一直在学 API而没有先想清楚协程到底是干嘛的。如果不理解它解决的问题你学到的就只是一堆孤立的关键字。1.1 回调和线程切换带来的代码地狱先回顾一下没有协程的时候我们怎么写异步代码。比如一个常见的场景等两个接口都返回了再拼装数据刷新界面。用回调写出来的大致是这个样子api.fetchUserInfo { user - api.fetchUserPosts(user.id) { posts - api.fetchUserFriends(user.id) { friends - // 三个都回来了拼数据 renderUI(user, posts, friends) } } }看着好像还能忍那如果中间还要处理错误呢要显示加载动画呢要在某个请求超时的时候给出提示呢回调一叠加代码马上变成一团乱麻。更难受的是一旦某个回调里忘了切回主线程UI 操作直接就崩了。线程切换也很磨人。以前我写 Android 网络请求常见的套路是主线程发请求或者用线程池- 拿到结果 -runOnUiThread切回主线程更新 View。稍微不留神线程就切错了接着就是各种偶发的崩溃排查起来想砸键盘。1.2 协程不是更快的线程而是更聪明的写法有个误区必须澄清协程不是为了让程序跑得更快而是为了让异步代码写起来像同步代码一样自然。它不节省 CPU 时间也不减少运算量它节省的是程序员理解代码逻辑的脑力。Kotlin 协程的核心机制是编译器帮你把suspend函数改写成状态机。你写的挂起函数看起来是一段上下连贯的代码实际上被拆成了很多个片段每个片段在合适的时机恢复执行。这有点像是在一个函数内部的多个断点之间来回跳但编写的人完全感觉不到。所以协程的本质可以理解为把异步回调的控制流反转重新扳了回来。代码是线性的、从上往下读的但执行是可以暂停和恢复的。用几个名字来概括就是suspend关键字和Continuation机制。前者标记一个函数可以挂起后者在背后保存了挂起到哪里了恢复后从哪继续的上下文。给它打个比方普通函数像一辆从不刹车的公交车一路开到终点。协程里的挂起函数像一位司机等红灯就停绿灯亮了继续开但乘客代码编写者的体验始终是坐在同一辆车上没有下车换乘的痛苦。2. 协程三件套launch、async、withContext 的正确打开方式理解了为什么再来看怎么用很多困惑就迎刃而解了。协程的基本操作说来说去就三个启动协程、并行执行、切换上下文。2.1 GlobalScope 是一颗定时炸弹很多教程一上来就给你写GlobalScope.launch { // 随便跑个什么任务 }我得先泼盆冷水生产环境千万别这么写。我自己早期也踩过这个坑。GlobalScope 的作用域是进程级别的生命周期跟着整个应用走它启动的协程在没有任何引用的情况下也是活着的。你从一个 Activity 里用 GlobalScope 发了个请求Activity 关了协程还在后台跑UI 操作照样执行轻则空指针重则内存泄漏。正确做法是使用一个能感知生命周期的 CoroutineScope。在 Android 里有现成的lifecycleScope、viewModelScope它们的存活时间跟界面/ ViewModel 绑定在销毁时自动取消协程不会出现人走了活儿还在干的情况。如果你是在普通 Kotlin 项目里自己管理可以这样创建class MyRepository { private val scope CoroutineScope(SupervisorJob() Dispatchers.IO) fun loadData() { scope.launch { // 业务逻辑 } } fun destroy() { scope.cancel() } }2.2 launch、async、withContext 分别什么时候用这三个是最常用的协程构建器和挂起函数。很多新手分不清我直接给结论launch用来启动一个不需要返回值的协程它返回一个Job你可以通过job.cancel()取消它也可以job.join()等待它执行完。async用来启动一个需要返回结果的协程它返回一个DeferredT你可以调用await()拿到结果。withContext用来在同一个协程内切换线程上下文它不会创建新的协程只是把当前协程切到指定调度器上执行完再切回来。实际使用的时候我最常见的组合是外层用launch内部并行任务用async切线程用withContext(Dispatchers.IO)。看个标准示例同时请求两个接口然后汇总suspend fun loadHomePageData(): HomePageData { return coroutineScope { val userDeferred async(Dispatchers.IO) { api.fetchUserInfo() } val bannerDeferred async(Dispatchers.IO) { api.fetchBanners() } val user userDeferred.await() val banners bannerDeferred.await() HomePageData(user, banners) } }这里有个很关键的点async在coroutineScope里面启动表示两个并行任务的父作用域是当前的协程。任何一个子协程出异常整个作用域都会取消。这个特性后面讲结构化并发的时候会细说。2.3 调度器线程切换的底层逻辑Kotlin 协程切换线程靠的是Dispatcher常用的就四个Dispatcher用途适用场景Dispatchers.Main主线程UI 操作Android 界面更新Dispatchers.IOIO 密集型任务网络请求、磁盘读写Dispatchers.DefaultCPU 密集型任务解析大 JSON、图像处理Dispatchers.Unconfined不指定线程基本用不到别碰你可以把 Dispatcher 理解成协程运行的跑道。withContext(Dispatchers.IO)的意思是把协程切到 IO 跑道上去跑跑完了再回到原来的跑道。这个切换对使用者是隐藏的你不用手动管理线程代码就像在单个线程里顺序执行一样。实际开发中我一般这样组织线程切换fun loadUserInfo() { viewModelScope.launch { _uiState.value UiState.Loading try { // 切到IO线程做网络请求 val user withContext(Dispatchers.IO) { api.fetchUserInfo() } // 回到主线程更新UI _uiState.value UiState.Success(user) } catch (e: Exception) { _uiState.value UiState.Error(e.message) } } }注意withContext返回的是最后一个表达式的值所以网络请求的结果可以直接赋给变量。这段代码挂在viewModelScope上ViewModel 销毁时协程自动取消不会有回调回调地狱也不用手动切线程。这就是协程最直接的价值。3. 结构化并发Kotlin 协程区别于其它语言协程的关键设计说实话结构化并发这个概念是我用协程半年之后才真正理解透的。入门的时候我只关注launch和async觉得能跑就行。直到有一次线上反馈说用户退出页面后还在偷偷请求网络我排查了很久才发现是协程没有按结构化并发的规则来写。3.1 协程的父子层级和生命周期结构化并发简单说就是协程是有父子关系的父协程要等所有子协程执行完毕才会结束父协程被取消所有子协程全部跟着取消。这就像项目管理项目组解散了下面的各个小组也自然停止干活而不是各干各的人都走光了还有代码在跑。看个例子理解一下fun testStructured() runBlocking { val parentJob launch { launch { delay(1000) println(子协程1执行完毕) } launch { delay(2000) println(子协程2执行完毕) } } parentJob.join() println(父协程执行完毕) }输出结果必然是子协程1先打完子协程2后打完最后父协程才打父协程执行完毕。如果父协程不等子协程那输出顺序就无法保证。再验证一下取消的传递fun testCancelPropagation() runBlocking { val parentJob launch { launch { try { delay(5000) } catch (e: CancellationException) { println(子协程1被取消) throw e } } launch { delay(5000) println(子协程2执行完毕) } } delay(1000) parentJob.cancel() parentJob.join() }这里父协程调用cancel()后所有子协程都会被取消。子协程1捕获到了取消信号子协程2直接静默取消。所以如果你的业务逻辑里有一些清理资源、释放锁的操作记得放在finally里并且用try-catch捕获CancellationException做兜底处理。3.2 取消是协作式的不会自动中断你的代码这一点太重要了我用一个真实例子来说明。之前有个需求是后台批量上传图片我写了个协程循环上传viewModelScope.launch { for (image in imageList) { uploadImage(image) // 上传每张图片 publishProgress(progress) // 更新进度 } }结果用户手动点击取消进度条停了但网络请求还在继续发过了好久才真正停下来。原因很简单协程的取消是协作式的不是强制的。它不会像Thread.stop()那样粗暴地把正在执行的任务杀掉而是通过设置一个取消标志来通知协程。如果代码里没有检查这个标志协程压根不知道自己已经被取消了。上面那段代码的问题在于uploadImage是一个普通的挂起函数里面如果一直没有使用挂起函数或检查取消标志取消信号就无法被响应。解决办法有几个调用ensureActive()检查当前协程是否活跃用isActive属性自己判断在循环里调用挂起函数比如yield()主动让出执行权改一下viewModelScope.launch { for (image in imageList) { ensureActive() // 如果协程被取消这里立即抛 CancellationException uploadImage(image) publishProgress(progress) } }之前有人问我为什么有的协程取消无效十有八九就是没做协作式取消的检查。这里也引申出一个很实用的排查技巧如果你发现自己写的协程取消不了先去代码里找有没有耗时操作是在withContext(Dispatchers.IO)的普通代码块里跑的。IO 线程池里的任务不会因为协程取消而中断需要你自己检查取消状态。3.3 SupervisorJob如何让一个子协程的失败不影响其它兄弟结构化并发的默认行为是一损俱损。一个子协程抛异常整个作用域都取消。这在有些场景下是你想要的比如用户进入一个页面加载基础数据失败了那整个页面数据都算是失败的。但有些场景不是这样比如首页同时加载推荐列表和用户信息推荐列表抽风挂了不应该把用户信息也一起取消。这时候用supervisorScope或者SupervisorJobfun loadHomeData() { viewModelScope.launch { supervisorScope { launch { try { val recommend api.fetchRecommendList() // 更新推荐列表UI } catch (e: Exception) { // 推荐列表失败不影响其他 } } launch { val user api.fetchUserInfo() // 更新用户信息UI } } } }supervisorScope的特点是子协程失败互不影响但父协程的取消仍然会传递到所有子协程。这个语义很符合页面级任务的整体取消 子任务互不干扰的模型。我个人的经验是默认用coroutineScope只有明确知道某个子任务失败不该连累别人的时候才用supervisorScope。因为coroutineScope的快速失败语义更符合大多数要么全成功要么全都撤的业务需求也更容易排查问题。4. 实战并发请求、超时控制与数据聚合的完整案例理论讲了一堆来一个完整的实战例子把前面说的知识点串起来。这个例子是电商 App 的商品详情页需要同时做三件事请求商品基本信息请求商品的促销活动信息如果有用户登录还要请求用户对该商品的收藏状态三个接口互相独立全部返回后才合并数据并渲染。而且每个接口都要设置超时时间防止某个接口拖死整个页面。4.1 用 async 聚合并发结果suspend fun loadProductDetail( productId: String, isLogin: Boolean ): ProductDetail { return coroutineScope { val productDeferred async(Dispatchers.IO) { withTimeout(5000) { api.fetchProductDetail(productId) } } val promotionDeferred async(Dispatchers.IO) { withTimeout(5000) { api.fetchPromotions(productId) } } // 未登录就不请求收藏状态 val favDeferred if (isLogin) { async(Dispatchers.IO) { withTimeout(3000) { api.fetchFavoriteStatus(productId) } } } else { null } val product productDeferred.await() val promotion promotionDeferred.await() val favorite favDeferred?.await() ?: false ProductDetail( product product, promotion promotion, isFavorite favorite ) } }这里有一个很核心的点async启动的任务是并发执行的也就是说三个接口同时发出请求而不是一个等一个。所以整个函数的耗时约等于最慢的那个接口而不是三个接口耗时的总和。这给用户带来的体验提升是巨大的。withTimeout的作用是给每个请求单独设置超时。如果某个接口超时会抛出TimeoutCancellationException它的父类就是CancellationException。注意这里有个坑在coroutineScope中任意一个子协程抛了CancellationException整个作用域都会被取消即使其它请求马上要返回了也会被取消。如果你希望某个接口超时不影响其它请求可以在每个async内部捕获异常或者把超时逻辑挪到更细的粒度。4.2 超时和重试的实用设计第4.1节里用withTimeout解决了单次请求卡死的问题但实际业务往往还需要重试。协程里最简单的重试就是循环suspend fun requestWithRetry( times: Int 3, block: suspend () - ApiResponse ): ApiResponse { repeat(times) { attempt - try { return withTimeout(5000) { block() } } catch (e: TimeoutCancellationException) { if (attempt times - 1) throw e delay(500 * (attempt 1)) // 重试间隔逐渐加大 } catch (e: Exception) { if (attempt times - 1) throw e delay(300) } } throw IllegalStateException(Unreachable) }这个函数把重试逻辑封装起来业务侧只需要传一个请求 lambda 就行代码非常干净。延迟时间用500 * (attempt 1)做一个简单的退避策略避免同时重试导致服务端压力太大。还有一个小技巧如果你要等多个服务里任意一个成功就算成功可以用select表达式。Kotlin 协程库提供了select来等待多个挂起函数哪个先完成就执行哪个分支。应用场景比如从缓存和网络同时取数据谁先到用谁。不过这个 API 相对冷门日常开发用到的不多知道有这么个东西就行了。4.3 Flow 和协程是什么关系面试为什么老问热搜词里有一条是kotlin flow 面试题。Flow 和协程的关系一句话概括Flow 是建立在协程基础上的响应式数据流 API。协程解决的是异步怎么挂起Flow 解决的是数据流怎么异步地生产和消费。在实战里Flow 最常用来做界面状态流和数据流的观察者模式替代 LiveData 或者 RxJava 的一部分职责。比如在商品详情页网络请求状态加载中、成功、失败可以用一个StateFlow暴露给 UIclass ProductViewModel : ViewModel() { private val _uiState MutableStateFlowProductUiState(ProductUiState.Loading) val uiState: StateFlowProductUiState _uiState.asStateFlow() fun loadProduct(productId: String) { viewModelScope.launch { _uiState.value ProductUiState.Loading try { val detail loadProductDetail(productId, userManager.isLogin()) _uiState.value ProductUiState.Success(detail) } catch (e: Exception) { _uiState.value ProductUiState.Error(e.message ?: 未知错误) } } } }Flow 的面试高频点其实也简单flow { emit(...) }是冷流只有收集者开始收集生产代码才执行StateFlow和SharedFlow是热流StateFlow保存最新状态适合做 UI 状态背压的概念在 Flow 里没有那么突出因为 Flow 默认是顺序执行的用了buffer、conflate才有并发面试问 Flow考察的其实是你懂不懂数据流跟协程的配合。基本上你能说清楚冷流和热流的区别再答上来一个map和catch的重试示例就已经超过很多人了。我自己在项目里用 Flow 的一个感受是它比 LiveData 更灵活比 RxJava 更简单。但你如果只是做简单的状态管理用StateFlow替代 LiveData 完全没问题不需要一下子把 Flow 的每个操作符都学完用到一个查一个就行。5. 从回调迁移到协程时踩过的坑最后这部分是实战吐槽环节我结合自己迁移过程中的各种翻车经历把最常见的几个坑列出来。每一个都是用时间换来的教训。5.1 生命周期没绑定页面销毁了协程还在跑这是最典型的问题。我之前在 Activity 里直接lifecycleScope.launch后来改成 ViewModel 里的viewModelScope情况好很多。但还有一种隐蔽情况协程里面持有 Activity 的 Context 引用页面销毁后Activity 实例本该被回收结果协程还存活Context 就泄漏了。检查方法也很简单在开发者选项里打开不保留活动然后反复进出页面观察内存变化。如果内存持续增长大概率就是协程没有随着页面销毁而取消。解决办法就一句话协程必须在跟界面或业务同生命周期的 Scope 里启动。Fragment 用viewLifecycleOwner.lifecycleScopeViewModel 用viewModelScope不要在 View 层随意 new 一个 Scope。5.2 异常处理方式不对崩溃静悄悄协程的异常处理逻辑跟普通函数不一样很多人第一次写的时候一脸懵。关键在于协程构建器分两类launch和async的异常传播规则不同。launch的异常会立刻抛给父协程如果没人处理就会崩溃async的异常会在await()时抛出所以用launch启动的协程最外层一定要有try-catch。我自己喜欢用一个统一的CoroutineExceptionHandlerval handler CoroutineExceptionHandler { _, throwable - Log.e(App, 协程异常, throwable) // 上报给崩溃平台 } viewModelScope.launch(handler) { // 业务代码 }但注意CoroutineExceptionHandler只在异常没有被try-catch捕获并且协程的根因没有被处理时才生效。如果你在async内发生异常父作用域又是coroutineScope那异常会一路传播到根协程handler也不一定能接住。最稳妥的做法在具体业务代码里做好try-catch不要把异常处理的希望完全寄托在手势处理机制上。5.3 Dispatchers.Main 依赖问题与测试陷阱在 Android 项目里运行协程默认是Dispatchers.Main它依赖主线程的Handler。但是如果你在单元测试里直接跑协程会遇到Maindispatcher 没初始化就崩溃的问题。解决办法是在测试里加一个Dispatchers.setMain(StandardTestDispatcher())。这个 API 是测试专用配合kotlinx-coroutines-test库使用。早年间我写单元测试凡是要测包含viewModelScope.launch的代码都很难跑因为viewModelScope本身就是依赖主线程的。后来用Dispatchers.setMain替换成测试版的 dispatcher所有问题迎刃而解。这个知识点虽然稍微进阶了一些但在实际工程规范里非常重要。如果有同事的协程测试一直起不来八成就是这个原因。5.4 suspend 函数里的耗时操作躲不开的坑suspend关键字只能挂起协程不能把 CPU 密集型的计算变成不卡。很多人以为写了suspend就万事大吉然后在挂起函数里做了个超大的循环结果主线程照样被卡死。原则是CPU 密集型的计算也要切换到Dispatchers.DefaultIO 操作要切到Dispatchers.IO。不要在Maindispatcher 上直接做复杂计算。我之前写过一段解析超大 JSON 的逻辑直接在Main里解析导致用户快速下拉列表时明显掉帧。后来改成withContext(Dispatchers.Default)整个流畅度完全不一样。还有一点如果某个挂起函数内部不调用任何其它挂起函数编译器会提示你redundant suspend modifier。这算是一个很好的提示挂起函数一定要有可挂起的地方否则你写的可能只是个普通函数加了层伪装。最后再分享一个小技巧如果你正在团队里推广协程我建议先从改造现有回调链最深的那个接口开始不要一上来就把所有代码都重写。找一个真实痛点把回调嵌套改成协程版让同事看到代码行数能减少一半自然就有人愿意跟着用了。工具链方面开发环境用 Android Studio 或者 IDEA 都行Kotlin 插件是内置的不需要额外折腾。如果你之前用的是 Eclipse 写 Kotlin迁移到 IDEA 系的 IDE 会顺畅很多协程的调试体验也更好协程的栈信息在 Coroutines 调试面板里能看到每个挂起点排查问题非常直观。我个人在实际操作中的体会是协程入门不难难的是理解它的设计理念。用回调思维去写协程写出来的一堆GlobalScope.launch到处飞代码比之前更乱。真正理解了结构化并发和协作式取消你写出来的协程代码才会又短又稳。希望这篇东西能帮你绕过我踩过的那些坑少走几步弯路。
返回列表