ARTICLE DETAIL

资讯详情

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

Kotlin协程基础详解:挂起机制、调度原理与结构化并发实践

Kotlin协程基础详解:挂起机制、调度原理与结构化并发实践 1. 先搞清楚协程到底解决什么问题我刚开始接触Kotlin协程时最大的困惑是这东西到底解决了什么痛点网上文章铺天盖地但大多一上来就抛概念——轻量级线程、异步编程框架、非阻塞式看完了还是不知道怎么用。先别急着背概念。我习惯用一句话总结协程是Kotlin提供的一种让异步代码写起来像同步代码的解决方案。什么意思传统异步编程里你发起一个网络请求回调回来才能处理结果。代码一旦多了回调层层嵌套就是俗称的回调地狱调试起来想砸电脑。Kotlin协程的核心价值它不帮你真正开线程而是用挂起suspend和恢复resume的机制让一个线程高效复用代码可读性极高业务逻辑从上到下顺序写不用为回调跳来跳去天然支持结构化并发协程的生命周期可控可管理不再担心任务还在跑页面已经关了协程常被用来处理以下场景网络请求Retrofit配合协程做接口请求拿到结果直接更新UI数据库操作Room从API层面直接支持suspend函数并发任务多个耗时操作需要并行执行时async/await可以优雅解决定时器与延迟任务delay替代Thread.sleep不阻塞线程复杂业务流程A依赖B的结果B又依赖C的结果这种链路用协程写清晰明了适合谁看已经掌握Kotlin基础语法、写过一些Android或服务端代码但还没系统学过协程的人。完全零基础也可以看我会从最底层的原理讲起白话为主代码为辅。我尽量把所有关键点都讲透你在项目里能直接拿这套思路去写代码。这一篇不聊Flow不聊Channel不聊各种高级操作符。基础篇就干一件事把协程的骨架立起来让你知道它是什么、怎么启动、怎么管理、怎么取消、怎么处理异常。2. 协程的核心机制挂起不是阻塞2.1 挂起是什么和线程切换有什么区别很多人一听挂起函数就懵觉得这是个高大上的概念。其实挂起的本质我可以用一个生活场景讲明白。想象你在餐厅排队。如果这个队是一根线程线的传统模型你排着队就什么都干不了别人也插不进来。但协程的排队方式是你在窗口A点了菜等餐的时候不用死盯着窗口可以去窗口B买杯奶茶奶茶好了再回来端菜。整个过程你只占了一个位置但效率高了很多。具体到技术层面挂起就是协程的执行过程可以暂停把当前线程让出来给其他协程或任务使用等到条件满足后再恢复执行。关键在于挂起不释放线程锁、不占用线程资源而是让出CPU时间片。线程调度是操作系统层面的行为切换需要内核态参与有开销协程的挂起恢复是编译器层面的转换本质上是一个状态机开销小得多。我做一个简单的类比总结对比项线程阻塞协程挂起谁管理操作系统内核Kotlin运行时/编译器切换开销微秒级涉及内核态切换纳秒级纯用户态切换是否能继续其他工作不能线程被卡住能当前线程可以做别的事控制权系统决定程序员通过代码控制这就是为什么官方说协程是轻量级线程——不是说协程不占任何资源而是它挂起时的代价比线程阻塞小得多。2.2 suspend关键字只是标记不等于自动异步很多教程说用suspend修饰的函数就是挂起函数这句话没毛病但容易误导人。suspend关键词的实际作用其实是给编译器一个标记这个函数内部可能会挂起可以在这里暂停执行。但要知道一个关键点suspend函数默认运行在调用它的协程所在的线程上它本身并不负责切线程。啥意思看这段代码suspend fun fetchData(): String { return withContext(Dispatchers.IO) { // 模拟耗时操作 delay(1000) 数据 } }fetchData是suspend函数但真正切到IO线程的是内部那个withContext(Dispatchers.IO)不是suspend本身。如果里面没有withContext没有delay它就直接在当前线程执行。我见过不少新手写代码给一个函数加上suspend就以为它自动异步了suspend fun heavyWork() { // 这段代码还是运行在主线程它不会自动切线程 Thread.sleep(3000) }这个函数如果放在主线程的协程里调用照样会卡UI。所以记住suspend只是标记真正决定在哪执行的是协程上下文CoroutineContext。2.3 挂起函数的底层原理简述为了让心里有底简单提一下编译器层面做了什么。一个带suspend的函数编译器会把它改造成一个状态机。比如这段代码suspend fun requestProcess() { val a getDataA() // 挂起点1 val b getDataB(a) // 挂起点2 println(b) }编译后大概会生成一个类似Continuation的类每个挂起点相当于状态机的一个状态。执行到挂起点时如果没有结果出来就return一个挂起标记等到结果可用了再带着上次的状态恢复执行。你不需要手动处理这些但理解这个机制后你就明白为什么协程能在不阻塞线程的情况下实现看起来像同步的代码了。这是整个基础篇的底层铺垫。3. 协程三大关键组件Scope、Context、Builder3.1 CoroutineScope作用域协程的容器CoroutineScope翻译成协程作用域你可以把它理解为协程的容器或管理单位。作用域存在的最大意义是解决协程怎么取消的问题。你启动几个协程它们统一归属到一个Scope下取消这个Scope底下所有协程全部取消。这比一个个去cancel强太多了。Kotlin官方不推荐直接暴露的GlobalScope也就是全局作用域原因是它不归属于任何生命周期一旦启动就无法取消容易造成协程泄漏。我后面会专门讲这个坑。实际开发中Scope的常见来源androidx.lifecycle:lifecycle-runtime-ktx提供lifecycleScope随Activity/Fragment生命周期走androidx.lifecycle:lifecycle-viewmodel-ktx提供viewModelScope随ViewModel销毁而取消自己创建CoroutineScope(Dispatchers.Main)手动管理生命周期写Android的推荐直接用lifecycleScope和viewModelScope省心且不容易出错。3.2 CoroutineContext上下文协程的运行环境CoroutineContext是协程的环境配置包里面包含几个核心元素Dispatcher决定跑在哪个线程Job协程的句柄用来管理生命周期CoroutineName协程名称调试用CoroutineExceptionHandler异常处理器最核心的是Dispatcher和Job。Dispatchers类型Dispatcher适合场景说明Dispatchers.MainUI操作仅在Android等有主线程的平台可用Dispatchers.IO网络/数据库/文件读写线程池较大Dispatchers.DefaultCPU密集型计算线程数等于CPU核心数Dispatchers.Unconfined非受限不指定线程可能来回跳谨慎使用很多初学者会问Main和IO切换不也是线程切换吗没错但协程帮你把这些切换封装成了挂起层面的操作代码写起来是顺序的逻辑清楚得多。3.3 启动协程的三种方式launch、async、runBlocking协程实际上不是创建出来的而是启动出来的。启动工具有三个各有各的用途launch启动一个不需要返回结果的协程val job CoroutineScope(Dispatchers.Main).launch { delay(1000) println(执行完成) } // job.cancel() 取消这个协程async启动一个需要返回结果的协程返回Deferredval deferred CoroutineScope(Dispatchers.IO).async { // 模拟耗时计算 delay(1000) 42 } // 等待结果 val result deferred.await() println(result)Deferred可以理解为未来的结果有点像Java的Future但有本质区别Deferred是可取消的Future的get()是阻塞的而await()是挂起不阻塞线程的。runBlocking阻塞当前线程直到协程执行完毕fun main() { runBlocking { delay(1000) println(你好) } }runBlocking会阻塞调用它的线程所以在Android主线程里绝对不能直接调用。它主要用于测试和main函数入口。3.4 suspend函数的调用规则只能在协程或挂起函数里调用这是协程基础的交通规则suspend函数只能在协程体、或者其他suspend函数里调用在普通函数里直接调用suspend函数编译器直接报错报错信息提示你能看懂Suspend function xxx should be called only from a coroutine or another suspend function。很多新手问为什么我解释一下suspend函数可以挂起挂起意味着程序的执行可能暂时暂停等到恢复时需要协程框架来接管。普通函数没有这个能力所以编译器直接拦截。那如果我在普通代码里确实想调用一个suspend函数怎么办方法有两个用CoroutineScope(...).launch { ... }包一层用runBlocking { ... }包一层仅限测试4. 实操篇从零搭建一个协程Demo工程4.1 环境准备与依赖引入我以Android项目为例因为这是协程最常用的战场。如果你做的是纯Kotlin后端项目思路一样只是省掉UI部分。在build.gradle.kts或build.gradle里添加dependencies { // 协程核心库 implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3) // Android平台支持库提供Main调度器 implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) }如果要用lifecycleScope和viewModelScope再加implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.6.2) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2)既然是基础篇建议最新稳定版统一到1.7.3以上协程API变化不大整体兼容性没问题。版本号以你自己实际环境为准不需要强行对齐。纯Kotlin JVM项目的话核心库就够了Dispatchers.Main在JVM工程里不可用用Default或IO。4.2 第一个协程程序理解启动顺序直接上一个完整的例子。我建议你新建一个项目后把这个代码跑一遍观察输出import kotlinx.coroutines.* fun main() runBlocking { // 在Main线程启动协程JVM环境没Main先看逻辑 val job launch { // launch默认继承外部Scope的Dispatcher // 在runBlocking里默认跑在调用runBlocking的线程 delay(1000) println(协程内部输出) } println(主流程输出) job.join() // 等待协程执行完 }输出顺序是主流程输出 协程内部输出为什么主流程输出先打印因为launch是一个非阻塞的启动它会立即返回一个Job后续代码继续往下走。delay是挂起等协程执行完主流程已经跑到了println。这个例子让你感受到关键一点launch启动协程不会阻塞当前线程。这是协程最直观的体验。4.3 在Android中用lifecycleScope加载数据写一个ViewModel里的通用场景class MainViewModel : ViewModel() { private val _userData MutableStateFlowUser?(null) val userData: StateFlowUser? _userData fun loadUser(userId: String) { viewModelScope.launch { // 切到IO线程做网络请求 val user withContext(Dispatchers.IO) { apiService.getUser(userId) } // 回到主线程更新UI状态 _userData.value user } } }这段代码的逻辑完全顺序展开viewModelScope.launch启动一个协程默认在Main线程withContext(Dispatchers.IO)切到IO线程执行网络请求通过挂起不阻塞主线程请求结果返回代码自动切回Main线程因为withContext执行完会恢复到原来的Dispatcher更新_state这个模式是协程最核心也是最常见的用法代码像同步一样写但底层是异步执行。对比一下传统写法先callBack回调再onSuccess更新如果中间要处理loading状态代码会散落在几个方法里。4.4 百闻不如一跑的完整Demo再给一个完整一点的实战示例这是一个模拟同时请求两个接口并汇总结果的场景data class User(val id: Int, val name: String) data class UserOrder(val items: ListString) suspend fun fetchUser(): User { delay(1000) // 模拟网络请求 return User(1, 小明) } suspend fun fetchOrders(userId: Int): UserOrder { delay(1000) return UserOrder(listOf(订单A, 订单B)) } fun main() runBlockingUnit { val startTime System.currentTimeMillis() // 使用async并行发起两个请求 val userDeferred async(Dispatchers.IO) { fetchUser() } val ordersDeferred async(Dispatchers.IO) { fetchOrders(1) } // 等待两个任务都完成 val user userDeferred.await() val orders ordersDeferred.await() println(用户: ${user.name}, 订单数量: ${orders.items.size}) println(总耗时: ${System.currentTimeMillis() - startTime}ms) }运行结果我在本地测试过大约耗时1000ms多一点而不是2000ms。证明两个async是真正并行执行的。如果换成launch一个一个跑总耗时就是2000ms。这就是协程在性能层面的核心价值用最小的资源实现轻松的并发。5. 协程的取消机制说得容易坑也不少5.1 取消的原理协作式取消Java的Thread.stop()是暴力停止容易导致数据不一致。Kotlin协程采用的是协作式取消。也就是说协程取消不是强制掐断而是给协程一个取消标记由协程自己决定何时响应取消。具体来说调用job.cancel()后Job的状态变为Cancelling协程在下一次遇到挂起点如delay、withContext等时会抛出CancellationException协程捕获到CancellationException后进入Cancelled状态完成取消这意味着如果协程内部没有任何挂起点只有一大段死循环计算调用cancel后协程并不会立刻停止它会等循环结束——甚至永远不结束。我举个例子val job CoroutineScope(Dispatchers.Default).launch { var count 0 while (true) { // 这是个致命的死循环 count // 这里没有挂起点无法响应取消 } } delay(100) job.cancel() println(已取消) // 这句话会打印但协程还在疯狂运行这是经典坑之一解决方案就是挂起函数的配合比如下面这种让循环体可以响应取消。5.2 怎么让协程响应取消最推荐的方式就是用系统提供的挂起函数。因为delay、withContext内部的挂起都会自动检查协程的取消状态。一个常见的反例是这样的。我们来看看规范写法。// 写法一用isActive检查 val job CoroutineScope(Dispatchers.Default).launch { var count 0 while (isActive) { count } } // 写法二用ensureActive检查 val job CoroutineScope(Dispatchers.Default).launch { repeat(1_000_000) { i - ensureActive() // do some work } } // 写法三官方推荐的用例场景用withContext里包挂起函数 val job CoroutineScope(Dispatchers.Default).launch { withContext(Dispatchers.IO) { // 内部如果调用了挂起函数能自动响应取消 } }ensureActive()本质上是在内部调用了检查点发现已取消就抛出CancellationException。如果你做的是一个比较耗CPU的计算型任务记得要在循环里加isActive或ensureActive检查否则取消无效协程会泄漏。5.3 被取消后还能不能继续执行谈finally与不可挂起协程取消后会执行finally块这点和try-with-resource逻辑很像用来释放资源。这里也有坑。val job CoroutineScope(Dispatchers.Default).launch { try { delay(1000) println(执行中) } finally { // 这里可以释放资源 println(我来释放资源啦) } } delay(100) job.cancel()输出我来释放资源啦但是如果finally块里有挂起函数不会自动执行就要手动处理。finally { // 这样会报错不会编译错误但运行时会直接抛出CancellationException // delay(100) // 正确做法用withContext(NonCancellable)包裹 withContext(NonCancellable) { delay(100) println(延迟释放完成) } }因为协程已经处于Cancelling状态在这个状态下调用挂起函数会立刻抛CancellationException。要用NonCancellable这个特殊的上下文来无视取消强制执行。这是比较进阶的知识但基础篇里了解存在即可。6. 协程异常处理策略比细节更重要6.1 异常的传播方式launch和async不同协程的异常处理是最容易翻车的模块。关键在于launch和async的异常传播方式不一样。launch异常即刻抛出如果不处理会传播到父协程导致父协程挂掉async异常在await()时抛出如果一直不调用await异常就一直被存着看代码fun main() runBlocking { // launch直接抛异常 val job launch { throw IllegalStateException(launch抛异常) } job.join() // 程序异常runBlocking会崩溃 }fun main() runBlocking { // async异常会延迟到await时抛出 val deferred async { throw IllegalStateException(async抛异常) } delay(100) // 此时不调用await不会崩溃但await时会崩 val result deferred.await() }所以在项目中如果是主动启动的独立任务用launch需要额外注意全局异常捕获。如果有返回值用async更需要小心处理await时的异常。6.2 异常处理的三种手段手段一try-catch包裹viewModelScope.launch { try { val data withContext(Dispatchers.IO) { apiService.getData() } _uiState.value UiState.Success(data) } catch (e: Exception) { _uiState.value UiState.Error(e.message) } }这是最常见的写法逻辑清晰。注意catch CancellationException时要特别小心后面会讲。手段二CoroutineExceptionHandler用于设置全局异常兜底val handler CoroutineExceptionHandler { _, throwable - println(捕获到异常: ${throwable.message}) } CoroutineScope(Dispatchers.Main handler).launch { throw RuntimeException(异常测试) }需要注意的是CoroutineExceptionHandler只对launch有效对async无效。手段三SupervisorJobSupervisorJob是一个特殊的Job它的核心特性是子协程的失败不会影响父协程和其他兄弟协程。默认情况下子协程异常会向上传播导致父协程取消。在需要一个任务挂了不影响其他任务的场景里SupervisorJob非常重要。val scope CoroutineScope(SupervisorJob() Dispatchers.Main) scope.launch { throw Exception(任务A挂了) } scope.launch { // 这个任务不会被影响照常执行 delay(1000) println(任务B执行正常) }6.3 一个极其常见的经验捕获CancellationException在协程机制里CancellationException和普通业务异常要分开看。它是协程取消的正常流程信号不是错误。我见过很典型的错误示范try { delay(1000) } catch (e: Exception) { // 糟糕把所有异常都吞了 Log.e(TAG, 捕获异常: ${e.message}) }如果这里catch的是Exception会把CancellationException也捕获掉。当协程被取消时取消信号被吞掉了协程的协作式取消机制就失灵了可能导致资源没有释放、状态没有清理。正确做法try { delay(1000) } catch (e: CancellationException) { // 确认是取消重抛保留取消状态 throw e } catch (e: Exception) { // 处理真正的业务异常 Log.e(TAG, 业务异常: ${e.message}) }这个习惯看似小事关键时刻能救你命。我遇到过因为吞掉CancellationException导致协程进入“不知道为什么取消了”的状态排查了很久。7. 协程调度线程切换机制详解7.1 一口气理清Dispatcher的切换链路协程的线程调度本质上靠withContext切换上下文在挂起函数执行完后再恢复原来的上下文。我再说一遍挂起与线程调度背后的道理这个理解了就对协程理解到一半了。当一个协程在Main线程执行遇到withContext(IO)时它做了这么几件事当前协程被挂起IO线程池里某个线程开始执行withContext块内的代码块内代码执行完毕协程恢复回到原来的Dispatcher也就是Main线程代码离开withContext块后继续在Main线程执行。这一整套过程对业务代码来说完全透明。这就是协程给开发者最舒服的体验。一个需要注意的点withContext不是创建新协程它只是在同一个协程内切换上下文。所以不能把withContext和launch混为一谈。这一点在面试里很常考搞清楚它是干嘛的比背概念有用得多。7.2 嵌套协程的线程归属看这段代码猜一下我想出发去打印这个打印在哪线程执行CoroutineScope(Dispatchers.Main).launch { withContext(Dispatchers.IO) { println(在IO线程执行: ${Thread.currentThread().name}) delay(100) } println(回到原来的线程: ${Thread.currentThread().name}) }输出结果在IO线程执行: DefaultDispatcher-worker-1 回到原来的线程: main子协程默认继承父协程的Dispatcher除非你显式指定新的Dispatcher。嵌套launch同理子协程不指定Dispatcher就跟着父协程的调度器跑。7.3 Dispatchers.Unconfined有什么特殊Dispatchers.Unconfined直译是不受限行为比较反直觉它在挂起恢复后不恢复到原来的线程而是在哪个线程恢复就在哪个线程继续执行。CoroutineScope(Dispatchers.Unconfined).launch { println(开始: ${Thread.currentThread().name}) delay(100) println(恢复: ${Thread.currentThread().name}) }输出可能是开始: main 恢复: kotlinx.coroutines.DefaultExecutorUnconfined适合极端情况比如某些性能敏感场景不想做线程切换。它不是推荐的日常工具理解它的原理就好。8. 结构化并发协程思想中最容易被忽视的一环8.1 什么是结构化并发为什么它不是选配结构化并发的核心思想是协程有清晰的生命周期边界父协程管理子协程形成一棵树。如果父协程被取消所有子协程自动取消。如果子协程抛异常异常沿着结构向上传播。没有结构化并发会出现什么问题协程启动了没人管它的死活Activity销毁了网络请求还在跑回调回来导致崩溃异常吞了或漏了根本不知道任务是不是正常结束所以我把结构化并发理解为协程的安全带。看的见的功能差别不大但在管理复杂业务时它是决定代码稳定性的关键。8.2 面试必问coroutineScope vs supervisorScope这两个API考察很频繁主要区别就一句话coroutineScope子协程失败整个scope就失败父协程和兄弟协程统统取消supervisorScope子协程失败不影响其他兄弟协程父协程也不会被取消对应到实战我做两层判断如果多个任务之间是同生共死的完整业务比如同时请求用户信息和订单列表数据缺一不可用coroutineScope如果多个任务是独立的、互不影响的比如同时下载多个文件一个失败不应该影响其他用supervisorScope8.3 用GlobalScope为什么是不推荐的GlobalScope是全局协程作用域不管Activity、Fragment、ViewModel的生命周期。用GlobalScope.launch启动一个协程后这个协程的生命周期与应用一样长。听起来好像方便实际危害很大页面销毁了协程还在后台跑浪费资源回调时页面已销毁更新UI直接崩溃无法统一取消协程越积越多内存泄漏官方在Kotlin协程文档里明确提示需谨慎使用GlobalScope因为它的使用方式破坏了结构化并发。实际项目里除了极少数需要在Application级启动的常驻任务基本都是用lifecycleScope或viewModelScope替代。9. 遇到过的真实问题与排查思路9.1 Main dispatcher is missing我用JVM工程跑协程时最常遇到这个错Module with the Main dispatcher had failed to initialize原因很简单Dispatchers.Main只在Android环境可用纯JVM工程没有主线程。解决办法有两个纯JVM学习阶段全部用Dispatchers.Default或Dispatchers.IO引入kotlinx-coroutines-android依赖同时在Android环境下运行9.2 协程启动后不执行程序直接退出很多人写main函数时这样写fun main() { CoroutineScope(Dispatchers.Default).launch { println(Hello) } // main立刻退出了协程还没跑完 }看到的现象是什么都没打印程序就退出了。原因在于main函数里的线程结束整个进程退出协程还没来得及执行。解决办法用runBlocking包住mainfun main() runBlocking { launch { delay(100) println(Hello) } delay(200) // 给协程一点执行时间 }9.3 视图销毁后协程还在跑导致界面更新崩溃这是我踩得比较多的坑// 错误示范自己在Activity里建Scope做网络请求 val scope CoroutineScope(Dispatchers.Main) scope.launch { val result withContext(Dispatchers.IO) { apiService.getData() } // 如果Activity已销毁这里更新UI就崩了 textView.text result }正确做法// 正确示范用lifecycleScope自动跟随生命周期销毁 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { val result withContext(Dispatchers.IO) { apiService.getData() } textView.text result } } }9.4 一图理清遇到协程问题先检查这三点我在排查协程相关问题时通常按照固定顺序来它跑在哪个Dispatcher上是不是在主线程做了耗时操作它的取消是否被忽略了有没有在循环里加isActive检查异常跑到哪里去了launch还是async有没有捕获这三个问题搞清楚了80%的协程问题都能定位到具体位置。10. 常用技巧与避坑总结10.1 能用launch就不用runBlocking除非是在main函数或者测试代码里不要在生产环境用runBlocking。一方面它阻塞线程和协程的不阻塞哲学相悖另一方面Android主线程上调用runBlocking会导致ANR警告甚至崩溃。10.2 多个耗时操作并行用async串行用顺序await这里有一个容易踩的脑坑把async写在串行代码里其实并没有真正并行。// 错误示范这仍然是串行 val user async { fetchUser() }.await() val orders async { fetchOrders(user.id) }.await()await会挂起等待结果上面这行代码的await必须等fetchUser返回后才会执行下一步所以两个async实际是串行的。正确并行写法应该是先把两个async都启动再分别awaitval userDeferred async { fetchUser() } val orderDeferred async { fetchOrders(1) } val user userDeferred.await() val orders orderDeferred.await()10.3 长时间运行的任务用delay替代Thread.sleep在协程里绝对不要用Thread.sleep。它会阻塞整个线程让其他协程无法利用这个线程直接破坏了协程的优势。delay是挂起当前线程被释放给其他协程使用。这也解释了一个面试常见题Thread.sleep和delay的区别是什么Thread.sleep阻塞当前线程不释放线程资源delay挂起当前协程释放线程给其他任务用10.4 子协程异常别在launch里随随便便try-catchlaunch的异常是立即传播的里面的try-catch只能捕获当前协程体内的异常捕获不了子协程的异常。如果你想某个子协程挂了不影响主流程正确姿势是用SupervisorJob或supervisorScope而不是用try-catch硬包。11. 基础篇的补充协程不止会启动还要会组合启动协程是基础真正在项目里产生价值的是协程的组合能力。基础篇结尾我给出几个最实用的小组合建议。11.1 加载状态与协程最经典的三段式每次发生异步请求几乎都要处理加载中/成功/失败三种状态。用协程写起来很舒服sealed class UiStateout T { object Loading : UiStateNothing() data class SuccessT(val data: T) : UiStateT() data class Error(val message: String) : UiStateNothing() } class DataViewModel(private val repository: Repository) : ViewModel() { private val _uiState MutableStateFlowUiStateString(UiState.Loading) val uiState: StateFlowUiStateString _uiState fun loadData() { viewModelScope.launch { _uiState.value UiState.Loading try { val data repository.getData() _uiState.value UiState.Success(data) } catch (e: Exception) { _uiState.value UiState.Error(e.message ?: 未知错误) } } } }11.2 超时控制withTimeout网络请求经常需要设置超时协程提供了一个特别优雅的APIviewModelScope.launch { try { val result withTimeout(3000) { apiService.getData() } // 3秒内返回就走到这 } catch (e: TimeoutCancellationException) { // 超时处理 } }11.3 重试机制用循环包装很多网络请求要做失败重试协程写起来也很简洁suspend fun T retry(times: Int 3, block: suspend () - T): T { var current 0 while (true) { try { return block() } catch (e: Exception) { current if (current times) throw e delay(1000L * current) } } }这段代码来自我的实际封装可以放项目里直接用想更规范一点可以加上退避策略、最大重试次数等配置。基础篇讲到这里协程的骨架已经立住了。你在项目里写代码的时候只要按照我给的这几个模式去套基本不会出大问题。后续再研究Flow、Channel、select等高级玩法时有了这层底子理解速度会快很多。我建议你把文章里的每个代码块都敲一遍亲手跑通比看十篇文章都有用。协程这东西没上手之前觉得玄乎上手之后就离不开了。
返回列表