Kotlin 协程原理与 Android 中的最佳实践

Kotlin 协程原理与 Android 中的最佳实践

一句话收益:读完本文,你将彻底理解协程的挂起/恢复机制、结构化并发模型,并掌握在 Android 实际工程中避开常见陷阱的完整方案。

适用版本:Kotlin 1.7+,kotlinx-coroutines-core 1.6+,Android API 21+

阅读时长:约 20 分钟


1. 从一个真实问题切入

你接手了一个列表页,点击刷新按钮后:

  1. 发起网络请求获取数据
  2. 解析后写入数据库
  3. 从数据库读取并更新 UI

用回调嵌套写完,代码层数已经超过屏幕宽度,测试同学反馈偶发 NetworkOnMainThreadException,内存泄漏工具提示 Activity 未回收。

这是协程真正解决的问题域。


2. 协程核心原理:挂起与恢复

2.1 CPS 变换------编译器做了什么

Kotlin 协程在语言层面的核心是 Continuation Passing Style (CPS) 变换。编译器将 suspend 函数改写为带 Continuation 参数的普通函数:

kotlin 复制代码
// 你写的代码
suspend fun fetchUser(id: Int): User

// 编译器实际生成(伪代码)
fun fetchUser(id: Int, continuation: Continuation<User>): Any?

Continuation<T> 接口定义在 kotlin.coroutines 包:

kotlin 复制代码
// kotlin/coroutines/Continuation.kt(AOSP 对应 kotlinx.coroutines 库)
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

当挂起发生时,函数返回特殊标记 COROUTINE_SUSPENDEDkotlin.coroutines.intrinsics.COROUTINE_SUSPENDED);恢复时由调度器调用 continuation.resumeWith(),从上次挂起点继续执行。

2.2 状态机------挂起点切割

每个 suspend 函数会被编译为一个状态机 ,每个 suspend 调用点对应一个状态:

复制代码
fetchUser() 状态机示意
┌──────────────────────────────────────┐
│  State 0: 初始状态                    │
│    → 调用 apiService.getUser()       │
│    → 返回 COROUTINE_SUSPENDED        │
│    → 保存局部变量到 Continuation      │
├──────────────────────────────────────┤
│  State 1: getUser 恢复后              │
│    → 调用 userDao.insert(user)       │
│    → 返回 COROUTINE_SUSPENDED        │
├──────────────────────────────────────┤
│  State 2: insert 恢复后               │
│    → 返回最终结果 User               │
└──────────────────────────────────────┘

关键类路径:kotlinx/coroutines/internal/CoroutineStackFrame.ktkotlin/coroutines/jvm/internal/BaseContinuationImpl.kt

2.3 调度器本质:线程池封装

复制代码
CoroutineDispatcher 继承关系
                CoroutineDispatcher
                       │
          ┌────────────┼───────────────────┐
          │            │                   │
   Dispatchers.Main  Dispatchers.IO   Dispatchers.Default
   (主线程 Handler)  (IO线程池,64线程上限) (CPU线程池,核心数线程)

Dispatchers.IO 底层使用 LimitingDispatcherkotlinx/coroutines/scheduling/LimitingDispatcher.kt),线程数上限由 systemProp.kotlinx.coroutines.io.parallelism 控制,默认 max(64, CPU核心数)

Dispatchers.Main 在 Android 中由 HandlerContextkotlinx/coroutines/android/HandlerContext.kt)实现,本质是将任务 post 到主线程 Looper


3. 结构化并发:协程作用域模型

3.1 CoroutineScope 与父子关系

复制代码
Job 父子树示意
viewModelScope (SupervisorJob)
    │
    ├── launch { fetchUserList() }  ← Job A
    │       └── async { parseData() }  ← Job B(A的子Job)
    │
    └── launch { loadBanner() }  ← Job C

规则:

  • 父 Job 取消 → 所有子 Job 取消
  • 子 Job 异常(非 CancellationException)→ 默认传播到父 Job(SupervisorJob 例外)
  • 父 Job 等待所有子 Job 完成才算完成

关键实现:kotlinx/coroutines/JobSupport.kt 中的 childCancelled()notifyParentChildException()

3.2 Android 内置 Scope

Scope Job 类型 取消时机 适用场景
viewModelScope SupervisorJob ViewModel onCleared 业务数据加载
lifecycleScope SupervisorJob Lifecycle DESTROYED UI 绑定的操作
rememberCoroutineScope() Job Composable 离开 Composition Compose 内事件处理
GlobalScope ⚠️ 无父级 永不自动取消 慎用!

4. 代码实战:错误写法 → 问题 → 正确写法

4.1 在 ViewModel 中发起网络请求

❌ 错误写法

kotlin 复制代码
class UserViewModel : ViewModel() {
    fun loadUser(id: Int) {
        // 错误:直接使用 GlobalScope,ViewModel 销毁后协程仍在运行
        GlobalScope.launch {
            val user = repository.fetchUser(id)
            // 切回主线程更新 UI
            withContext(Dispatchers.Main) {
                _user.value = user
            }
        }
    }
}

问题GlobalScope 的协程不与 ViewModel 生命周期绑定,ViewModel 已 onCleared() 后协程仍可能持有旧引用导致内存泄漏;_user.value 赋值时 ViewModel 可能已销毁。

✅ 正确写法

kotlin 复制代码
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()

    fun loadUser(id: Int) {
        viewModelScope.launch {          // 绑定 ViewModel 生命周期
            _user.value = repository.fetchUser(id)  // StateFlow 线程安全
        }
    }
}

// Repository 层:使用 withContext 切换线程,而非 Dispatchers 参数
class UserRepository(private val api: UserApi, private val dao: UserDao) {
    suspend fun fetchUser(id: Int): User = withContext(Dispatchers.IO) {
        val user = api.getUser(id)       // 网络请求(已在 IO 线程)
        dao.insert(user)                 // 写数据库
        dao.getUser(id)                  // 读数据库返回
    }
}

4.2 并行请求的正确姿势

❌ 错误写法

kotlin 复制代码
// 两个请求串行执行,总耗时 = t1 + t2
viewModelScope.launch {
    val user = repository.fetchUser(id)
    val posts = repository.fetchPosts(id)
    updateUI(user, posts)
}

✅ 正确写法(并行)

kotlin 复制代码
viewModelScope.launch {
    // async 立即启动,不等待结果
    val userDeferred = async { repository.fetchUser(id) }
    val postsDeferred = async { repository.fetchPosts(id) }

    // await() 才真正挂起等待
    val user = userDeferred.await()     // 等待完成
    val posts = postsDeferred.await()   // 等待完成
    // 总耗时 = max(t1, t2)

    updateUI(user, posts)
}

4.3 异常处理:asynclaunch 的差异

关键区别launch 的异常直接传播到 CoroutineExceptionHandlerasync 的异常在 await() 时抛出。

kotlin 复制代码
// launch 异常处理
val handler = CoroutineExceptionHandler { _, throwable ->
    Log.e("TAG", "Coroutine failed", throwable)
}

viewModelScope.launch(handler) {
    throw RuntimeException("launch error") // 立即触发 handler
}

// async 异常处理(必须 try-catch await)
viewModelScope.launch {
    val deferred = async {
        throw RuntimeException("async error") // 这里不会立即崩溃
    }
    try {
        deferred.await() // 异常在此处抛出
    } catch (e: RuntimeException) {
        Log.e("TAG", "Async failed", e)
    }
}

4.4 取消协程:协作式取消

协程取消是协作式的,IO 阻塞操作默认不响应取消:

kotlin 复制代码
// ❌ 问题写法:Thread.sleep 不响应取消
viewModelScope.launch {
    Thread.sleep(5000)  // 即使 ViewModel 销毁,这里依然阻塞线程
}

// ✅ 正确写法:使用 delay(可取消的挂起函数)
viewModelScope.launch {
    delay(5000)  // 收到取消信号立即抛出 CancellationException
}

// ✅ 在循环中手动检查取消状态
viewModelScope.launch {
    val items = heavyList()
    for (item in items) {
        ensureActive()          // 等价于 if (!isActive) throw CancellationException()
        processItem(item)       // CPU 密集操作
    }
}

5. Android 最佳实践

5.1 Repository 层:suspend + withContext

做法 :Repository 的所有挂起函数用 withContext(Dispatchers.IO) 包裹,调用方无需关心线程切换。

原因:Dispatcher 决策应在最接近 IO 操作的地方发生,ViewModel 不应关心"这个操作在哪个线程"。

对比 :若不这样做,ViewModel 需要自行处理 withContext,导致业务逻辑与线程调度耦合,单元测试时难以用 TestCoroutineDispatcher 替换。

5.2 Flow:冷流与热流的选择

做法 :数据库查询返回 Flow<T>,UI 层用 repeatOnLifecycle(Lifecycle.State.STARTED) 收集。

kotlin 复制代码
// ViewModel
val users: StateFlow<List<User>> = userRepository
    .getAllUsers()                           // 返回 Flow<List<User>>(Room 自动发射更新)
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),  // 停止收集5秒后取消上游
        initialValue = emptyList()
    )

// Fragment/Activity
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) { // 前台时收集,后台自动暂停
        viewModel.users.collect { users ->
            adapter.submitList(users)
        }
    }
}

原因SharingStarted.WhileSubscribed(5000) 在 UI 进入后台 5 秒后取消上游 Flow,防止后台无效订阅消耗资源;repeatOnLifecycle 保证仅在 STARTED 状态收集,避免在 STOPPED 状态更新 UI 导致崩溃。

对比lifecycleScope.launchWhenStarted 已被弃用(Deprecated),因为它只暂停协程但不取消上游 Flow,仍会持续消耗资源。

5.3 测试:TestCoroutineDispatcher

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule() // 替换 Dispatchers.Main

    @Test
    fun `loadUser updates user state`() = runTest {
        val fakeRepo = FakeUserRepository()
        val viewModel = UserViewModel(fakeRepo)

        viewModel.loadUser(1)

        advanceUntilIdle()  // 执行所有挂起的协程

        assertEquals(expectedUser, viewModel.user.value)
    }
}

// MainDispatcherRule 实现
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

6. 常见坑点

坑 1:在 suspend 函数中错误捕获 CancellationException

现象 :协程明明取消了,但相关逻辑还在执行,cancel() 无效。

原因CancellationException 是协程取消的信号,如果被 catch (e: Exception) 吞掉,取消信号丢失。

复现

kotlin 复制代码
viewModelScope.launch {
    try {
        delay(10000)
    } catch (e: Exception) {
        // 错误!CancellationException 被吞掉
        Log.e("TAG", "error: $e")
        // 后续逻辑仍然执行
    }
}

解决

kotlin 复制代码
try {
    delay(10000)
} catch (e: CancellationException) {
    throw e  // 必须重新抛出,让取消信号传播
} catch (e: Exception) {
    Log.e("TAG", "real error: $e")
}

// 或者只捕获具体异常
try {
    delay(10000)
} catch (e: IOException) {
    // 只处理 IO 异常
}

坑 2:在非挂起函数中调用 runBlocking 阻塞主线程

现象:主线程 ANR,UI 冻结。

原因runBlocking 阻塞调用线程直到协程完成,在主线程调用等于将异步操作变回同步。

复现

kotlin 复制代码
// Activity/Fragment 中
fun onButtonClick() {
    val user = runBlocking { repository.fetchUser(id) }  // 阻塞主线程!
    updateUI(user)
}

解决

kotlin 复制代码
// 改用 lifecycleScope
fun onButtonClick() {
    lifecycleScope.launch {
        val user = repository.fetchUser(id)  // 在协程内挂起等待,主线程不阻塞
        updateUI(user)
    }
}

坑 3:viewModelScope.launch 后立即销毁 ViewModel,异常无处处理

现象ViewModel.onCleared() 后协程内的异常 Crash。

原因viewModelScope 默认不安装 CoroutineExceptionHandler,未处理的异常会走 Thread.uncaughtExceptionHandler

解决

kotlin 复制代码
viewModelScope.launch(
    CoroutineExceptionHandler { _, throwable ->
        _errorEvent.value = throwable.message
    }
) {
    repository.fetchUser(id)
}

坑 4:collect 未在 repeatOnLifecycle 中使用

现象 :App 进入后台后 Flow 仍然触发 UI 更新,偶发 IllegalStateException: Can not perform this action after onSaveInstanceState

原因 :直接在 lifecycleScope.launchcollect 不会在 UI 不可见时自动停止。

解决 :见第 5.2 节 repeatOnLifecycle 写法。


7. 总结

  1. CPS 变换 + 状态机 :协程挂起/恢复的本质是编译器自动生成的状态机,COROUTINE_SUSPENDED 是暂停信号。
  2. 结构化并发 :用 viewModelScope / lifecycleScope 替代 GlobalScope,让协程与生命周期绑定。
  3. Dispatcher 就近原则 :在 Repository 层用 withContext(Dispatchers.IO) 切换线程,ViewModel 保持干净。
  4. Flow + repeatOnLifecycle :冷热流选型合理,SharingStarted.WhileSubscribed 平衡性能与实时性。
  5. CancellationException 不能吞:取消是协作式的,捕获后必须重新抛出。

核心结论:协程不是魔法,而是编译器辅助的 CPS 变换 + 调度器封装;掌握结构化并发模型,才能在 Android 复杂生命周期场景下写出真正健壮的异步代码。


参考资料

相关推荐
Aleyn2 小时前
用 KSP 给 Navigation 3 加一层「跨模块路由」:nav3-helper 设计与使用
android·android jetpack·composer
GeekBug2 小时前
Claude Code 如何帮我写 80% 的 Android 样板代码
android·claude
dora2 小时前
手把手带你实现一个Android抽卡集图鉴功能
android
海雅达手持终端PDA2 小时前
海雅达Model 10X—高通6490工业三防平板,生产制造仓储管理应用
android·物联网·能源·制造·信息与通信·交通物流·平板
赏金术士2 小时前
Kotlin 从入门到进阶 之委托 模块(六)
python·微信·kotlin
liu_sir_2 小时前
安卓设置界面-关于手机修改为关于设备
android·大数据·elasticsearch
new_bie_B2 小时前
Android16 应用安装流程源码分析
android
帅次2 小时前
LazyColumn 懒加载、items 与 key
android·flutter·kotlin·android studio·webview
zhangphil2 小时前
Android显示系统RenderThread绘制HARDWARE/普通格式Bitmap与GPU与CPU处理机制
android