news 2026/4/15 15:17:13

Kotlin 协程:像写同步代码一样写异步逻辑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Kotlin 协程:像写同步代码一样写异步逻辑

Kotlin 协程:像写同步代码一样写异步逻辑

前言
很多 Android/Java 开发者初学 Kotlin 协程时,往往会被 “轻量级线程”、“非阻塞式挂起”、CoroutineContextDispatcherJob等一堆概念劝退。
本文旨在剥离复杂的实现细节,用最直观的**“餐厅服务员"比喻和"对比法”**,带你深入浅出地理解 Kotlin 协程,并掌握其核心用法。

1. 为什么要用协程?(The Why)

在没有协程之前,处理异步任务(比如网络请求、读写数据库)通常有两种方式:

  1. Thread(线程):直接new Thread()
    • 缺点:线程是昂贵的资源,创建和销毁开销大,线程切换消耗 CPU。且代码难以管理(Callback Hell 的变种)。
  2. Callback(回调):像 Java 的Swing或 Android 的Handler,或者 Retrofit 的enqueue
    • 缺点回调地狱 (Callback Hell)。当一个请求依赖另一个请求的结果时,代码会变成著名的 “波动拳” 形状,难以维护和阅读。

Kotlin 协程的核心价值在于:用同步的代码结构,写异步的逻辑。

直观对比

假设我们要:

  1. 登录 (Login)
  2. 获取用户信息 (Get User Info)
  3. 显示用户信息 (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会监听ViewModelonCleared()方法,一旦 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.MainUI 操作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 协程最优雅的设计之一。简单来说:父亲等孩子,孩子听父亲。

  1. 父亲等孩子:父协程会等待所有子协程完成后,自己才算完成。
  2. 孩子听父亲:如果父协程被取消(比如用户退出了界面),所有子协程会自动取消,不会造成资源泄露。

示例:并发加载两个接口

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 被取消了(比如用户退出了界面),getAvatargetInfo的请求也会被立即取消。

4.1 自动取消机制 (Cancellation)

为什么说"孩子听父亲"?
当父协程(loadUserData所在的 Scope)被取消时,它会向所有子协程(async启动的任务)发送一个取消信号 (CancellationException)。

代码演示:

valjob=viewModelScope.launch{try{loadUserData()}catch(e:CancellationException){println("任务被取消了!")}}// 用户点击了返回键,ViewModel 销毁,自动调用 job.cancel()// 或者手动调用:job.cancel()

发生了什么?

  1. job.cancel()被调用。
  2. loadUserData收到取消信号。
  3. loadUserData里的两个async任务也会收到取消信号,立即停止网络请求,抛出异常并结束。
  4. 资源被安全释放,不会有"僵尸任务"在后台偷偷跑。

5. 常见误区与最佳实践

❌ 误区 1:滥用GlobalScope

GlobalScope是全局的,它的生命周期伴随整个 App。

  • 坏处:如果在 Activity 中用GlobalScope.launch请求网络,Activity 销毁了,请求还在跑,回来更新 UI 就会 Crash 或泄露内存。
  • 修正:在 Android 中总是使用lifecycleScopeviewModelScope

🧐 答疑:lifecycleScopeviewModelScope到底怎么用?

很多同学会有疑问:为什么在 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 中临时发起的一个短任务)。
  • 理由:它的生命周期绑定的是ActivityFragment的生命周期。
    • Activity.onDestroy()执行时,lifecycleScope自动取消。
  • 代码位置:写在ActivityFragment类里面。
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:生命周期大考验
如果在ActivityonCreate里这样写:

lifecycleScope.launch{delay(5000)// 挂起 5 秒Log.d("Test","Finished")}

如果在 2 秒的时候,用户把 Activity 关闭了(Destroyed),请问 5 秒后 “Finished” 还会打印吗?

🕵️ 答案与解析

  1. 不能编译

    • 解析delay是一个 suspend 函数。规则:suspend 函数只能在协程体内或另一个 suspend 函数中调用。loadUser只是一个普通函数,没有suspend关键字,也没在launch块里。
    • 通俗理解:这就好比你试图在普通函数里直接"暂停",编译器不知道该怎么暂停,必须加上suspend关键字告诉编译器:“注意,我要干耗时操作了”。
  2. 选 C (viewModelScope)

    • 解析
      • C: 正解。它是 Android 官方专门为 ViewModel 设计的,能感知生命周期,ViewModel 销毁时自动取消协程,省心且安全
      • A:GlobalScope容易内存泄露。
      • B:thread是传统线程,没法自动切回主线程,也不受 ViewModel 生命周期管理。
      • D:runBlocking会阻塞主线程,导致 App 卡死。
  3. 主线程 (Main)

    • 解析withContext是一个挂起函数。它执行完Dispatchers.IO里的代码后,会自动把线程切回之前的线程(这里是viewModelScope默认的 Main 线程)。这就是协程"神奇"的地方!
  4. 不会打印

    • 解析:这就是结构化并发的好处!lifecycleScope绑定了 Activity 的生命周期。Activity 销毁时,Scope 自动取消,里面所有挂起的协程(哪怕正在 delay)也会被立即取消,后续代码不会执行。

总结

Kotlin 协程并没有那么神秘,它就是一套更优雅的线程封装框架

  1. 化繁为简:用顺序的逻辑写异步代码,消灭回调地狱。
  2. 挂起不阻塞:利用suspend机制,让线程利用率最大化(服务员比喻)。
  3. 结构化并发:自动管理生命周期,避免内存泄露。

记住三个词:Scope(管生杀),Dispatcher(管干活的地方),Suspend(管暂停与恢复)。掌握了这三个,你就掌握了协程的 80%。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/10 22:44:34

HY-MT1.5-1.8B多引擎翻译对比评测

HY-MT1.5-1.8B多引擎翻译对比评测 1. 选型背景与评测目标 随着全球化进程的加速&#xff0c;高质量、低延迟的机器翻译需求在跨语言交流、内容本地化和实时通信等场景中日益增长。传统的云端大模型虽然具备较强的翻译能力&#xff0c;但在边缘设备部署、响应速度和隐私保护方…

作者头像 李华
网站建设 2026/4/10 12:30:54

终极免费OpenAI API密钥完整技术指南:零成本AI开发解决方案

终极免费OpenAI API密钥完整技术指南&#xff1a;零成本AI开发解决方案 【免费下载链接】FREE-openai-api-keys collection for free openai keys to use in your projects 项目地址: https://gitcode.com/gh_mirrors/fr/FREE-openai-api-keys 在当今人工智能技术快速发…

作者头像 李华
网站建设 2026/4/11 7:18:18

Neuro-Sama实战部署:3步打造智能语音交互系统

Neuro-Sama实战部署&#xff1a;3步打造智能语音交互系统 【免费下载链接】Neuro A recreation of Neuro-Sama originally created in 7 days. 项目地址: https://gitcode.com/gh_mirrors/neuro6/Neuro 引言&#xff1a;从"技术难题"到"可行方案"的…

作者头像 李华
网站建设 2026/4/5 7:58:02

Macast终极指南:轻松实现手机到电脑的媒体投屏

Macast终极指南&#xff1a;轻松实现手机到电脑的媒体投屏 【免费下载链接】Macast Macast - 一个跨平台的菜单栏/状态栏应用&#xff0c;允许用户通过 DLNA 协议接收和发送手机中的视频、图片和音乐&#xff0c;适合需要进行多媒体投屏功能的开发者。 项目地址: https://git…

作者头像 李华
网站建设 2026/4/13 17:14:20

FactoryBluePrints:打造戴森球计划最高效工厂的完整解决方案

FactoryBluePrints&#xff1a;打造戴森球计划最高效工厂的完整解决方案 【免费下载链接】FactoryBluePrints 游戏戴森球计划的**工厂**蓝图仓库 项目地址: https://gitcode.com/GitHub_Trending/fa/FactoryBluePrints 你是否曾经在戴森球计划中遇到过这样的挑战&#x…

作者头像 李华