Kotlin 协程原理与 Android 中的最佳实践
Kotlin 协程原理与 Android 中的最佳实践
> 一句话收益:深入理解协程挂起/恢复机制与调度器原理,掌握在 Android 中规避内存泄漏、正确结构化并发的实战技巧。
> 适用版本:Kotlin 1.9+ / Coroutines 1.8+ / Android API 21+
> 阅读时长:约 18 分钟
---
1. 从一个真实 Bug 切入
某电商 App 在列表页滚动时偶发 ANR,Trace 显示主线程被阻塞约 4 秒。定位后发现:
// 问题代码:在 ViewModel 中错误地在主线程执行网络请求
class ProductViewModel : ViewModel() {
fun loadProducts() {
// ❌ runBlocking 会阻塞当前线程!
val products = runBlocking { repository.fetchProducts() }
_uiState.value = products
}
}
runBlocking 将主线程挂起等待 IO 完成,直接导致 ANR。这正是不理解协程挂起语义的典型误用。
---
2. 协程核心概念全景
2.1 协程 vs 线程
线程模型 协程模型
┌─────────────────────┐ ┌─────────────────────────────────┐
│ Thread-1 [运行中] │ │ Thread-1 │
│ Thread-2 [阻塞等IO]│ │ Coroutine-A [挂起,释放线程] │
│ Thread-3 [等待锁] │ │ Coroutine-B [运行中] │
│ Thread-4 [睡眠] │ │ Coroutine-C [挂起,等待] │
│ 内存:~2MB/线程 │ │ 内存:~几百字节/协程 │
└─────────────────────┘ └─────────────────────────────────┘
协程是轻量级的可挂起计算,挂起时不阻塞线程,线程可以去执行其他协程。
2.2 挂起函数(suspend function)原理
编译器对 suspend 函数进行 CPS(续延传递风格)变换:
原始 suspend fun fetchData(): String
编译后等价于:
fun fetchData(continuation: Continuation
): Any
每个挂起点生成一个状态机(label 字段驱动),挂起时保存局部变量快照到 Continuation 对象,恢复时从快照还原继续执行:
suspend fun loadUser(id: String): User {
// label=0: 初始状态
val token = getToken() // 挂起点1 → label=1
// label=1: getToken 完成后恢复
val user = fetchUser(id, token) // 挂起点2 → label=2
// label=2: fetchUser 完成后恢复
return user
}
// 编译器生成的状态机伪代码:
fun loadUser(id: String, cont: Continuation
): Any {
val sm = cont as? LoadUserStateMachine ?: LoadUserStateMachine(cont)
when (sm.label) {
0 -> {
sm.id = id; sm.label = 1
return getToken(sm) // 挂起,返回 COROUTINE_SUSPENDED
}
1 -> {
val token = sm.result as String; sm.label = 2
return fetchUser(id, token, sm)
}
2 -> return sm.result as User
}
}
---
3. 调度器(Dispatcher)原理
3.1 调度器类型与适用场景
| 调度器 | 线程池 | 适用场景 |
|--------|--------|----------|
| Dispatchers.Main | 主线程 | UI 更新、LiveData/Flow 收集 |
| Dispatchers.IO | 最多 64 个线程(可配置) | 网络请求、文件读写、数据库操作 |
| Dispatchers.Default | CPU 核心数线程 | CPU 密集计算、数据处理 |
| Dispatchers.Unconfined | 不限定线程 | 测试或特殊场景,生产慎用 |
3.2 withContext 切换调度器
协程执行流:
Main线程: [launch开始] ──→ [withContext(IO)] ──→ [withContext(Main)]
↓ ↓
IO线程池: [执行网络请求] [返回结果]
withContext 是非阻塞的上下文切换,切换后恢复时回到调用方调度器, 无需手动切回。
3.3 CoroutineContext 组成
CoroutineContext = Job + CoroutineDispatcher + CoroutineName + CoroutineExceptionHandler
示例:
val ctx = Dispatchers.IO +
SupervisorJob() +
CoroutineName("DataLoader") +
CoroutineExceptionHandler { _, e -> Log.e("TAG", e.message) }
---
4. 代码示例
4.1 正确写法:ViewModel 中的结构化并发
class ProductViewModel @Inject constructor(
private val repository: ProductRepository
) : ViewModel() {
private val _uiState = MutableStateFlow
(UiState.Loading)
val uiState: StateFlow
= _uiState.asStateFlow()
fun loadProducts() {
// ✅ viewModelScope 绑定 ViewModel 生命周期,自动取消
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
// ✅ withContext 切换到 IO 线程执行网络请求
val products = withContext(Dispatchers.IO) {
repository.fetchProducts()
}
// ✅ 自动切回 Main 线程更新 UI
_uiState.value = UiState.Success(products)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
// ✅ 并行加载多个数据源
fun loadDashboard() {
viewModelScope.launch {
val productsDeferred = async(Dispatchers.IO) { repository.fetchProducts() }
val bannerDeferred = async(Dispatchers.IO) { repository.fetchBanners() }
// awaitAll 等待所有任务,任一失败则取消其他
val (products, banners) = awaitAll(productsDeferred, bannerDeferred)
_uiState.value = UiState.Dashboard(products, banners)
}
}
}
4.2 错误写法 → 问题 → 正确写法
错误写法 1:GlobalScope 导致内存泄漏// ❌ GlobalScope 生命周期与 App 相同,ViewModel 销毁后协程继续运行
GlobalScope.launch {
val data = repository.fetch()
withContext(Dispatchers.Main) {
updateUI(data) // ViewModel 已销毁,崩溃!
}
}
问题:协程不随 ViewModel 销毁而取消,持有 View 引用导致内存泄漏。
// ✅ 使用 viewModelScope,ViewModel.onCleared() 时自动取消所有子协程
viewModelScope.launch { ... }
错误写法 2:在 suspend 函数中用 try-catch 捕获 CancellationException
// ❌ CancellationException 不应被吞掉,否则取消机制失效
suspend fun fetchData() {
try {
delay(1000)
} catch (e: Exception) { // 捕获了 CancellationException!
Log.e("TAG", "error") // 协程取消时也走这里,然后继续执行
}
}
问题: CancellationException 是协程取消的信号,被捕获后协程无法正常终止。
// ✅ 单独处理 CancellationException 并重新抛出
suspend fun fetchData() {
try {
delay(1000)
} catch (e: CancellationException) {
throw e // 必须重新抛出
} catch (e: Exception) {
Log.e("TAG", "error: ${e.message}")
}
}
错误写法 3:在 launch 中误用 async 异常传播
// ❌ async 的异常在 await() 时才抛出,launch 中若不 await 则异常被吞
viewModelScope.launch {
val deferred = async { riskyOperation() }
// 忘记调用 deferred.await(),异常被静默忽略
doOtherWork()
}
// ✅ 始终 await() 或用 supervisorScope 隔离失败
viewModelScope.launch {
supervisorScope {
val deferred = async { riskyOperation() }
try {
val result = deferred.await()
} catch (e: Exception) {
handleError(e)
}
}
}
---
5. 最佳实践
5.1 使用结构化并发,绑定组件生命周期
做法:Activity/Fragment 用lifecycleScope,ViewModel 用 viewModelScope,绝不使用 GlobalScope。 原因:结构化并发保证父协程取消时所有子协程自动取消,避免资源泄漏和悬空引用。 对比:使用 GlobalScope 时,即使 Activity 已销毁,协程仍持有对 View 的引用继续运行,轻则内存泄漏,重则崩溃。
5.2 Flow 替代回调,在 lifecycle-aware 的 scope 中收集
做法:Repository 层返回Flow,在 UI 层使用 repeatOnLifecycle(Lifecycle.State.STARTED) 收集。 原因: repeatOnLifecycle 在 onStop 时自动取消收集,在 onStart 时重新订阅,避免后台状态更新导致崩溃。 对比:使用 lifecycleScope.launch { flow.collect {...} } 在后台时仍然收集,可能在 onStop 后更新 UI 抛出异常。
// ✅ 正确方式
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> render(state) }
}
}
5.3 IO 操作永远在 Dispatchers.IO 执行
做法:所有 Repository 层的网络/数据库方法内部使用withContext(Dispatchers.IO),调用方无需关心线程切换。 原因:IO 调度器维护专用线程池,避免占用 Default 调度器的 CPU 线程,也不阻塞主线程。 对比:如果 suspend 函数内部实际执行了 IO 但不切换调度器,调用方在主线程使用时可能导致 NetworkOnMainThreadException 或 ANR。
5.4 异常处理用 CoroutineExceptionHandler + try-catch 双保险
做法:launch 用 CoroutineExceptionHandler 捕获未处理异常, async 的异常在 await() 处用 try-catch 处理。 原因: launch 和 async 的异常传播规则不同: launch 立即传播到父协程, async 延迟到 await() 时。 对比:仅用 CoroutineExceptionHandler 无法捕获 async 异常;仅用 try-catch 可能遗漏 launch 中的未预期异常。
5.5 测试时注入 TestDispatcher
做法:生产代码通过构造函数注入CoroutineDispatcher,测试时替换为 UnconfinedTestDispatcher 或 StandardTestDispatcher。 原因: TestDispatcher 支持时间控制( advanceUntilIdle、 runCurrent),使异步代码变为可测试的同步代码。 对比:硬编码 Dispatchers.IO 导致单元测试中协程行为不可控,需要真实等待时间,测试不稳定。
---
6. 常见坑点
坑1:协程泄漏——取消后仍在执行
现象:Fragment 退出后日志仍在打印,内存分析发现 Fragment 实例未被回收。 原因:在viewLifecycleOwner.lifecycleScope 之外启动协程,或持有了外部引用。 复现:
class MyFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// ❌ 使用 fragment 的 lifecycleScope 而非 viewLifecycleOwner.lifecycleScope
lifecycleScope.launch {
while (true) {
delay(1000)
Log.d("TAG", "tick") // 视图销毁后仍在打印
}
}
}
}
解决:Fragment 中始终使用 viewLifecycleOwner.lifecycleScope,它绑定 View 的生命周期(onDestroyView 时取消)。
坑2:在非挂起上下文中调用 suspend 函数
现象:编译错误Suspend function can only be called within a coroutine body。 原因: suspend 函数只能从协程或其他 suspend 函数中调用。 复现:在 onClick 等普通回调中直接调用 suspend 函数。 解决:
button.setOnClickListener {
// ✅ 在 lifecycleScope 中启动协程
lifecycleScope.launch {
val result = suspendFunction()
updateUI(result)
}
}
坑3:SupervisorJob 误用导致异常传播失效
现象:子协程失败后父协程和兄弟协程也被取消,与预期不符。 原因:SupervisorJob 需要作为顶层 Job 使用,若子协程使用普通 launch(继承父 Job),则失败仍会传播。 复现:
// ❌ 错误用法:SupervisorJob 传入 launch 的 context 无效
val scope = CoroutineScope(SupervisorJob())
scope.launch {
launch { throw Exception("child failed") } // 仍会取消父协程!
}
解决:
// ✅ 使用 supervisorScope 包裹,或让 SupervisorJob 作为 scope 的直接 Job
val scope = CoroutineScope(SupervisorJob())
scope.launch { // 此协程是 SupervisorJob 的直接子协程
// 此 launch 失败不影响兄弟协程
}
坑4:Flow 在 Cold 状态被多次订阅导致重复请求
现象:多个观察者订阅同一个Flow 时,触发了多次网络请求。 原因: Flow 默认是冷流(Cold),每次 collect 都会重新执行 Flow 构建块。 复现:
val userFlow: Flow
= flow {
val user = api.fetchUser() // 每次 collect 都执行!
emit(user)
}
// 两个地方 collect → 两次网络请求
解决:使用 stateIn 或 shareIn 将冷流转为热流:
val userFlow: StateFlow
= repository.getUserFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), // 5秒无订阅自动停止
initialValue = null
)
坑5:delay 在测试中导致测试超时
现象:含有delay(5000) 的协程单元测试耗时 5 秒,或测试超时失败。 原因:使用了真实时间调度,未使用测试专用调度器。 复现:测试代码中没有使用 runTest 或 TestCoroutineScheduler。 解决:
@Test
fun testWithDelay() = runTest {
// ✅ runTest 自动跳过 delay,虚拟时钟前进
val result = viewModel.loadWithDelay()
advanceUntilIdle()
assertEquals(expected, result)
}
---
7. 总结
1. 协程挂起原理:编译器将 suspend 函数转换为状态机 + Continuation,挂起不阻塞线程,线程可服务其他协程。
2. 调度器选择:主线程 UI 操作用 Main,IO 操作用 IO,CPU 密集用 Default,withContext 是安全的非阻塞切换方式。
3. 结构化并发:始终使用 viewModelScope / lifecycleScope,绑定组件生命周期,禁止裸用 GlobalScope。
4. 异常处理:CancellationException 必须重新抛出;launch 与 async 异常传播规则不同,需区别对待;SupervisorJob 需正确放置。
5. Flow 最佳实践:UI 层用 repeatOnLifecycle 收集,热流共享用 stateIn/shareIn,测试用 runTest + TestDispatcher。
> 核心结论:协程的本质是由编译器生成状态机驱动的轻量并发原语,理解挂起/恢复机制和结构化并发是规避 Android 中协程陷阱的根本。
---
参考资料
- AOSP 源码:kotlinx.coroutines.CancellableContinuationImpl、kotlinx.coroutines.internal.DispatchedTask
- 协程测试指南
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)