Kotlin 协程:像写同步代码一样写异步逻辑
前言:
很多 Android/Java 开发者初学 Kotlin 协程时,往往会被 “轻量级线程”、“非阻塞式挂起”、CoroutineContext、Dispatcher、Job等一堆概念劝退。
本文旨在剥离复杂的实现细节,用最直观的**“餐厅服务员"比喻和"对比法”**,带你深入浅出地理解 Kotlin 协程,并掌握其核心用法。
1. 为什么要用协程?(The Why)
在没有协程之前,处理异步任务(比如网络请求、读写数据库)通常有两种方式:
- Thread(线程):直接
new Thread()。- 缺点:线程是昂贵的资源,创建和销毁开销大,线程切换消耗 CPU。且代码难以管理(Callback Hell 的变种)。
- Callback(回调):像 Java 的
Swing或 Android 的Handler,或者 Retrofit 的enqueue。- 缺点:回调地狱 (Callback Hell)。当一个请求依赖另一个请求的结果时,代码会变成著名的 “波动拳” 形状,难以维护和阅读。
Kotlin 协程的核心价值在于:用同步的代码结构,写异步的逻辑。
直观对比
假设我们要:
- 登录 (Login)
- 获取用户信息 (Get User Info)
- 显示用户信息 (Show User)
回调写法 (Callback Hell):
funloginAndShowUser(){api.login("username","password",object:Callback{overridefunonSuccess(token:String){// 嵌套 1api.getUserInfo(token,object:Callback{overridefunonSuccess(user:User){// 嵌套 2runOnUiThread{showUser(user)}}overridefunonError(e:Exception){handleError(e)}})}overridefunonError(e:Exception){handleError(e)}})}协程写法 (Coroutines):
// 看起来像同步代码,但实际上是异步的!funloginAndShowUser()=viewModelScope.launch{try{valtoken=api.login("username","password")// 挂起,不阻塞valuser=api.getUserInfo(token)// 等上面执行完才执行这行showUser(user)// 自动切回主线程更新 UI}catch(e:Exception){handleError(e)// 统一的错误处理}}2. 核心概念:餐厅服务员的比喻 (The Concept)
为了理解“挂起 (Suspend)”和“阻塞 (Block)”的区别,我们想象一家餐厅。
- 线程 (Thread)=服务员
- 任务 (Task)=客人点的菜
传统的阻塞式 (Blocking)
客人点了一道需要 10 分钟做的菜。
服务员 (线程)把单子给厨房后,就傻站在厨房门口等,直到菜做好才端给客人。
在这 10 分钟里,这个服务员干不了别的事(比如服务其他客人),这就是阻塞。
如果要服务更多客人,老板只能雇佣更多服务员(创建更多线程),成本很高。
协程的非阻塞式挂起 (Non-blocking Suspend)
客人点了一道需要 10 分钟做的菜。
服务员 (线程)把单子给厨房,贴上一张便利贴 (Continuation),写着:“菜做好了叫我,我再把它端给 3 号桌”。
然后服务员立刻转身去服务其他桌的客人。
这就是挂起。服务员(线程)没有被卡住,他很忙,一直在干活。
当菜做好了,厨房按铃,随便哪个空闲的服务员(或者原来的那个)看到便利贴,把菜端给客人。
这就是恢复 (Resume)。
结论:协程让我们用极少的线程(服务员),处理了大量的并发任务。
3. 怎么用?(The How)
使用协程通常涉及三个要素:Scope (范围)、Suspend Function (挂起函数)、Builder (构建器)。
3.1 启动协程:Builder & Scope
要在普通代码中进入"协程世界",你需要一个构建器。
launch:射后不理 (Fire-and-forget)。- 比喻:像发射一枚导弹,发射出去就不管了。
- 返回值:返回一个
Job对象,你可以用它来取消任务。 - 场景:不需要返回值的任务,比如"更新缓存"、“写日志”。
async:有去有回 (Promise)。- 比喻:像派出一个侦察兵,你期待他带回情报。
- 返回值:返回一个
Deferred<T>(可以理解为 Java 的 Future)。你需要调用.await()来获取最终结果。 - 场景:需要返回值的任务,比如"两个接口并发请求,拿到结果后再合并显示"。
且必须在一个CoroutineScope(协程作用域)下启动。
// Android 中常用的 ScopeviewModelScope.launch{// 这里是协程体}lifecycleScope.launch{// 这里是协程体}🧐 灵魂拷问:这些变量是从哪冒出来的?
viewModelScope/lifecycleScope:它们不是 Kotlin 语言自带的,而是 Android Jetpack 库提供的扩展属性。withContext:这是协程核心库提供的标准挂起函数。如果你好奇源码实现(选读):
以viewModelScope为例,Google 其实就是利用 Kotlin 的扩展属性,给ViewModel类"外挂"了一个属性。// 简化版源码逻辑publicvalViewModel.viewModelScope:CoroutineScopeget(){// 1. 尝试从 tags 里取出一个已经存在的 scopevalscope:CoroutineScope?=this.getTag(JOB_KEY)if(scope!=null){returnscope}// 2. 如果没有,就创建一个新的,并且绑定到主线程valnewScope=CloseableCoroutineScope(SupervisorJob()+Dispatchers.Main.immediate)// 3. 保存起来,下次直接用setTagIfAbsent(JOB_KEY,newScope)returnnewScope}这里的
CloseableCoroutineScope会监听ViewModel的onCleared()方法,一旦 ViewModel 销毁,它就会自动执行cancel(),取消所有子协程。这就是为什么用它不会内存泄露的原因。
3.2 魔法关键字:suspend
如果一个函数需要耗时(比如网络请求、IO),或者需要调用其他挂起函数,它就必须被标记为suspend。
// 定义一个挂起函数suspendfunfetchDocs():String{// 模拟耗时,delay 也是一个 suspend 函数delay(1000)return"Docs Content"}规则:suspend函数只能在协程体或另一个suspend函数中调用。
3.2.1 什么时候需要加suspend?(代码详解)
很多同学容易搞混,我们直接看三个场景:
场景一:不需要suspend
如果你只是想"启动"一个协程,就像按下一个开关,你的函数不需要挂起。
// ✅ 正确:普通函数也能启动协程funonClick(){// launch 是一个普通函数,它"射后不理",瞬间就返回了// 它只是把任务扔进了线程池,并没有让 onClick 函数暂停viewModelScope.launch{// 这里面才是协程世界delay(1000)// 这里可以调用挂起函数println("任务完成")}// onClick 函数会立刻执行到这里,不会等待上面的 delay}场景二:必须加suspend
如果你想让你的函数"包含"耗时操作,并且让调用者"等待"它完成,必须加suspend。
// ❌ 错误:普通函数不能调用 delayfunloadData(){delay(1000)// 编译报错!普通函数不懂怎么"暂停"}// ✅ 正确:加上 suspend,赋予它暂停的能力suspendfunloadData(){delay(1000)// 正确!println("数据加载完成")}场景三:常见的误区写法
不要为了用launch而加suspend。
// ❓ 疑惑:这里加 suspend 有用吗?suspendfunwrongUsage(){viewModelScope.launch{delay(1000)}}解析:虽然不报错,但这里的suspend是多余的(甚至是有害的)。
launch是异步的,它会立刻返回。wrongUsage函数执行时,启动了协程就立刻结束了,它并没有真正挂起等待那个 1 秒的任务。- 如果你希望
wrongUsage等待任务完成,应该用coroutineScope(见下文结构化并发) 或者直接把逻辑写在suspend函数里,而不是套一层launch。
一句话总结:
launch=我要派个人去干活(我是老板,我派完活就走,我不等)。suspend=我自己要干个耗时的活(我就是那个干活的人,我得暂停手头别的事,专心干这个,干完才能继续)。
3.3 线程调度:Dispatchers
协程虽然不绑定特定线程,但它需要运行在线程上。Dispatchers决定了协程在哪个线程池运行。
| Dispatcher | 场景 | 对应线程 |
|---|---|---|
Dispatchers.Main | UI 操作 | Android 主线程 (UI Thread) |
Dispatchers.IO | 读写文件、网络请求、数据库 | 针对 IO 优化的线程池 (数量较多) |
Dispatchers.Default | 复杂计算、算法、JSON 解析 | CPU 密集型线程池 (数量等于 CPU 核心数) |
Dispatchers.Unconfined | 不限制 | 也就是当前在哪里就在哪里执行(极少使用) |
实战模式:
通常我们在主线程启动协程,遇到耗时操作时,通过withContext切换线程。
funloadData()=viewModelScope.launch(Dispatchers.Main){// 1. 在主线程启动showLoading()// UI 操作valresult=withContext(Dispatchers.IO){// 2. 切换到 IO 线程执行耗时操作// 这里的代码在 IO 线程运行api.getData()}// 执行完自动切回主线程hideLoading()// 3. 回到主线程更新 UIupdateUI(result)}4. 进阶:结构化并发 (Structured Concurrency)
这是 Kotlin 协程最优雅的设计之一。简单来说:父亲等孩子,孩子听父亲。
- 父亲等孩子:父协程会等待所有子协程完成后,自己才算完成。
- 孩子听父亲:如果父协程被取消(比如用户退出了界面),所有子协程会自动取消,不会造成资源泄露。
示例:并发加载两个接口
suspendfunloadUserData()=coroutineScope{// 创建一个新的作用域// async 启动子协程valdeferredAvatar=async(Dispatchers.IO){api.getAvatar()}valdeferredInfo=async(Dispatchers.IO){api.getInfo()}// 等待两个都完成valavatar=deferredAvatar.await()valinfo=deferredInfo.await()User(avatar,info)}如果loadUserData在执行过程中,外部 Scope 被取消了(比如用户退出了界面),getAvatar和getInfo的请求也会被立即取消。
4.1 自动取消机制 (Cancellation)
为什么说"孩子听父亲"?
当父协程(loadUserData所在的 Scope)被取消时,它会向所有子协程(async启动的任务)发送一个取消信号 (CancellationException)。
代码演示:
valjob=viewModelScope.launch{try{loadUserData()}catch(e:CancellationException){println("任务被取消了!")}}// 用户点击了返回键,ViewModel 销毁,自动调用 job.cancel()// 或者手动调用:job.cancel()发生了什么?
job.cancel()被调用。loadUserData收到取消信号。loadUserData里的两个async任务也会收到取消信号,立即停止网络请求,抛出异常并结束。- 资源被安全释放,不会有"僵尸任务"在后台偷偷跑。
5. 常见误区与最佳实践
❌ 误区 1:滥用GlobalScope
GlobalScope是全局的,它的生命周期伴随整个 App。
- 坏处:如果在 Activity 中用
GlobalScope.launch请求网络,Activity 销毁了,请求还在跑,回来更新 UI 就会 Crash 或泄露内存。 - 修正:在 Android 中总是使用
lifecycleScope或viewModelScope。
🧐 答疑:lifecycleScope和viewModelScope到底怎么用?
很多同学会有疑问:为什么在 Activity/Fragment 里能直接用lifecycleScope,在 ViewModel 里能直接用viewModelScope?需不需要我自己 new 一个对象?
答案是:不需要你自己 new,直接用!
它们是 Android 官方库 (Jetpack KTX) 帮你预置好的扩展属性。只要你的项目引入了对应的 KTX 依赖,它们就自动出现在你的 Activity/Fragment/ViewModel 实例里了。
1. 什么时候用viewModelScope?
- 场景:绝大多数业务逻辑(网络请求、数据库读写、复杂计算)。
- 理由:
ViewModel的生命周期比Activity长(屏幕旋转时 ViewModel 不会销毁)。- 只要 ViewModel 还活着,协程就活着。
- 当页面彻底关闭(
onCleared)时,viewModelScope会自动取消所有还在跑的任务。
- 代码位置:写在
ViewModel类里面。
classMyViewModel:ViewModel(){funloadData(){// 直接用!不用 new!viewModelScope.launch{valdata=repo.getData()// ...}}}2. 什么时候用lifecycleScope?
- 场景:与 UI 控件强相关的操作(比如监听 UI 事件、启动动画、或者在 Activity/Fragment 中临时发起的一个短任务)。
- 理由:它的生命周期绑定的是
Activity或Fragment的生命周期。- 当
Activity.onDestroy()执行时,lifecycleScope自动取消。
- 当
- 代码位置:写在
Activity或Fragment类里面。
classMyActivity:AppCompatActivity(){overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)// 直接用!不用 new!lifecycleScope.launch{// 比如:监听一个 Flow 数据流myFlow.collect{value->updateUI(value)}}}}总结口诀:
- 业务逻辑、数据请求➡️ 找
ViewModel,用viewModelScope。 - UI 交互、界面刷新➡️ 找
Activity/Fragment,用lifecycleScope。
❌ 误区 2:Thread.sleepvsdelay
Thread.sleep(1000):阻塞。服务员傻站着 10 分钟,谁也别想用他。delay(1000):挂起。服务员定了个闹钟,转头去干别的了。- 修正:在协程中永远只用
delay。
✅ 最佳实践:将Dispatcher作为依赖注入
不要在 Repository 或 ViewModel 中把Dispatchers.IO写死,这样不好做单元测试。
// 推荐写法classUserRepository(privatevalapi:Api,privatevalioDispatcher:CoroutineDispatcher=Dispatchers.IO// 可注入){suspendfungetUser()=withContext(ioDispatcher){api.getUser()}}6. ⚔️ 实战小测验:你能过几关?
感觉还是有点云里雾里?来做几道题检验一下!
题目 1:真假挂起
下面的代码能编译通过吗?如果不能,为什么?
funloadUser(){delay(1000)// 👈 这里有问题吗?println("User loaded")}题目 2:谁是卧底
我在ViewModel里写了一个网络请求,请问下面哪个写法是最推荐的?
A.GlobalScope.launch { ... }
B.thread { ... }
C.viewModelScope.launch { ... }
D.runBlocking { ... }
题目 3:线程迷踪
下面的代码中,更新 UI 的操作(textView.text = ...)运行在哪个线程?
// 假设当前在主线程启动viewModelScope.launch{// 默认运行在 Main 线程valdata=withContext(Dispatchers.IO){// 模拟耗时操作"Result"}textView.text=data// 👈 这行代码在哪个线程?}题目 4:生命周期大考验
如果在Activity的onCreate里这样写:
lifecycleScope.launch{delay(5000)// 挂起 5 秒Log.d("Test","Finished")}如果在 2 秒的时候,用户把 Activity 关闭了(Destroyed),请问 5 秒后 “Finished” 还会打印吗?
🕵️ 答案与解析
不能编译。
- 解析:
delay是一个 suspend 函数。规则:suspend 函数只能在协程体内或另一个 suspend 函数中调用。loadUser只是一个普通函数,没有suspend关键字,也没在launch块里。 - 通俗理解:这就好比你试图在普通函数里直接"暂停",编译器不知道该怎么暂停,必须加上
suspend关键字告诉编译器:“注意,我要干耗时操作了”。
- 解析:
选 C (viewModelScope)。
- 解析:
- C: 正解。它是 Android 官方专门为 ViewModel 设计的,能感知生命周期,ViewModel 销毁时自动取消协程,省心且安全!
- A:
GlobalScope容易内存泄露。 - B:
thread是传统线程,没法自动切回主线程,也不受 ViewModel 生命周期管理。 - D:
runBlocking会阻塞主线程,导致 App 卡死。
- 解析:
主线程 (Main)。
- 解析:
withContext是一个挂起函数。它执行完Dispatchers.IO里的代码后,会自动把线程切回之前的线程(这里是viewModelScope默认的 Main 线程)。这就是协程"神奇"的地方!
- 解析:
不会打印。
- 解析:这就是结构化并发的好处!
lifecycleScope绑定了 Activity 的生命周期。Activity 销毁时,Scope 自动取消,里面所有挂起的协程(哪怕正在 delay)也会被立即取消,后续代码不会执行。
- 解析:这就是结构化并发的好处!
总结
Kotlin 协程并没有那么神秘,它就是一套更优雅的线程封装框架。
- 化繁为简:用顺序的逻辑写异步代码,消灭回调地狱。
- 挂起不阻塞:利用
suspend机制,让线程利用率最大化(服务员比喻)。 - 结构化并发:自动管理生命周期,避免内存泄露。
记住三个词:Scope(管生杀),Dispatcher(管干活的地方),Suspend(管暂停与恢复)。掌握了这三个,你就掌握了协程的 80%。