
前言
你已经学会了启动协程,也学会了取消协程。收发自如,剑法初成。
但一个棘手的问题随之而来:你该在哪里存放这些协程的"遥控器"?
想象一个场景:你在 Activity 中写了一个 launch,启动了一个网络请求。用户旋转了一下屏幕------这是 Android 开发中最常见的动作之一。Activity 被销毁重建,你的协程却还在后台运行。当网络请求返回时,它试图更新一个已经消失的 UI,于是 IllegalStateException 劈头盖脸地砸下来。
传统方案里,你需要手动管理这些"野线程":在 onDestroy 中遍历所有 Thread 并调用 interrupt(),或者在 Handler 中小心翼翼地 removeCallbacks。稍有不慎,就是内存泄漏和莫名其妙的崩溃。
协程给出的答案,是一个叫做 CoroutineScope 的概念。它就像一块疆域------所有在这块疆域内启动的协程,都会被自动登记在册。当疆域被"收回"时,域内所有协程都会被统一清剿,无一漏网。
本讲是炼气境的后阶修炼。你将学会:
- 自定义创建
CoroutineScope,并为它绑定生命周期。 - 深入理解官方提供的
viewModelScope和lifecycleScope的内部原理。 - 写出绝不泄漏的协程代码。
操千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意。
什么是 CoroutineScope?
在前两讲中,我们无数次使用了 viewModelScope.launch,但从未真正审视 viewModelScope 本身是什么。
CoroutineScope 是一个接口,它的唯一职责是定义协程的生命周期边界 。每一个
CoroutineScope内部都持有一个Job(称为coroutineContext[Job]),该Job的状态决定了整个 Scope 内所有协程的生死------一旦 Scope 的 Job 被取消,域内所有协程都会被级联取消。
如果你熟悉 Android 的 ViewModel 或 Lifecycle,可以这样理解:
ViewModel是数据的生命周期边界。CoroutineScope是协程的生命周期边界。
两者结合,天作之合。

自定义 CoroutineScope:打造你自己的疆域
在深入 Android 专属 Scope 之前,我们先用纯 Kotlin 代码手搓一个 CoroutineScope,理解它的底层构造。
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// 创建一个自定义的 CoroutineScope
// 构造函数参数是 CoroutineContext,至少需要包含一个 Job
val myScope = CoroutineScope(Job() + Dispatchers.Default)
println("🧘 主协程:在 myScope 中启动三个子协程")
myScope.launch {
repeat(5) { i ->
delay(500)
println("协程 A:心跳 $i")
}
}
myScope.launch {
repeat(5) { i ->
delay(600)
println("协程 B:心跳 $i")
}
}
myScope.launch {
delay(1500)
println("协程 C:我完成了")
}
delay(2000) // 让它们跑 2 秒
println("🧘 主协程:时间到,取消整个 myScope!")
myScope.cancel()
delay(500) // 给一点时间让取消生效
println("🧘 主协程:myScope 已取消,域内所有协程终结。")
}
运行结果中,你会看到协程 A 和 B 各自打印了几次心跳,然后在 myScope.cancel() 调用后戛然而止。你只调用了一次 cancel,却终结了三个协程。 这就是 Scope 的威力。
将 CoroutineScope 绑定到 Android 生命周期
理解了 Scope 的基本原理,我们就可以在 Android 中实践了。核心思路是:在组件创建时创建 Scope,在组件销毁时取消 Scope。
手动绑定:Activity 示例
kotlin
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.*
class ManualScopeActivity : AppCompatActivity() {
// 创建专属于此 Activity 的 CoroutineScope
// 使用 Dispatchers.Main 确保协程运行在主线程(用于更新 UI)
private val activityScope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 在 activityScope 中启动协程
activityScope.launch {
repeat(10) { i ->
delay(1000)
// 安全地更新 UI(因为我们在 Dispatchers.Main 中)
updateTextView("更新 $i")
}
}
}
override fun onDestroy() {
// 关键步骤:在 Activity 销毁时取消整个 Scope
activityScope.cancel()
super.onDestroy()
}
private fun updateTextView(text: String) {
// 实际更新 UI 的代码
}
}
这个模式完美解决了屏幕旋转导致的泄漏问题:每次 onDestroy 被调用时,activityScope.cancel() 会干净利落地终止所有还在运行的协程。旋转屏幕时,旧的 Activity 被销毁,协程随之消亡;新的 Activity 创建新的 Scope,启动新的协程。井水不犯河水。
官方的馈赠:lifecycleScope 和 viewModelScope
手动管理 Scope 虽然可行,但略显繁琐。Android Jetpack 为我们提供了两个开箱即用的 Scope:
| Scope 名称 | 所属组件 | 绑定生命周期 | 取消时机 | 默认调度器 |
|---|---|---|---|---|
lifecycleScope |
LifecycleOwner(Activity/Fragment) |
生命周期 | onDestroy |
Dispatchers.Main |
viewModelScope |
ViewModel |
ViewModel | onCleared |
Dispatchers.Main.immediate |
这两个 Scope 的内部实现与我们手动写的 activityScope 如出一辙。以 lifecycleScope 为例,它的核心源码简化版如下:
kotlin
// 简化版 LifecycleCoroutineScope 实现(仅供理解原理)
class LifecycleCoroutineScopeImpl(
private val lifecycle: Lifecycle
) : CoroutineScope {
private val job = SupervisorJob() // 稍后解释为何用 SupervisorJob
override val coroutineContext = job + Dispatchers.Main
init {
// 监听生命周期,在 onDestroy 时取消 Scope
lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
job.cancel()
}
}
})
}
}
这就是为什么你可以在 Activity 或 Fragment 中直接写 lifecycleScope.launch { }------它已经帮你做好了生命周期绑定。
实战:一个永不泄漏的倒计时器
让我们用 lifecycleScope 实现一个经典的易泄漏场景------倒计时器。
需求:界面显示 60 秒倒计时,用户离开页面时计时器必须自动停止。
传统 Thread + Handler 写法(反面教材):
kotlin
// ❌ 容易泄漏的传统写法
class LeakyActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
private var countdown = 60
private val runnable = object : Runnable {
override fun run() {
countdown--
textView.text = countdown.toString()
if (countdown > 0) {
handler.postDelayed(this, 1000)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handler.post(runnable)
}
// 如果忘记 removeCallbacks,旋转屏幕后 runnable 会持有旧 Activity 引用
// 导致内存泄漏,且倒计时会在后台继续跑
}
协程 + lifecycleScope 写法(最佳实践):
kotlin
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class CountdownActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_countdown)
val countdownText = findViewById<TextView>(R.id.countdownText)
val startButton = findViewById<Button>(R.id.startButton)
startButton.setOnClickListener {
// 直接在 lifecycleScope 中启动倒计时协程
lifecycleScope.launch {
for (remaining in 60 downTo 0) {
countdownText.text = "剩余时间:$remaining 秒"
delay(1000)
}
countdownText.text = "倒计时结束!"
}
}
}
// 无需手动清理!lifecycleScope 会在 onDestroy 时自动取消所有协程
}
配合 Compose 的 ViewModel 版本(更现代):
kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class CountdownViewModel : ViewModel() {
var countdownText by mutableStateOf("准备就绪")
private set
private var countdownJob: Job? = null
fun startCountdown() {
// 取消之前的倒计时(如果存在)
countdownJob?.cancel()
countdownJob = viewModelScope.launch {
for (remaining in 60 downTo 0) {
countdownText = "剩余时间:$remaining 秒"
delay(1000)
}
countdownText = "倒计时结束!"
}
}
fun cancelCountdown() {
countdownJob?.cancel()
countdownText = "已取消"
}
// ViewModel.onCleared 会自动调用 viewModelScope.cancel()
// 所以即使你忘记取消 countdownJob,也不会泄漏
}

自定义 Scope 的实战场景:全局单例协程
有些场景下,你需要一个比 Activity 生命周期更长的 Scope------比如一个全局的下载管理器,它在应用运行期间一直存在。
kotlin
import kotlinx.coroutines.*
object GlobalDownloadManager {
// 应用级别的 CoroutineScope
// 使用 SupervisorJob + Dispatchers.IO
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun downloadFile(url: String, onProgress: (Int) -> Unit) {
applicationScope.launch {
// 模拟下载
for (progress in 0..100 step 10) {
delay(500)
withContext(Dispatchers.Main) {
onProgress(progress)
}
}
}
}
// 在应用退出时调用(例如在 Application.onTerminate 或自定义清理方法中)
fun shutdown() {
applicationScope.cancel()
}
}
注意 :全局 Scope 如果不妥善管理,本身也可能成为泄漏源。务必在合适的时机(如应用退出)调用 cancel()。一个更好的实践是使用 ProcessLifecycleOwner.get().lifecycleScope,它绑定到整个应用进程的生命周期。
常见错误与避坑指南
错误 1:使用 GlobalScope 替代 lifecycleScope
kotlin
// ❌ 危险:GlobalScope 的生命周期是应用进程级别
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlobalScope.launch {
// 这个协程会一直运行,即使 Activity 被销毁
// 旋转屏幕 10 次,你就会多出 10 个在后台运行的协程
while (true) {
delay(1000)
println("我还活着...")
}
}
}
}
GlobalScope 是一个全局单例 Scope,它的生命周期与应用程序进程相同。永远不要在 Activity/Fragment/ViewModel 中使用 GlobalScope。它仅适用于那些真正需要在应用整个生命周期内运行的后台任务(极其罕见)。
正确做法 :使用 lifecycleScope 或 viewModelScope。
错误 2:在协程内部直接引用 View/Activity
kotlin
// ❌ 危险:协程可能比 View 活得更久
lifecycleScope.launch {
val data = fetchData()
textView.text = data // 如果协程在 onDestroy 后才返回,textView 可能为 null
}
虽然 lifecycleScope 会在 onDestroy 时取消协程,但取消是协作式 的。如果 fetchData() 是一个没有挂起点的纯阻塞操作,取消信号无法被检测到。更安全的做法是使用 lifecycleScope.launchWhenStarted 或配合 LiveData/Flow(后续章节会讲)。
错误 3:在自定义 Scope 中忘记传入 Job
kotlin
// ❌ 错误:没有 Job 的 Scope
val scope = CoroutineScope(Dispatchers.Main)
scope.launch { ... }
scope.cancel() // 编译错误!没有 cancel 方法
CoroutineScope 的扩展函数 cancel() 实际上是从 coroutineContext[Job] 中获取的。如果你创建 Scope 时没有提供 Job,就没有 cancel 可调用。正确写法:CoroutineScope(Job() + Dispatchers.Main)。
错误 4:在 ViewModel 中使用 lifecycleScope
kotlin
class MyViewModel : ViewModel() {
fun loadData() {
// ❌ 编译错误:ViewModel 不是 LifecycleOwner
lifecycleScope.launch {
// ...
}
}
}
lifecycleScope 是 LifecycleOwner 的扩展属性,而 ViewModel 没有实现 LifecycleOwner。在 ViewModel 中请使用 viewModelScope。
最佳实践
-
UI 层使用 lifecycleScope,数据层使用 viewModelScope :这是官方推荐的分离方式。UI 相关的短暂操作(如动画、一次性事件)用
lifecycleScope;数据加载、业务逻辑用viewModelScope。 -
永远不要直接使用 GlobalScope:除非你明确知道自己在创建一个应用级全局任务,并且有对应的清理机制。
-
自定义 Scope 时,始终包含 Job :
CoroutineScope(Job() + Dispatcher)是标准模板。 -
利用
coroutineContext[Job]?.cancelChildren()精细控制 :如果你只想取消所有子协程而保留 Scope 本身可用,可以调用scope.coroutineContext[Job]?.cancelChildren()。这在需要"重置"状态时非常有用。 -
在 ViewModel 中管理单个任务的 Job 引用 :如倒计时示例中的
countdownJob,这允许你在启动新任务前取消旧任务,避免重复执行。
慎用] end LC -->|绑定 Activity/Fragment| UI层 VC -->|绑定 ViewModel| VM AS -->|绑定进程| 全局层 style UI层 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style 数据层 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px style 全局层 fill:#fff3e0,stroke:#f57c00,stroke-width:2px style LC fill:#bbdefb,stroke:#1565c0,stroke-width:2px style VC fill:#c8e6c9,stroke:#388e3c,stroke-width:2px style AS fill:#ffb74d,stroke:#e65100,stroke-width:2px
一个更宏大的问题即将揭晓
在本讲中,我们反复提到了一个词:取消传播 。你知道了 scope.cancel() 会取消域内所有协程。但你是否好奇过:
- 如果域内某个子协程内部发生了异常,会发生什么?
- 父协程会因此被取消吗?
- 如果我想让一个子协程的失败不影响其他兄弟协程,该怎么做?
这些问题的答案,都指向协程的异常处理机制 ,以及一个特殊的 Job 变体------SupervisorJob。
我们刚刚在 LifecycleCoroutineScopeImpl 的简化源码中看到了 SupervisorJob 的身影。它正是为了隔离子协程的异常而设计的。
在下一讲------炼气境·巅峰------我们将彻底掀开协程的引擎盖。你将看到:
- 挂起函数的编译器魔法:状态机如何生成,
label如何跳转。 - 字节码层面的
Continuation续体传球。 delay到底是如何在不阻塞线程的前提下"暂停"协程的。
这是一场深入原理的硬核之旅。准备好你的丹田,我们要开始运功内视了。
【当前境界修为面板】
- 当前境界 :
[炼气境 · 后阶] - 下一突破 :
[炼气境 · 巅峰](需领悟:挂起函数原理熔炉------状态机、CPS续体、字节码爆破) - 修炼进度 :
[██████░░░░░░░░░░░░░░] 33% - 本讲获得法器 :
CoroutineScope 疆域划定术、lifecycleScope/viewModelScope 免泄漏心法
【本讲思考题】
1、表象题:以下代码有什么问题?
kotlin
class MyActivity : AppCompatActivity() {
val scope = CoroutineScope(Dispatchers.Main)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
scope.launch {
delay(10000)
textView.text = "完成"
}
}
}
2、场景题:你需要在应用全局维护一个 WebSocket 连接,它需要在应用前台时保持连接,进入后台时断开。你会选择使用哪种 Scope?如何设计连接逻辑?
3、原理题 :viewModelScope 和 lifecycleScope 的默认调度器都是 Dispatchers.Main。但 viewModelScope 使用的是 Dispatchers.Main.immediate。请查阅资料,简述 immediate 与普通 Main 的区别,以及为什么 ViewModel 选择使用 immediate。
道友,炼气境的三重基础已全部传授完毕。下一讲,我们将踏入真正的原理深水区。那里没有 API 的浮光掠影,只有编译器与运行时的硬核真相。你准备好了吗?
欢迎一键四连 (
关注+点赞+收藏+评论)