Android Kotlin(1) 协程启动和取消

298 阅读7分钟

一、协程的启动模式

Kotlin协程提供了四种启动模式,通过start参数指定,用于控制协程的调度和执行时机:

1. DEFAULT (默认模式)

  • 行为:立即调度协程执行
  • 特点:最常用的模式,协程创建后尽快执行
  • 示例
    launch(start = CoroutineStart.DEFAULT) { 
        // 默认模式,可省略start参数
    }
    

2. LAZY (懒启动)

  • 行为:只有手动调用start()join()时才会启动
  • 特点:延迟执行,适合需要按条件触发的协程
  • 示例
    val job = launch(start = CoroutineStart.LAZY) {
        println("Lazy coroutine")
    }
    // 需要时手动启动
    job.start()
    

3. ATOMIC (原子启动)

  • 行为:立即调度,但启动前不能被取消
  • 特点:保证协程至少会开始执行,适合重要不可取消的初始化操作
  • 示例
    launch(start = CoroutineStart.ATOMIC) {
        // 这段代码保证会开始执行
    }
    

4. UNDISPATCHED (不调度立即执行)

  • 行为:在当前线程立即执行协程体,直到第一个挂起点
  • 特点:减少初始调度开销,适合已知很快会挂起的操作
  • 示例
    launch(start = CoroutineStart.UNDISPATCHED) {
        println("立即在当前线程执行") 
        delay(100) // 从这里开始才切换线程
    }
    

二、协程的作用于构造器

选择合适的作用域构造器可以帮助你更好地管理协程的生命周期和异常处理。在Android开发中,优先使用viewModelScopelifecycleScope可以避免内存泄漏。

1. 基础作用域构造器

runBlocking

  • 特点:阻塞当前线程,直到内部所有协程执行完毕
  • 用途:主要用于测试和main函数
  • 示例
    fun main() = runBlocking {
        launch {
            delay(1000)
            println("World")
        }
        println("Hello")
    }
    

coroutineScope

  • 特点:挂起函数,会等待所有子协程完成
  • 用途:结构化并发,分解并行任务
  • 示例
    suspend fun doWork() = coroutineScope {
        launch { task1() }
        launch { task2() }
        // 自动等待两个任务完成
    }
    

2. 常用作用域构造器

supervisorScope

  • 特点:子协程失败不会影响其他子协程
  • 用途:需要独立运行的任务集合
  • 示例
    suspend fun handleRequests() = supervisorScope {
        launch { processRequest(request1) }
        launch { processRequest(request2) }
        // 一个失败不会取消另一个
    }
    

withContext

  • 特点:挂起函数,切换协程上下文并返回结果,自动恢复原上下文。
  • 用途:临时切换线程(如 Dispatchers.IODispatchers.Main)。
  • 示例
    suspend fun fetchData(): Data = withContext(Dispatchers.IO) {
        // 在IO线程执行网络请求
        api.fetchData()
    }
    

async

  • 特点协程构造器,启动子协程并发执行,返回 Deferred<T>(需 await() 获取结果)。
  • 用途:需要并行执行多个任务并组合结果(如同时请求两个接口)。
  • 示例
    suspend fun fetchDashboard(): Dashboard = coroutineScope {
        val userDeferred = async { fetchUser() }  // 并发执行
        val newsDeferred = async { fetchNews() }
        Dashboard(userDeferred.await(), newsDeferred.await()) // 等待结果
    }
    

3. Android特殊作用域

viewModelScope (AndroidX)

  • 特点:绑定ViewModel生命周期
  • 用途:ViewModel中的协程管理
  • 示例
    class MyViewModel : ViewModel() {
        fun loadData() {
            viewModelScope.launch {
                // 当ViewModel清除时自动取消
            }
        }
    }
    

lifecycleScope (AndroidX)

  • 特点:绑定LifecycleOwner生命周期
  • 用途:Activity/Fragment中的协程管理
  • 示例
    class MyActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            lifecycleScope.launch {
                // 随Activity销毁自动取消
            }
        }
    }
    

三、Job生命周期

1. Job 的 6 种状态

  1. New (新建状态)  - 使用 launch(start = CoroutineStart.LAZY) 创建的协程初始状态
  2. Active (活跃状态)  - 协程正在执行
  3. Completing (完成中状态)  - 协程已完成工作,但正在等待子协程完成
  4. Cancelling (取消中状态)  - 协程正在取消,但还未完全取消
  5. Cancelled (已取消状态)  - 协程已被取消
  6. Completed (已完成状态)  - 协程正常完成

2. 重要注意事项

  1. 状态是不可逆的 - 一旦进入 Cancelling 或 Completed 就不能回到 Active
  2. 资源清理 - 在 finally 块中执行清理,因为它会在 Cancelling 状态执行
  3. 子协程影响 - 父协程会等待所有子协程完成才能进入 Completed 状态
  4. 异常传播 - 子协程的失败会影响父协程的状态(除非使用 SupervisorJob)

理解这些状态及其转换关系,可以帮助你更好地控制协程的执行流程,编写更健壮的异步代码。



四、协程的取消

1. 基本取消操作

取消协程

val job = launch {
    // 协程体
}
job.cancel() // 取消协程

取消作用域

val scope = CoroutineScope(Dispatchers.Main)
scope.cancel() // 取消该作用域下的所有子协程

2. 检查协程是否被取消

显式检查isActive

launch {
    while(isActive) { // 检查协程是否活跃
        // 工作代码
    }
}

使用ensureActive()

launch {
    repeat(1000) { i ->
        ensureActive() // 如果协程已取消则抛出CancellationException
        // 工作代码
    }
}

3. 取消异常处理

协程取消会抛出CancellationException,它会被协程框架静默处理,不会导致应用崩溃。

捕获取消异常

launch {
    try {
        // 协程代码
    } catch (e: CancellationException) {
        // 清理资源
        throw e // 必须重新抛出
    }
}

4. 不可取消的代码块

使用withContext(NonCancellable)执行必须完成的代码:

launch {
    try {
        // 可取消代码
    } finally {
        withContext(NonCancellable) {
            // 必须执行的清理代码
        }
    }
}

5. 超时取消

使用withTimeoutwithTimeoutOrNull

// 超时抛出TimeoutCancellationException
val result = withTimeout(1000) {
    // 长时间操作
}

// 超时返回null
val result = withTimeoutOrNull(1000) {
    // 长时间操作
}

6. 协程取消的传播

取消是协作式的,需要协程代码配合检查取消状态:

  • 所有kotlinx.coroutines中的挂起函数都是可取消的
  • 自定义挂起函数应该在适当位置检查取消状态

7. 取消与资源清理

使用try-finally确保资源释放:

launch {
    val resource = acquireResource()
    try {
        // 使用资源
    } finally {
        releaseResource(resource)
    }
}

五、常见问题与解决方案

问题1:协程不响应取消

原因:协程中执行了阻塞操作且未检查取消状态 解决方案

launch {
    while(isActive) {
        // 执行工作
        yield() // 或 ensureActive()
    }
}

问题2:ViewModel中协程泄漏

解决方案

class MyViewModel : ViewModel() {
    private val viewModelScope = CoroutineScope(
        SupervisorJob() + Dispatchers.Main
    )
    
    override fun onCleared() {
        super.onCleared()
        viewModelScope.cancel()
    }
}
// 或者直接使用AndroidX的viewModelScope扩展

正确管理协程取消是编写健壮Android应用的重要部分,特别是在处理生命周期敏感的操作时。

问题3:协程中 cancel() 与 cancelAndJoin() 的区别

在Kotlin协程中,cancel()cancelAndJoin()都是用于取消协程的方法,但它们的行为和用途有重要区别:

job.cancel() 行为特点

  • 立即返回:不会等待协程实际完成取消
  • 异步取消:只是发起取消请求,协程可能还在运行
  • 不阻塞:调用线程不会被阻塞

job.cancelAndJoin()行为特点

  • 挂起调用者:是一个suspend函数,会挂起当前协程
  • 等待完成:会等待协程完全取消并完成
  • 阻塞协程:调用它的协程会被挂起,直到目标协程完全取消

最佳实践建议

  1. 在普通函数中(非suspend)只能使用cancel()
  2. 在suspend函数中,当需要确保协程完全取消后再继续执行时,使用cancelAndJoin()
  3. 对于需要清理资源的场景,优先使用cancelAndJoin()
  4. 对UI事件响应等需要快速返回的场景,使用cancel()

记住:协程取消是协作式的,无论使用哪种取消方式,协程代码本身需要正确响应取消请求(通过检查isActive或调用yield()等挂起函数)。

问题4:如何替换 launchWhenStarted(已弃用)?

1. 一开始为什么用 launchWhenStarted

  1. ​避免内存泄漏​​:当 Fragment 不可见(如跳转到其他页面)时,暂停数据收集。
  2. ​节省资源​​:避免在后台无意义地处理数据更新。
  3. ​自动绑定生命周期​​:无需手动管理协程取消。

2. 什么原因导致被标记为Deprecated

因为它可能导致资源浪费(协程在 STOPPED 状态时仍占用资源)。官方推荐使用 repeatOnLifecycle(Lifecycle.State.STARTED) 替代,它会在 STARTED 状态时启动协程,并在 STOPPED 状态时 完全取消 协程,避免资源泄漏。

3. 如何使用repeatOnLifecycle

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                CurrentTimeInstance.dateFlow.collect { config ->
                    tvTimeFormat.text = "当前时间格式: ${config.value}"
                }
            }
        }

4. 为什么 repeatOnLifecycle 更好?

  • launchWhenStarted​ 在 STOPPED 状态时只是 ​​暂停​​ 协程,但仍然占用资源。
  • repeatOnLifecycle​ 在 STOPPED 状态时 ​​完全取消协程​​,并在 STARTED 时重新启动,避免资源浪费。