ARTICLE DETAIL

资讯详情

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

Kotlin协程实战:从回调地狱到结构化并发

Kotlin协程实战:从回调地狱到结构化并发 1. 协程到底在解决什么问题1.1 从回调地狱说起做 Android 开发的朋友对回调地狱应该都不陌生。以前我们写网络请求从主线程发出去等结果回来再切回主线程更新 UI代码长这样api.fetchUserInfo(object : CallbackUser { override fun onSuccess(user: User) { runOnUiThread { tvName.text user.name api.fetchUserPosts(user.id, object : CallbackListPost { override fun onSuccess(posts: ListPost) { runOnUiThread { adapter.submitList(posts) } } override fun onFailure(e: Exception) { /* ... */ } }) } } override fun onFailure(e: Exception) { /* ... */ } })这还算简单的实际项目里如果后面再跟上图片加载、数据库缓存、多接口合并缩进能到十层八层。逻辑没错但读起来、改起来都极其痛苦。而且每一层都要手动处理异常、判断生命周期漏掉一个回调就可能导致崩溃或者内存泄漏。Kotlin 协程解决的就是这个问题。它让你用看起来像同步代码的写法完成实际上是异步的操作。同样是上面的场景用协程写出来是这样lifecycleScope.launch { val user api.fetchUserInfo() // 挂起不阻塞主线程 tvName.text user.name val posts api.fetchUserPosts(user.id) adapter.submitList(posts) }没有回调嵌套没有手动切线程代码顺序从上往下读就像在写同步逻辑。关键在于fetchUserInfo是一个挂起函数suspend function它执行到一半可以主动让出线程等耗时操作完成后再回来继续往下走。1.2 线程切换的痛在多线程编程里最烦的事情就是线程切换。在 Android 上子线程不能更新 UI主线程不能做耗时操作。于是我们被迫在各种线程之间来回切换稍不留神就出问题。我记得早期用 Java 写网络请求时经常是new Thread(...).start()一把梭开个子线程做耗时操作然后通过runOnUiThread切回主线程更新界面。代码看着不复杂但一旦并发请求多了线程怎么管理、生命周期怎么控制、怎么避免内存泄漏全是坑。协程对线程切换的抽象做得非常优雅。它把线程这个概念从业务代码中剥离出来你只需要声明我要在 IO 线程做这件事、我要在主线程更新 UI剩下的切换由协程调度器自动完成lifecycleScope.launch { val result withContext(Dispatchers.IO) { doHeavyWork() // 耗时操作扔到IO线程 } tvResult.text result // 自动回到主线程更新UI }协程并不是比线程更快它本质上还是跑在线程上的。它真正的价值是让线程的使用变得可控、可预测、可管理把开发者从繁琐的线程细节中解放出来。如果你之前接触过 C 的协程或者听说过 Go 的 goroutine会发现思路是类似的底层还是线程但上层用轻量级任务来调度从而支持大量的并发任务而不需要创建大量线程。Kotlin 协程在 JVM 上用了类似的思想每个协程是一个轻量级任务可以被挂起和恢复。2. 协程的核心概念用大白话讲清楚2.1 挂起函数不是阻塞是让路挂起函数是协程的基础。用suspend关键字标记的函数只能在协程或者其他挂起函数中被调用。打个比方挂起函数就像一个会暂停工作的员工。老板协程布置了一个任务员工干到一半发现需要等一个快递网络请求/IO操作他不需要傻傻地站在公司门口等快递员来而是先去做别的事情。等快递到了再回来继续干活。这个机制有一个重要特性挂起的时候它不会阻塞当前线程。线程可以去执行其他协程的任务。等条件满足了协程框架会安排它恢复执行。suspend fun fetchUserInfo(): User { return withContext(Dispatchers.IO) { // 模拟网络请求 delay(1000) User(Tom) } }看delay这个函数它也是挂起函数。它在协程里会让出线程而不是像Thread.sleep()那样把线程卡住。假设你在主线程启动了一个协程里面调用了delay(5000)主线程不会被阻塞界面依然是流畅的这跟你直接用Thread.sleep(5000)的效果完全是两回事。有个典型误区很多同学以为挂起函数只能在协程里调用所以协程的代码不可以在普通函数里用。准确地说挂起函数只能在协程里被调用这是调用者的限制。但你可以在普通函数里用runBlocking包一层比如测试代码里经常会这么干。不过生产环境要谨慎runBlocking会真实地阻塞当前线程它主要是给测试和主函数用的。2.2 CoroutineScope与结构化并发我刚开始学协程的时候最懵的就是各种作用域GlobalScope、lifecycleScope、viewModelScope、CoroutineScope名字长得像用起来又不太一样。先说CoroutineScope。它的核心作用是管理协程的生命周期。你在一个 Scope 里启动协程这个 Scope 负责跟踪所有由它启动的协程并且可以统一取消它们。GlobalScope是一个全局作用域它启动的协程生命周期跟整个应用一致没有任何自动取消机制。如果你在 Activity 里用GlobalScope.launch启动一个长时间运行的协程Activity 销毁了协程还在跑很容易造成内存泄漏。Google 官方明确不推荐在应用代码里用GlobalScope。lifecycleScope是 AndroidX Lifecycle 库提供的作用域它会跟随LifecycleOwner比如 Activity 或 Fragment的生命周期自动取消。页面销毁时协程自动取消不用你手动管。这是 Android 开发中最常用的作用域。class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { // 页面销毁时自动取消 } } }viewModelScope则是针对 ViewModel 的ViewModel 被清除时协程自动取消。因为协程是在 ViewModel 的onCleared()被调用的所以非常安全。这里要重点理解结构化并发这个概念。简单说就是协程的启动和结束是受 Scope 约束的协程之间有清晰的父子关系。父协程取消子协程也会跟着取消。这保证了我们不会出现协程泄漏——协程在运行但它的宿主已经不在了。fun testCoroutine() { val scope CoroutineScope(SupervisorJob() Dispatchers.Main) scope.launch { launch { // 子协程A } launch { // 子协程B } } }在这个例子里scope是顶层协程的父级两个launch是父子协程。如果父协程被取消A 和 B 都会被取消。如果 A 崩溃了根据 Job 类型的不同它可能会影响父协程和兄弟协程Job 默认是子协程取消/异常会向父级传递SupervisorJob则不会让子协程的错误影响到其他兄弟协程。这两个 Job 的差异是后面做结构设计时特别要注意的。2.3 Dispatcher谁在跑你的代码协程调度器Dispatcher决定了协程在哪个线程池或者哪个线程上运行。Kotlin 协程提供了几个内置的 DispatcherDispatchers.Main在 Android 主线程运行用于更新 UI。Dispatchers.IOIO 密集型任务比如网络请求、数据库读写、文件操作。Dispatchers.DefaultCPU 密集型任务比如大量计算、解析大 JSON。Dispatchers.Unconfined不指定线程恢复时可能跑到别的线程去一般不建议日常使用。常用的组合是外层在主线程启动协程内部用withContext(Dispatchers.IO)切到 IO 线程做耗时操作结束后自动回到主线程。这种写法简洁、可控而且能天然避免界面必须主线程更新的约束。lifecycleScope.launch { val data withContext(Dispatchers.IO) { repository.fetchData() } tvData.text data.toString() }注意看withContext返回结果后下面的代码自动在主线程执行。这不是魔法本质是协程调度器在挂起恢复时切换了线程。launch和withContext都接收一个 dispatcher 参数你可以在任何需要的地方切换线程只要代码结构清晰不会出现肉眼可见的线程乱七八糟的情况。有人会问IO 和 Default 到底有什么区别在 JVM 上Default的线程数默认等于 CPU 核心数IO的线程数默认上限是 64实际上会根据任务动态伸缩。IO 任务的延迟高但不会长时间占用 CPU适合用更多线程来等待Default 任务占 CPU 高线程多了反而频繁切换降低效率。所以二者分开是非常合理的。还有一个常见的疑问Dispatchers.Main 是不是只能在 Android 上用不是。Kotlin 官方维护了 common 模块对于非 Android 平台Main会被映射成对应的主线程派发器。比如在 Swing/JavaFX 项目里Main会被映射到对应的 UI 线程。如果你的项目没有 ui 线程的概念它可能等于Default。3. 上手实操从零写一个协程示例3.1 环境准备与依赖不管你是在 Android 项目还是在纯 Kotlin/JVM 项目里用协程都需要引入依赖。如果你用的是 Gradle版本信息大概是这样的注意版本号会更新以官方最新为准dependencies { // Kotlin 协程核心库 implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) // Android 平台支持库包含 Main 调度器 implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1) }Android 项目里一般还需要加上lifecycleScope/viewModelScope的依赖它来自androidx.lifecycle:lifecycle-runtime-ktx。如果用的是 Compose还有collectAsState之类的配合使用。实际开发中这些依赖一般已经由模板自带但如果自己在老项目里加注意版本别和 Kotlin 编译器版本不匹配否则会出现Module was compiled with an incompatible version of Kotlin这类错误后面问题排查部分我会细说。3.2 第一个协程launch与asynclaunch和async是用的最多的两个协程构建器。launch用于启动一个没有返回值的协程适合做个事情但不需要拿结果的场景比如更新 UI、写日志、发送埋点。async用于启动一个有返回值的协程返回的是DeferredT可以理解为未来会有结果的一个占位符。调用.await()会挂起等待结果。fun main() runBlocking { // launch 示例并行处理两个任务 launch { delay(1000) println(任务A完成) } launch { delay(500) println(任务B完成) } // 等所有launch里的协程执行完 delay(2000) // async 示例并发请求 val user async { fetchUser() } val posts async { fetchPosts() } println(User: ${user.await()}) println(Posts: ${posts.await()}) }如果你用launch来模拟拿结果一般会借助共享的外部变量但这样很难处理并发安全。async/await就能优雅地拿到返回值而且天然支持并发。当两个async并发执行时总耗时约等于最慢的那一个而不是两者之和。想确认它们是否真的并发可以在两个async里分别打印线程名val a async(Dispatchers.Default) { println(A 运行在 ${Thread.currentThread().name}) delay(1000) A } val b async(Dispatchers.Default) { println(B 运行在 ${Thread.currentThread().name}) delay(1000) B } println(${a.await()} ${b.await()})在同一个 Dispatcher 下它们大概率会分配到不同线程上执行。如果给两个async都用Dispatchers.Main那么它们会交替地占用主线程仍然算并发因为 delay 会挂起但在 Android 上不适合在主线程做耗时任务。3.3 取消与超时处理协程的取消是一个很容易被忽视但非常重要的点。首先取消需要协程配合。不是调用了cancel()协程就立刻消失了。如果一个协程正在做 CPU 密集计算或者它在普通代码里循环没有检查取消状态那cancel()并不会生效。只有协程在挂起点如delay、withContext、await时取消才能被响应。举个例子val job launch(Dispatchers.Default) { var i 0 while (i Int.MAX_VALUE) { i } println(循环结束) } delay(10) job.cancel()这段代码里while循环会一直跑完cancel()不会中断它因为循环体里没有任何挂起点协程不会响应取消。要让取消生效可以检查协程的取消状态val job launch(Dispatchers.Default) { var i 0 while (isActive) { i } }isActive是CoroutineScope的属性会在协程取消后变成 false。这样循环就能被中断。再看超时处理。在实际开发中我们常常要给请求设置超时时间协程提供了两个便捷的挂起函数withTimeout和withTimeoutOrNull。区别是前者超时后抛出TimeoutCancellationException后者超时后返回 null。val result withTimeoutOrNull(3000) { fetchData() // 如果3秒内没返回result 为 null } if (result null) { // 提示超时 }这个功能非常实用。以前用 Java 写超时需要Future.get(timeout)或者自己维护一个过期标记麻烦还不一定准确。协程里一个withTimeoutOrNull就能搞定。注意withTimeout超时抛出的TimeoutCancellationException虽然叫CancellationException的子类但它并不会导致崩溃前提是你把它当成正常的控制流来处理。如果你在try-catch里捕获了它并且没有重新抛出会破坏协程的结构化取消后续代码可能继续运行行为不可预测。所以处理协程异常时要小心尽量不要捕获CancellationException而什么也不做。4. 常见问题与排查技巧实录4.1 GlobalScope为什么不建议用很多新手刚开始写协程图省事就用GlobalScope.launch。网上很多教程也这么写看着确实简单GlobalScope.launch { // 做一些事情 }但实际开发中GlobalScope是应该被拉黑的。原因很简单它不遵循结构化并发协程的生命周期不受任何组件控制。你在 Activity 里用GlobalScope.launch发起一个网络请求用户退出了页面但如果请求还没结束协程是不会自动停下来的。等请求回来了它会继续执行后面的代码包括更新已经销毁的 UI这就是内存泄漏甚至崩溃的根源。官方文档有一段很直接的描述GlobalScopeis not recommended for general use. 所以在实际项目中Activity / Fragment 里用lifecycleScopeViewModel 里用viewModelScope自定义的长生命周期组件用自己创建的CoroutineScope(SupervisorJob() Dispatchers.IO)并负责在合适的时候cancel()这是一个所有权的问题。谁拥有生命周期谁就应该拥有协程的启动和取消权。把所有协程都挂到全局作用域下等于把所有权交给了整个进程非常危险。如果项目里一定要用GlobalScope比如做某种全局的单例工具最好确保协程内部使用了withContext(NonCancellable)或者有明确的结束条件并且不做任何 UI 操作。4.2 协程里的异常处理协程的异常处理跟普通 try-catch 很不一样很多坑都出在这里。第一个规律在协程内部捕获异常。用launch启动协程时协程体内的异常会被传递给父协程如果不处理就可能导致父协程范围内的其他协程一起被取消。所以在协程内做可能会失败的 IO 操作时要自己 try-catchlifecycleScope.launch { try { val data withContext(Dispatchers.IO) { repository.fetchData() } handleSuccess(data) } catch (e: Exception) { handleError(e) } }第二个规律async的异常在await()时抛出。一个async即使内部抛了异常如果没有调用await()异常可能被静默吞掉在某些版本/场景下。所以对async的调用要么立刻 await 并在外层 try-catch要么在async内部自己 try-catch千万不要默认它不会抛异常。第三个规律SupervisorJob和普通Job的区别在异常传播上体现得最明显。普通Job下子协程异常会导致父协程被取消进而让所有兄弟协程取消。SupervisorJob则允许每个子协程独立失败互不影响。前者适合一个失败则全部终止的批量任务后者适合每个页面组件独立运行的场景。val scope CoroutineScope(SupervisorJob() Dispatchers.Main) scope.launch { throw NullPointerException(A崩溃) } scope.launch { delay(1000) println(B不受影响) // 使用SupervisorJob时这行会打印 }如果你在用普通JobB 可能永远不会执行到你想要的地方。这个差异在大量并行协程的场景下尤其重要。第四个规律CoroutineExceptionHandler可以设置未捕获异常的兜底处理但它只能用在launch的上下文里不能用于async。而且SupervisorJob才能真正隔离异常CoroutineExceptionHandler不改变异常传播关系。我之前排查过一个线上问题两个launch并行执行一个处理图片压缩一个上传网络图片压缩偶发异常结果上传也被取消客户反馈图片上传总是中途失败。原因就是它们共享了同一个Job一旦一个协程抛异常整个作用域被取消。改成SupervisorJob后问题就消失了。4.3 与ViewModel、Compose的配合协程在 Android 上和ViewModel、Compose的配合是现在的主流用法。在ViewModel里最标准的写法如下class MyViewModel : ViewModel() { private val _uiState MutableStateFlowUiState(UiState.Loading) val uiState: StateFlowUiState _uiState fun loadData() { viewModelScope.launch { _uiState.value UiState.Loading try { val data withContext(Dispatchers.IO) { repository.fetchData() } _uiState.value UiState.Success(data) } catch (e: Exception) { _uiState.value UiState.Error(e.message) } } } }在 Compose 里可以用collectAsState()把StateFlow转成 Compose 的 StateComposable fun MyScreen(viewModel: MyViewModel) { val uiState by viewModel.uiState.collectAsState() when (val state uiState) { is UiState.Loading - LoadingView() is UiState.Success - DataView(state.data) is UiState.Error - ErrorView(state.message) } }viewModelScope的协程在 ViewModel 清除时自动取消。MutableStateFlow又是线程安全的流可以在子线程里更新UI 层会自动感知。这一套组合拳非常稳也是当前 Jetpack 生态推荐的架构方式。如果你看到依赖里报kotlinx-coroutines-core和kotlinx-coroutines-android版本不统一或者是 Kotlin 编译器版本与协程库编译时的 Kotlin 版本不兼容大概率会报Module was compiled with an incompatible version of Kotlin. The metadata version is ...这种问题的排查思路先检查 Kotlin 插件版本和协程库版本是否匹配可以通过查看官方 BOM 或者 GitHub 上的版本映射来确认。最简单的办法是统一用最新稳定版的协程库并保持 Kotlin Gradle 插件版本也更新到对应版本。如果项目用的是旧版本 Kotlin比如 1.6硬上协程 1.8.1 就很容易踩到不兼容的坑。必要时把依赖降到与 Kotlin 版本相匹配的旧版协程或者反过来升级 Kotlin。一般的建议是升级而非降级因为新版本修复了大量问题。另外Gradle 版本本身也可能影响构建。有些老项目用了比较旧的 Gradle 版本碰到新依赖时会报各种Unsupported class file major version的错误这种要优先检查 JDK 和 Gradle 版本而不是去纠结协程代码本身。5. 实际项目中的协程使用心得5.1 用协程替代 RxJava 的迁移经验我之前维护过一个重度使用 RxJava 的旧项目后来逐步往协程迁移。迁移过程中最大的体会是协程的心智负担比 RxJava 低很多。RxJava 的学习曲线非常陡峭Observable、Flowable、Single、Completable、Maybe还有背压、线程调度、操作符链……每一步都有概念要理解。而协程的核心抽象只有几个挂起函数、Scope、Dispatcher、Job。对于团队协作来说协程的上手成本低得多代码可读性也高很多。迁移时并不需要一次性全改完。一个比较稳妥的路径是先把网络层的数据源 Promise/回调改成挂起函数然后在 ViewModel 里把业务逻辑从Observable链改成viewModelScope.launch;再把数据库、缓存等异步操作逐步替换最后把 UI 数据流换成Flow或StateFlow。不过要注意谨慎使用runBlocking。在迁移的时候有些人碰到在普通函数里调挂起函数的问题就直接用runBlocking包一层。这在工作线程里问题不大但如果在主线程里用会卡住 UI严重时引发 ANR。它只应该出现在测试代码、main函数入口或者极其特殊的非 UI 环境。5.2 父子协程与任务组的使用在某些场景下你需要并发执行多个同类型的子任务并且等待它们全部完成。协程里可以用coroutineScope来创建一个协程作用域作为父级suspend fun fetchAllPages(): ListPage coroutineScope { val deferreds (1..10).map { page - async(Dispatchers.IO) { api.fetchPage(page) } } deferreds.awaitAll() }这里coroutineScope会等待所有子协程完成任何一个子协程抛异常整个作用域会失败并会取消其他还没完成的子协程。这个语义在批量请求场景下非常合适一个失败了就别浪费时间等其他的了。如果希望部分失败不影响整体可以使用supervisorScopesuspend fun fetchAllPagesFaultTolerant(): ListPage? supervisorScope { val deferreds (1..10).map { page - async(Dispatchers.IO) { try { api.fetchPage(page) } catch (e: Exception) { null } } } deferreds.awaitAll() }两种 scope 的取舍就是整体一致性和部分可用性的取舍。日常开发中批量上报日志、批量拉取首页数据、并发下载图片用哪个取决于业务对异常的容忍度。5.3 从会用到用好很多人学协程卡在看懂了但还是不会写的阶段。我觉得最有效的学习方式是找一个真实的小需求强制自己用协程重新实现一遍。比如做一个搜索联想词的功能用户输入关键词从网络获取联想词列表。你可以先不带防抖地写一版然后在里面加入debounce逻辑。协程里实现防抖非常方便private val searchQuery MutableStateFlow() init { viewModelScope.launch { searchQuery .debounce(300) // 防抖 .filter { it.isNotBlank() } .distinctUntilChanged() .mapLatest { query - withContext(Dispatchers.IO) { api.search(query) } } .collect { results - _uiState.value UiState.Success(results) } } }Flow里自带的debounce、distinctUntilChanged、mapLatest都是协程库提供的操作符。mapLatest会取消前一个未完成的任务再执行新的正好能避免旧请求覆盖新请求的问题。这种组合拳用传统回调方式实现需要写很多额外标记和逻辑用协程就是几行的事。再比如重复点击按钮只让最后一次生效用Job可以快速实现private var loadJob: Job? null fun onButtonClick() { loadJob?.cancel() // 取消上一次任务 loadJob viewModelScope.launch { val data withContext(Dispatchers.IO) { repository.fetchData() } handleData(data) } }这个模式在业务里非常常用快速点击、条件筛选、下拉刷新都可以用这种先取消再启动的方式防止过期的异步结果影响 UI。5.4 协程的性能与资源边界协程并不是随便创建几百上千个都没关系的虽然它比线程轻量但也不是零成本。每个协程在挂起时需要保存状态调度时也有一定的内存开销。如果在一个大列表里对每个 item 都launch一个协程同样需要考虑是否真的需要那么多并发。Dispatchers.IO默认的线程池上限是 64如果你一次性启动几百个协程做 IO 操作它们会排队等待线程资源并不代表并发飞快。我做过的实验在主线程上启动 10 万个协程每个只是打印一句话内存占用比创建 10 万个线程小得多如果把每个协程都替换成Thread大概率直接 OOM。这正是协程适合高并发任务的原因。但高并发不等于无限并发仍然需要对任务量有清晰认知。另外要注意的是在线程池中阻塞线程的行为要尽量避免。Dispatchers.IO的线程数是有限的如果某个协程内部用了Thread.sleep、CountDownLatch.await()或者阻塞式 IO它会占用线程一段时间导致其他任务排队。所以即使你在协程里也要优先使用协程友好的挂起 API比如delay不要用Thread.sleep。如果调用了第三方同步库无法避免阻塞式 IO尽量通过withContext(Dispatchers.IO)隔离并控制并发数不然很容易把 IO 线程池打满。5.5 如果只能记住三句话总结下来我对协程的理解可以用三句话来概括第一协程解决的是异步代码的写法问题让你把回调式的向后传递变成顺序式的从上往下读同时保证线程安全。第二协程解决的是异步任务的生命周期问题通过结构化并发把协程的启动、取消、异常传播统一管理不给内存泄漏留机会。第三协程解决的是并发任务的调度问题通过 Dispatcher 将开多少线程、在哪执行、如何切回这类底层细节沉淀为框架能力让业务开发聚焦在做什么而不是怎么做。如果你刚开始学协程不需要一次性把所有概念都弄懂。先从lifecycleScope.launch写第一个网络请求然后逐步引入async、withContext、Flow、StateFlow。跟着真实业务练几轮很快就能从看得懂变成写得出。等你在项目里踩过几次协程被静默取消、全局作用域导致泄漏的坑之后对协程的理解就会更进一步。我在实际项目里见过太多代码能跑但不知道为什么会这样的协程写法了。希望这篇内容能帮你把协程的核心逻辑串起来知其然也知其所以然。如果你正在搭建新项目的异步架构或者正在把老代码往协程迁移建议先把SupervisorJob和StateFlow这两个词的实现原理吃透它们基本决定了整个异步层的地基稳不稳。
返回列表