ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Kotlin协程实战:从入门到Android性能优化与架构实践

Kotlin协程实战:从入门到Android性能优化与架构实践 1. 先从线程切换说起协程解决的是Android开发的真实痛点1.1 回调嵌套不是代码问题是并发模型问题很多从Java转到Kotlin的Android开发第一次接触协程时都有一个共同的疑惑我已经会用Handler、RxJava、asyncTask了为什么还要再多学一套东西我先说一个真实场景。早几年做App一个典型的业务需求是登录后同时拉取用户信息和配置列表都成功后跳转首页失败则弹Toast。用原生回调写出来大概是这个画风api.login(userName, password, object : CallbackLoginResult { override fun onSuccess(result: LoginResult) { api.fetchUserInfo(object : CallbackUserInfo { override fun onSuccess(userInfo: UserInfo) { api.fetchConfig(object : CallbackConfig { override fun onSuccess(config: Config) { runOnUiThread { navigateToHome(userInfo, config) } } override fun onFailure(e: Exception) { showError(e) } }) } override fun onFailure(e: Exception) { showError(e) } }) } override fun onFailure(e: Exception) { showError(e) } })这个回调嵌套还只是三层真实业务里叠加权限判断、缓存读取、埋点上报十个八个回调叠起来完全正常。代码每缩进一层可读性就下降一个量级改需求的时候想死的心都有。但我必须说清楚一个关键认知嵌套本身只是表象真正的问题是并发模型没有跟上业务复杂度。回调接口是Java时代事件驱动模型下的产物它把所有异步逻辑拆成了碎片化的回调方法然后靠闭包把它们粘在一起。而协程做的事情是让异步代码重新回到顺序书写的形态——你写的代码是什么顺序执行的时序大概率就是什么顺序大脑不需要维护一个隐性状态机。1.2 协程不是线程池换皮它是编译器给异步代码做的手术协程被误读最深的一点就是有人把它简单理解为轻量级线程。这个类比方便入门但会带来灾难性的错误预期线程有独立的栈、有CPU调度、有上下文切换成本而协程在线程之上只是挂起和恢复一个线程上可以跑成千上万个协程。看一段最直觉的代码fun main() runBlocking { repeat(100_000) { launch { delay(1000L) println(task $it done) } } }这段代码用线程做大概率直接OOM换成协程可以在一个线程上平滑跑完十万个并发任务。原因在于delay是挂起函数它不会像Thread.sleep那样阻塞线程而是把协程的状态保存起来、让出线程时间到了再恢复执行。本质上Kotlin协程的关键是编译器将挂起函数改造成了状态机。launch启动一个协程时创建的是Continuation对象每次挂起点对应状态机里的一个状态。这块原理不用背出来面试但心里必须有这个图景否则后面遇到取消不生效挂起函数卡死这类问题你连排查方向都没有。2. 协程基础三件套suspend、withContext与Dispatchers的搭配逻辑2.1 suspend函数不是魔法它只是一个可挂起的函数声明新手最容易踩的第一个坑是以为给函数加suspend关键字函数就会自动在后台线程执行。完全错误。suspend只是声明这个函数可能挂起不等于这个函数一定切线程。// 这样写依然运行在主线程只是可以挂起而已 suspend fun loadFromCache(): String { delay(100) return cache }suspend真正的作用是限制调用环境只能在协程体内或者另一个suspend函数里调用。所以它的意义是向编译器承诺我可能在执行过程中让出线程。至于切不切线程要看内部有没有withContext(Dispatchers.IO)这类切调度器的操作。我在实际代码里见过不少团队犯一个效率错误把所有函数都无脑标成suspend包括纯计算、纯内存读写的函数。这不会报错但会让协程体多出不必要的状态机开销还会让调用链上的所有人被迫处于协程上下文里。规范做法是只有真正存在挂起点网络、数据库、文件IO、跨进程等耗时操作的函数才标suspend。2.2 Dispatchers选型Main、IO、Default用错了会出什么事调度器决定了协程跑在哪个线程池上。Android开发日常就三个Dispatcher跑在哪儿适合干什么不适合干什么Dispatchers.Main主线程更新UI、操作LiveData/Flow任何耗时计算、任何IODispatchers.IOIO线程池网络请求、文件读写、数据库高频CPU密集计算Dispatchers.DefaultCPU线程池解析JSON、列表排序、位图处理阻塞式IO操作这里有个具体的高频误用JSON解析。很多人习惯性把Gson().fromJson()塞进Dispatchers.IO但JSON解析本质是CPU密集任务应该交给Dispatchers.Default。IO和Default共用一个线程池但IO的线程数上限更高默认最大64Default上限是CPU核心数。把CPU密集任务扔进IO池反而可能因为线程竞争拖累真正需要IO的任务。另一个容易忽视的坑是withContext的调用代价。每次withContext切换调度器都涉及线程切换和上下文恢复。如果循环内部频繁调用性能损耗会被放大// 错误示范循环里每次都要切换线程 repeat(1000) { withContext(Dispatchers.IO) { // do something tiny } } // 正确做法一次切换批量处理 withContext(Dispatchers.IO) { repeat(1000) { /* do something tiny */ } }2.3 launch、async、runBlocking各管一摊别混着用launch返回Job用于发出去不管结果的任务async返回Deferred用于需要拿返回值的任务runBlocking是给非协程世界比如main函数入口、测试方法搭桥用的Android业务代码里永远不应该出现它。我见过有人把网络请求写进runBlocking理由是这样返回结果方便。这等于把主线程整个卡住完全背离了协程的意义。Cooperative配合这个原则说了很多遍但实际代码里总有人忘记所有挂起函数都要求调用方愿意配合挂起runBlocking强行不配合当场把线程冻结。真正实际项目里最常用的组合拳是scope.launch { val user async { repository.loadUser() } val config async { repository.loadConfig() } showPage(user.await(), config.await()) }这个写法实现了两个请求并发执行而代码看起来依然是顺序结构。async适合这种多个独立任务并行执行、汇总结果的场景。但如果任务之间存在依赖asyncawait就会退化成阻塞等待不如直接顺序执行来得清晰。3. ViewModel与生命周期绑定scope用不对省下的代码都会变成隐患3.1 viewModelScope和lifecycleScope到底应该怎么分工协程有个特性让它特别适合Android结构化并发。子协程的生命周期跟父协程绑定父协程被取消时所有子协程都会被递归取消。这意味着只要能选对scope你就可以让协程跟着页面生命周期自动销毁彻底告别内存泄漏。开发中最常用的两个官方scopeviewModelScope绑定ViewModel的onCleared()ViewModel销毁时自动取消。lifecycleScope绑定LifecycleOwnerActivity/Fragment走到onDestroy()时自动取消。分工逻辑一句话说清楚跟界面状态无关、只跟数据相关的任务用viewModelScope需要感知界面生命周期比如要在onStart恢复任务、onStop暂停任务的用lifecycleScope。举个实际例子首页加载用户信息就适合viewModelScope因为即使Activity旋转重建数据加载也不应该中断。而页面埋点上报、视频播放这类跟界面强关联的任务更适合lifecycleScope界面没了任务就该停。3.2 自定义CoroutineScope不要随手全局建scope有些团队习惯写一个全局对象object Global { val scope CoroutineScope(SupervisorJob() Dispatchers.Main) }任何地方都拿这个scope去launch任务跟任何页面都不绑定结果就是协程永不取消泄漏、崩溃、资源浪费接踵而至。如果一定要用自定义scope至少要做到两点持有Job引用在不需要时主动取消或者把scope绑到生命周期对象上。我自己在项目里更推荐一个折中方案在BaseViewModel里提供一个额外的scope绑定当前业务模块的缓存策略而不是全局单例。3.3 Job、SupervisorJob、CoroutineExceptionHandler三者的关系这是面试最喜欢的考点也是实际代码里最容易写错的地方。Job()和SupervisorJob()的核心区别在于异常传播策略Job子协程抛出未捕获异常时会取消父协程并级联取消兄弟协程。SupervisorJob每个子协程独立处理异常一个子协程失败不会影响其他子协程。看一个具体的例子// 用Job第一个子任务崩溃第二个子任务也被取消 val jobScope CoroutineScope(Job() Dispatchers.IO) jobScope.launch { throw RuntimeException(boom) } jobScope.launch { delay(1000) println(我不会被执行) } // 用SupervisorJob第一个子任务崩溃不影响第二个 val supervisorScope CoroutineScope(SupervisorJob() Dispatchers.IO) supervisorScope.launch { throw RuntimeException(boom) } supervisorScope.launch { delay(1000) println(我照样执行) }实际项目里绝大多数场景应该用SupervisorJob因为你通常希望一个请求失败不要拖垮整个页面。但要注意async配合SupervisorJob时await()处依然会抛出子任务里的异常这个规律和Job是一样的。关于CoroutineExceptionHandler我必须提前说一个反直觉的真相launch里可以通过handler捕获异常async里不行——async的异常要等await()时抛出。这个细节放后面第5节详细展开因为这正是handler不生效最常见的根因。4. Flow实操从网络请求到UI状态收集的完整数据链路4.1 Flow、StateFlow、SharedFlow的区别别再用错类型了协程解决了异步任务的组织问题但Android日常还有一个高频需求没有解决数据流。比如搜索框每次输入都触发请求、传感器持续上报数据、数据库监听表变化。这类持续产生多个值的场景就是Flow登场的时刻。Flow、StateFlow、SharedFlow三者的核心区别用大白话说Flow冷流每次collect都会重新执行flow块里的代码适合每次订阅都重新计算/请求的场景。StateFlow热流保存最新一个值新订阅者会立刻收到该值。跟LiveData很像但完全不受生命周期限制适合作为UI状态容器。SharedFlow热流不保存或者按配置缓存历史值多个订阅者共享同一份事件流适合事件广播场景。实际项目中最常见的错误是拿StateFlow当事件通道用。比如点击按钮后弹一个Toast如果用StateFlow页面旋转重建后新订阅者会重新收到旧值Toast被重复弹出。这种一次性事件应该用MutableSharedFlow...()配合replay 0。4.2 一条完整链路Retrofit返回Flow到UI收集现在很多网络库比如Retrofit的协程扩展已经支持直接返回Flow。一个典型的架构链路长这样// Repository层 fun fetchUser(id: String): FlowResultUser flow { emit(reloadUser(id)) }.flowOn(Dispatchers.IO) // ViewModel层 class UserViewModel(private val repo: UserRepository) : ViewModel() { private val _uiState MutableStateFlowUserUiState(UserUiState.Loading) val uiState: StateFlowUserUiState _uiState fun load(id: String) { viewModelScope.launch { repo.fetchUser(id) .map { result - result.toUiState() } .catch { e - UserUiState.Error(e.message) } .collect { state - _uiState.value state } } } } // Activity层 lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state - when (state) { is Loading - showLoading() is Success - showUser(state.user) is Error - showError(state.message) } } } }这个链路的精妙之处在于每一层的调度清晰fetchUser用flowOn(Dispatchers.IO)指定上游跑在IO线程collect里的UI更新永远在主线程。而repeatOnLifecycle(Lifecycle.State.STARTED)是官方推荐的收集时机页面退到后台时停止收集回到前台自动恢复不会浪费资源。这里要特别强调flowOn的含义它只影响上游操作符的执行线程不影响下游。比如map操作符写在flowOn之前还是之后执行线程完全不一样。很多人在这一步栽过跟头把耗时转换写在了flowOn之后然后UI仍然卡顿。4.3 操作符实际项目用得最多的四板斧Flow的操作符很多看着吓人但Android项目日常真正高频的我个人经验就四个map类型转换。网络返回的DTO转成UI模型。catch捕获上游异常转成数据或错误状态。flowOn指定上游执行线程。debounce防抖。搜索场景必备300ms以内不重复请求。combine合并多个Flow的最新值。拿搜索框举例典型的debouncedropsearchEditText.textChanges() .debounce(300) .distinctUntilChanged() .flatMapLatest { query - repo.search(query) } .catch { emit(SearchResult.Empty) } .collect { list - adapter.submitList(list) }flatMapLatest也是很多人刚开始不熟悉但极其好用的操作符它会在新值到来时取消旧的内部Flow并切换到新的Flow。搜索场景下如果用户快速输入了ko、kot、kotlin只有最后一次请求的结果会被保留前面慢返回的结果直接被丢弃。5. 错误处理与取消最容易翻车的两个隐藏雷区5.1 CoroutineExceptionHandler不生效的三种情况协程的异常处理比你想的复杂一截我先把结论列出来再讲原因。场景异常在哪儿处理常见误区launch内的未捕获异常CoroutineExceptionHandler以为handler一定能兜住async内部异常await()时抛出把handler传给async无效子协程异常取决于父Job类型Job会级联取消SupervisorJob独立传播try-catch包住launch该协程的异常可以被catch很多人以为在外部catch无效CoroutineExceptionHandler不生效的情况主要就三种async任务的异常不在handler里处理而是在await()时抛出。多个子协程并发时其中一个抛出异常会被handler接走但其他协程可能已经因为Job的级联取消机制被终止。handler设置的位置不对比如在SupervisorJob的下层又手动创建了新的Job作用域。实际项目里我更推荐一条铁律所有协程体里对可能失败的块显式try-catch或者用辅助函数统一包装而不是依赖全局handler。全局handler是用来兜底的最后一道防线不该作为主要的错误处理手段因为它拿不到异常发生的具体业务上下文排查问题非常困难。5.2 取消为什么不生效挂起点才是取消的关键协程的取消是协作式的不是强杀。Job.cancel()只是给协程发了一个取消信号协程必须在下一次检查取消时响应。挂起函数delay、withContext、Flow.collect内部都做了取消检查所以它们能响应取消。但纯CPU计算的代码块不会自动响应。随手写一个例子viewModelScope.launch { var sum 0L // 耗时循环不检查取消cancel之后依然继续跑 for (i in 1..1_000_000_000) { sum i } println(sum) }这个循环写在协程里但没有任何挂起点协程被取消时它根本感知不到。解决的方案有几个循环里加ensureActive()或者yield()或者把计算放进withContext(Dispatchers.Default)让挂起点强制插入。viewModelScope.launch { withContext(Dispatchers.Default) { for (i in 1..1_000_000_000) { ensureActive() // 每轮循环检查取消状态 sum i } } }另一个必须记住的细节CancellationException不应该被普通业务catch吞掉。很多人写try-catch时习惯性地catch所有异常结果把协程的取消信号也接住了导致协程在取消后继续执行下面的代码甚至出现Activity销毁了但请求还在回调的诡异问题。// 正确的兜底写法 try { repository.fetchData() } catch (e: CancellationException) { throw e // 取消信号必须继续向上抛 } catch (e: Exception) { handleError(e) }5.3 withContext与取消一个隐藏的资源泄漏陷阱用withContext访问外部资源时有个很容易忽略的问题协程被取消时withContext内部挂起的IO操作不会自动停止比如OkHttp的call还在发请求。这不算协程的锅但框架层面没有帮你做资源回收。正确的做法是让挂起操作本身具备取消感知能力。OkHttp的Kotlin扩展call.await()内部已经实现了取消时自动cancel()连接所以直接用没问题。但如果你用的是别的不支持取消的IO库就需要自己监听Job的取消suspend fun readData(input: InputStream): ByteArray withContext(Dispatchers.IO) { // 协程取消时关闭流 coroutineContext[Job]?.invokeOnCompletion { if (it ! null) { input.close() } } input.readBytes() }invokeOnCompletion的it参数如果是CancellationException说明协程是被取消的这时候做资源清理是常规操作。这个技巧在自研网络层、蓝牙通信、文件传输这类场景里特别有用。6. 协程测试与调试让异步代码可预测的最后一公里6.1 runTest的魔力虚拟时间让delay瞬间跳过协程代码难测试主要难在时间控制。一个简单的loading延迟、一个三秒超时在真实测试里要等真实时间测试跑起来又慢又不稳定。官方提供的kotlinx-coroutines-test库里的runTest直接解决了这个问题它使用虚拟时间所有delay都跳过真实等待。Test fun loadUser_should_update_state() runTest { val repo FakeUserRepository() val vm UserViewModel(repo) vm.load(1) // 虚拟时间里delay(1000) 直接跳过 assertEquals(UserUiState.Loading, vm.uiState.value) advanceTimeBy(1000L) assertIsUserUiState.Success(vm.uiState.value) }advanceTimeBy可以手动推进虚拟时钟配合runCurrent把当前任务排空。这套机制让测试代码可以精确控制时序完全不需要sleep去碰运气。不过这里有个很容易踩的坑runTest默认使用StandardTestDispatcher它不会立即执行协程体需要调用runCurrent()或者advanceUntilIdle()把任务跑完。很多新手会疑惑为什么我launch完变量没变其实就是忘了推进任务队列。6.2 让网络层支持注入Dispatcher依赖注入不只是为了解耦如果你在代码里到处硬编码Dispatchers.IO测试时就没法用虚拟时间控制。推荐的做法是给Repository或ViewModel提供Dispatcher依赖class UserViewModel( private val repo: UserRepository, private val dispatcher: CoroutineDispatcher Dispatchers.IO ) : ViewModel() { fun load(id: String) { viewModelScope.launch { withContext(dispatcher) { // 业务逻辑 } } } }生产环境不传默认IO测试环境注入StandardTestDispatcher一切尽在掌握。这个模式简单但极其好用我几乎所有项目都会在基类里预留这个口子。6.3 协程调试debug模式怎么开堆栈为什么还是看不懂Android Studio从北极狐版本开始就内置了协程调试器出现Coroutine一栏可以看每个协程的状态Active、Cancelled、Completed。开启方法很简单在Debug模式下的View Tool Windows里打开Debug切换到Coroutines标签页。但协程的堆栈确实比普通线程堆栈难读因为一个协程的执行栈分布在多个挂起点之间报错位置往往不是业务代码而是状态机生成的BaseContinuationImpl内部的resumeWith。我的实操经验是先看异常信息里的业务异常消息不要盯着堆栈第一行。用CoroutineName(具体的任务名)给协程命名日志里会有体现排查多协程问题会快很多。在关键挂起点前后打日志配合时间戳比读堆栈高效。viewModelScope.launch(CoroutineName(LoadUserTask) Dispatchers.Main) { Log.d(TAG, start) val user withContext(Dispatchers.IO) { repo.loadUser() } Log.d(TAG, loaded) }6.4 性能上的两个小建议不要随手launch到Main不要随手collect关于性能我想说两个实际观察。第一很多新手写代码时习惯先launch(Dispatchers.Main)再看情况切线程。这里背后有个隐藏成本每次withContext(Dispatchers.IO)切换都是两次线程切换Main到IOIO回Main。如果逻辑不需要操作UI干脆直接launch(Dispatchers.IO)减少一次不必要的切换。第二数据量大的流式结果collect时注意批量处理。比如从数据库监听一张表几千行数据逐条emit到UI主线程会明显卡顿。用buffer()操作符可以给上下游之间加缓冲区让上游不用等下游处理完再继续发射吞吐量提升明显或者用chunked()做批量聚合。dbDao.observeRecords() .buffer(100) .collect { record - adapter.append(record) }这块属于优化层面项目没出问题前不用急着加但一旦出现列表刷新卡顿优先怀疑的方向就是生产者-消费者速率不匹配buffer就是最直接的解药。回看我带过的几个项目协程从最初的看不懂源码就敢用到后来团队制定了统一的协程规范——所有网络请求必须走viewModelScope、所有异步任务必须有明确的异常处理、所有挂起函数必须标明线程模型项目的崩溃率确实肉眼可见地降了下来。协程这套东西API本身半个月就能上手真正值钱的是后面这些实践层面的分寸感什么时候用Job、什么时候用SupervisorJob什么时候用Flow、什么时候用StateFlow异常是捕获还是上抛。这些判断没有标准答案靠的是在真实项目里反复踩坑、复盘、再验证。希望这篇从入门到实践的内容能帮你少走一段我走过的弯路。
返回列表