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 双保险

做法launchCoroutineExceptionHandler 捕获未处理异常, async 的异常在 await() 处用 try-catch 处理。 原因launchasync 的异常传播规则不同: launch 立即传播到父协程, async 延迟到 await() 时。 对比:仅用 CoroutineExceptionHandler 无法捕获 async 异常;仅用 try-catch 可能遗漏 launch 中的未预期异常。

5.5 测试时注入 TestDispatcher

做法:生产代码通过构造函数注入 CoroutineDispatcher,测试时替换为 UnconfinedTestDispatcherStandardTestDispatcher原因TestDispatcher 支持时间控制( advanceUntilIdlerunCurrent),使异步代码变为可测试的同步代码。 对比:硬编码 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 → 两次网络请求

解决:使用 stateInshareIn 将冷流转为热流:
val userFlow: StateFlow
  
    = repository.getUserFlow()

   

.stateIn(

scope = viewModelScope,

started = SharingStarted.WhileSubscribed(5000), // 5秒无订阅自动停止

initialValue = null

)

坑5:delay 在测试中导致测试超时

现象:含有 delay(5000) 的协程单元测试耗时 5 秒,或测试超时失败。 原因:使用了真实时间调度,未使用测试专用调度器。 复现:测试代码中没有使用 runTestTestCoroutineScheduler解决
@Test

fun testWithDelay() = runTest {

// ✅ runTest 自动跳过 delay,虚拟时钟前进

val result = viewModel.loadWithDelay()

advanceUntilIdle()

assertEquals(expected, result)

}

---

7. 总结

1. 协程挂起原理:编译器将 suspend 函数转换为状态机 + Continuation,挂起不阻塞线程,线程可服务其他协程。

2. 调度器选择:主线程 UI 操作用 Main,IO 操作用 IO,CPU 密集用 DefaultwithContext 是安全的非阻塞切换方式。

3. 结构化并发:始终使用 viewModelScope / lifecycleScope,绑定组件生命周期,禁止裸用 GlobalScope

4. 异常处理CancellationException 必须重新抛出;launchasync 异常传播规则不同,需区别对待;SupervisorJob 需正确放置。

5. Flow 最佳实践:UI 层用 repeatOnLifecycle 收集,热流共享用 stateIn/shareIn,测试用 runTest + TestDispatcher

> 核心结论:协程的本质是由编译器生成状态机驱动的轻量并发原语,理解挂起/恢复机制和结构化并发是规避 Android 中协程陷阱的根本。

---

参考资料

- Kotlin 协程官方指南

- Android 协程最佳实践

- repeatOnLifecycle API 指南

- Flow 官方文档

- AOSP 源码:kotlinx.coroutines.CancellableContinuationImplkotlinx.coroutines.internal.DispatchedTask

- 协程测试指南

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐