【Kotlin 协程修仙录 · 炼气境 · 后阶】 | 划定疆域:CoroutineScope 与 Android 生命周期的绑定艺术

前言

你已经学会了启动协程,也学会了取消协程。收发自如,剑法初成。

但一个棘手的问题随之而来:你该在哪里存放这些协程的"遥控器"?

想象一个场景:你在 Activity 中写了一个 launch,启动了一个网络请求。用户旋转了一下屏幕------这是 Android 开发中最常见的动作之一。Activity 被销毁重建,你的协程却还在后台运行。当网络请求返回时,它试图更新一个已经消失的 UI,于是 IllegalStateException 劈头盖脸地砸下来。

传统方案里,你需要手动管理这些"野线程":在 onDestroy 中遍历所有 Thread 并调用 interrupt(),或者在 Handler 中小心翼翼地 removeCallbacks。稍有不慎,就是内存泄漏和莫名其妙的崩溃。

协程给出的答案,是一个叫做 CoroutineScope 的概念。它就像一块疆域------所有在这块疆域内启动的协程,都会被自动登记在册。当疆域被"收回"时,域内所有协程都会被统一清剿,无一漏网。

本讲是炼气境的后阶修炼。你将学会:

  • 自定义创建 CoroutineScope,并为它绑定生命周期。
  • 深入理解官方提供的 viewModelScopelifecycleScope 的内部原理。
  • 写出绝不泄漏的协程代码。

千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意


什么是 CoroutineScope

在前两讲中,我们无数次使用了 viewModelScope.launch,但从未真正审视 viewModelScope 本身是什么。

CoroutineScope 是一个接口,它的唯一职责是定义协程的生命周期边界 。每一个 CoroutineScope 内部都持有一个 Job(称为 coroutineContext[Job]),该 Job 的状态决定了整个 Scope 内所有协程的生死------一旦 Scope 的 Job 被取消,域内所有协程都会被级联取消

如果你熟悉 Android 的 ViewModelLifecycle,可以这样理解:

  • 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 的威力。

sequenceDiagram participant Main as 🧘 主协程 participant Scope as 🏢 myScope participant A as 🚀 协程 A participant B as 🚀 协程 B participant C as 🚀 协程 C rect rgb(232, 245, 233) Main->>Scope: 创建 Scope(Job + Dispatchers.Default) Scope->>A: launch Scope->>B: launch Scope->>C: launch A->>A: delay(500) 循环 B->>B: delay(600) 循环 C->>C: delay(1500) end rect rgb(255, 243, 224) Main->>Main: delay(2000) Main->>Scope: cancel() Scope->>A: 取消信号 Scope->>B: 取消信号 Scope->>C: 取消信号 A-->>Scope: CancellationException B-->>Scope: CancellationException C-->>Scope: CancellationException Scope-->>Main: 取消完成 end

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,启动新的协程。井水不犯河水。


官方的馈赠:lifecycleScopeviewModelScope

手动管理 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 { }------它已经帮你做好了生命周期绑定。

stateDiagram-v2 [*] --> Created : Activity 创建 Created --> ScopeCreated : lifecycleScope 创建 ScopeCreated --> Launched : launch { } Launched --> Running : 协程执行中 Running --> Running : 挂起与恢复 state Destroyed { [*] --> Cancelling : lifecycleScope.cancel() Cancelling --> Cleaned : 所有协程终止 } Running --> Destroyed : onDestroy 触发 ScopeCreated --> Destroyed : onDestroy 触发

实战:一个永不泄漏的倒计时器

让我们用 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 时自动取消所有协程
}

配合 ComposeViewModel 版本(更现代):

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。它仅适用于那些真正需要在应用整个生命周期内运行的后台任务(极其罕见)。

正确做法 :使用 lifecycleScopeviewModelScope

错误 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 {
            // ...
        }
    }
}

lifecycleScopeLifecycleOwner 的扩展属性,而 ViewModel 没有实现 LifecycleOwner。在 ViewModel 中请使用 viewModelScope


最佳实践

  1. UI 层使用 lifecycleScope,数据层使用 viewModelScope :这是官方推荐的分离方式。UI 相关的短暂操作(如动画、一次性事件)用 lifecycleScope;数据加载、业务逻辑用 viewModelScope

  2. 永远不要直接使用 GlobalScope:除非你明确知道自己在创建一个应用级全局任务,并且有对应的清理机制。

  3. 自定义 Scope 时,始终包含 JobCoroutineScope(Job() + Dispatcher) 是标准模板。

  4. 利用 coroutineContext[Job]?.cancelChildren() 精细控制 :如果你只想取消所有子协程而保留 Scope 本身可用,可以调用 scope.coroutineContext[Job]?.cancelChildren()。这在需要"重置"状态时非常有用。

  5. 在 ViewModel 中管理单个任务的 Job 引用 :如倒计时示例中的 countdownJob,这允许你在启动新任务前取消旧任务,避免重复执行。

graph LR subgraph UI层[ UI 层] LC[lifecycleScope] end subgraph 数据层[数据层] VM[ViewModel] VC[viewModelScope] end subgraph 全局层[全局层] AS[自定义 ApplicationScope
慎用] 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、原理题viewModelScopelifecycleScope 的默认调度器都是 Dispatchers.Main。但 viewModelScope 使用的是 Dispatchers.Main.immediate。请查阅资料,简述 immediate 与普通 Main 的区别,以及为什么 ViewModel 选择使用 immediate


道友,炼气境的三重基础已全部传授完毕。下一讲,我们将踏入真正的原理深水区。那里没有 API 的浮光掠影,只有编译器与运行时的硬核真相。你准备好了吗?

欢迎一键四连关注 + 点赞 + 收藏 + 评论

相关推荐
朝星2 小时前
Android开发[5]:组件化之路由+注解
android·kotlin
随遇丿而安2 小时前
Android全功能终极创作
android
随遇丿而安2 小时前
第1周:别小看 `TextView`,它其实是 Android 页面里最常被低估的组件
android
summerkissyou19875 小时前
Android-基础-SystemClock.elapsedRealtime和System.currentTimeMillis区别
android
ian4u5 小时前
车载 Android C++ 完整技能路线:从基础到进阶
android·开发语言·c++
学习使我健康7 小时前
Android 中 Service 用法
android·kotlin
2601_949816687 小时前
MySQL 数据库连接池爆满问题排查与解决
android·数据库·mysql
Tangsong4048 小时前
以Termius的方式进行安卓设备调试?试试【easyadb】| 多功能可视化adb工具
android·adb