在 Kotlin 协程的日常开发中,"代码能跑通"与"代码写得优雅高效"之间往往隔着一层对协程底层设计理念(如结构化并发、挂起语义、线程调度)的理解。
本文基于 Marcin Moskała(《Kotlin Coroutines》作者)在技术分享中总结的 8 个典型反模式,深入剖析协程开发中最容易踩坑的常见误区,并给出生产环境下的重构方案与最佳实践。
误区 1:不必要的同步/串行执行(Unnecessary Synchronicity)
问题代码
kotlin
suspend fun fetchTrainerDetails(key: String) {
val profile = client.fetchUserProfile(key)
val trainingData = client.fetchTrainerWorkshops(key)
TrainingDetails(profile, trainingData)
}
陷阱诊断
fetchUserProfile 与 fetchTrainerWorkshops 是两个互相独立的网络请求。直接在 suspend fun 中顺序调用挂起函数,会导致第二个请求必须等待第一个请求完全返回后才开始执行。总耗时为两者之和( Ttotal=T1+T2),浪费了并发性能。
规范重构
kotlin
suspend fun fetchTrainerDetails(key: String) = coroutineScope {
val profile = async { client.fetchUserProfile(key) }
val trainingData = async { client.fetchTrainerWorkshops(key) }
TrainingDetails(profile.await(), trainingData.await())
}
利用 coroutineScope 确保结构化并发,配合 async 同时发起异步请求,总耗时缩短为 max(T1,T2)。
误区 2:错误的 async/await 使用方式(Incorrect async/await Use)。
问题代码
kotlin
suspend fun fetchTrainerDetails(key: String) = coroutineScope {
val profile = async { client.fetchUserProfile(key) }.await()
val trainingData = async { client.fetchTrainerWorkshops(key) }.await()
TrainingDetails(profile, trainingData)
}
陷阱诊断
在 async { ... } 后面紧接着调用 .await(),会导致当前协程在此处被立即挂起,直到第一个任务返回后才会继续向下执行第二行代码。这种写法使并发调用退化回串行挂起,且无谓地增加了创建子协程(Deferred)的框架开销。
规范重构
应先统一启动所有的 async 任务拿到句柄,在需要组合数据时再统一 await():
kotlin
suspend fun fetchTrainerDetails(key: String) = coroutineScope {
val profileDeferred = async { client.fetchUserProfile(key) }
val trainingDataDeferred = async { client.fetchTrainerWorkshops(key)
TrainingDetails(profileDeferred.await(), trainingDataDeferred.await())
}
误区 3:不必要的协程/过度设计(Unnecessary Coroutines)
问题代码
kotlin
suspend fun fetchTrainerDetails(key: String) = coroutineScope {
val profile = async { client.fetchUserProfile(key) }
val trainingData = async { client.fetchTrainerWorkshops(profile.await().userId) }
TrainingDetails(profile.await(), trainingData.await())
}
陷阱诊断
当任务之间存在强数据依赖 (请求 B 依赖请求 A 返回的 userId)时,两个请求本质上无法并行。此时硬套 async / await 只会让第二个子协程在创建后立即挂起等待,引入了不必要的复杂性和协程创建开销。
规范重构
对于有明确依赖关系的顺序逻辑,回归最纯粹的顺序挂起代码即可:
kotlin
suspend fun fetchTrainerDetails(key: String): TrainingDetails {
val profile = client.fetchUserProfile(key)
val trainingData = client.fetchTrainerWorkshops(profile.userId)
return TrainingDetails(profile, trainingData)
}
误区 4:在 coroutineScope 中不当使用 launch(Incorrect launch Use)
问题代码
kotlin
suspend fun updateUser() = coroutineScope {
val user = userClient.fetchUser()
launch { userDatabase.saveUser(user) }
user
}
陷阱诊断
coroutineScope 的核心规则是:必须等待其内部所有子协程(包括 launch)完成后才会退出并返回 。很多开发者误以为写了 launch 就能不阻塞当前函数并立即返回 user,但实际上 coroutineScope 会死死挂起直到数据库保存完毕。
规范重构
- 场景 A(保存完再返回,默认推荐) :直接顺序挂起,无需
launch与coroutineScope。
kotlin
suspend fun updateUser(): User {
val user = userClient.fetchUser()
userDatabase.saveUser(user)
return user
}
- 场景 B(真正的"后台离线保存",不阻塞当前调用方返回):交给应用生命周期的外部作用域处理。
kotlin
suspend fun updateUser(): User {
val user = userClient.fetchUser()
externalScope.launch { userDatabase.saveUser(user) } // 注入 ApplicationScope
return user
}
误区 5:滥用 GlobalScope(Using GlobalScope)
问题代码
kotlin
suspend fun updateUser() {
val user = userClient.fetchUser()
GlobalScope.launch { userDatabase.saveUser(user) }
return user
}
陷阱诊断
GlobalScope 启动的是进程级别的顶级协程,无法被统一取消(Cannot be cancelled)。它彻底脱离了结构化并发的生命周期树,不仅极易引发内存泄漏和无效后台计算,还给单元测试带来了极大的困扰(测试框架无法拦截和等待其完成)。
规范重构
如果确实需要超越当前作用域生命周期的"后台应用级任务",应通过依赖注入传入受控的 ApplicationScope(内部配备 SupervisorJob),而不是使用全局单例 GlobalScope。
kotlin
class UserRepository(
private val userClient: UserClient,
private val userDatabase: UserDatabase,
private val externalScope: CoroutineScope // 注入作用域
) {
suspend fun updateUser(): User {
val user = userClient.fetchUser()
externalScope.launch { userDatabase.saveUser(user) }
return user
}
}
误区 6:非必要使用 runBlocking(Using runBlocking If Not Necessary)
问题代码
kotlin
// 绝对不要在挂起函数中使用 runBlocking!
suspend fun getToken() = runBlocking { ... }
// 绝对不要在 UI 线程(如 onClick 回调)中使用 runBlocking!
fun onClick() = runBlocking { ... }
陷阱诊断
runBlocking 的作用是阻塞当前线程直至内部协程结束。在挂起函数中使用它破坏了挂起非阻塞的特性;在 UI 线程中使用它则极易造成界面卡顿甚至 ANR。
核心准则与合法场景
runBlocking 仅适用于以下条件的交集:处于允许被阻塞的线程中 + 必须同步等待结果/桥接同步 API。
kotlin
// 合法正例:在 OkHttp Interceptor(纯后台工作线程)中桥接同步接口与挂起函数
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val authToken = runBlocking { getToken() } // 在后台 I/O 线程同步阻塞等待 Token
val withHeader = originalRequest.newBuilder()
.header("Authorization", "Bearer $authToken")
.build()
return chain.proceed(withHeader)
}
误区 7:在 Dispatchers.IO 之外执行线程阻塞调用(Blocking Calls Outside Dispatchers.IO)
问题代码
kotlin
class DiscSaveRepository(
private val discReader: DiscReader
) : SaveRepository {
override suspend fun loadSave(name: String): SaveData =
discReader.read("save/$name") // 假设 discReader.read 是阻塞式磁盘读写
}
陷阱诊断
根据 Android 及 Kotlin 官方架构规范,所有的 suspend fun 都必须是主线程安全的(Main-safe)。若挂起函数内部包含了传统的同步阻塞 I/O(如文件、数据库读写)却没有切换调度器,当 UI 层直接调用该函数时,依然会卡死主线程。
规范重构
使用 withContext(Dispatchers.IO) 将阻塞调用包装起来,确保 Main-safety:
kotlin
override suspend fun loadSave(name: String): SaveData = withContext(Dispatchers.IO) {
discReader.read("save/$name")
}
误区 8:硬编码静态调度器(Using Static Dispatcher)
问题代码
kotlin
class DiscSaveRepository(
private val discReader: DiscReader
) : SaveRepository {
override suspend fun loadSave(name: String): SaveData =
withContext(Dispatchers.IO) { // 硬编码了 Dispatchers.IO
discReader.read("save/$name")
}
}
陷阱诊断
在类内部直接硬编码 Dispatchers.IO 会导致该类无法正常进行单元测试(测试环境无法拦截并替换真实的多线程 IO 调度器)。同时,盲目共用默认的 IO 线程池也无法对特定高负载任务做并发度隔离。
规范重构
将 CoroutineDispatcher 作为构造参数进行依赖注入(DI) ,并可结合 limitedParallelism 控制最大并发数:
kotlin
class DiscSaveRepository(
private val discReader: DiscReader,
private val dispatcher: CoroutineDispatcher // 构造注入
) : SaveRepository {
override suspend fun loadSave(name: String): SaveData =
withContext(dispatcher) {
discReader.read("save/$name")
}
}
// 生产环境中通过 DI 容器(如 Hilt/Koin)提供:
val ioDispatcher = Dispatchers.IO
val discSaveDispatcher = Dispatchers.IO.limitedParallelism(20) // 限制特定业务最大并发数为 20
总结:Kotlin 协程避坑法则速查表
| 场景 / 需求 | 避坑反模式 | 推荐最佳实践 |
|---|---|---|
| 独立、无依赖的并发任务 | 顺序调用挂起函数,或连续编写 async { ... }.await() |
使用 coroutineScope 统一发起多个 async 任务,再统一调用 await() 获取结果 |
| 存在明确依赖关系的顺序任务 | 强行套用 async 和 await() |
编写直观的顺序 suspend 代码 |
| 跨生命周期的后台异步任务 | 使用 GlobalScope.launch |
使用通过依赖注入提供、生命周期明确的 ApplicationScope |
| 同步 API 调用挂起函数 | 在 UI 线程或 suspend 函数内部盲目使用 runBlocking |
仅在确定处于后台工作线程、且确实需要同步桥接时使用 runBlocking |
| 阻塞式 I/O 读写 | 直接在默认线程或主线程执行阻塞操作 | 使用 withContext 切换到合适的 Dispatcher,并通过依赖注入提供调度器 |
Kotlin Coroutines: Common Mistakes That Lead to Production Bugs