别再这么写协程了!Marcin Moskała 剖析的 几个常见协程误区与重构

在 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 T_{total} = T_1 + T_2 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) \max(T_1, T_2) 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

相关推荐
mmsx2 小时前
Android 防二次打包第一道锁:签名校验与完整性校验
android·kotlin
mmsx2 小时前
Android 混淆不等于安全:二次打包链路完整走一遍
android·kotlin
小宋10214 小时前
Agent轨迹级评测实战:工具选择、预算超限与回归门禁
android·网络·人工智能·回归
墨天梦6 小时前
D05_ViewModel与单向数据流
android·kotlin
蒸鱼Yuzheng7 小时前
Android 构建可复现性:APK 指纹、文件级差异与供应链审计
android·apk·devops·软件供应链·可复现构建
墨天梦10 小时前
D03_Compose列表与稳定身份
android·gitee·kotlin
萌新杰少10 小时前
Kuikly股票查看软件开发体验——SaiRen
android·kotlin·客户端
vilya10 小时前
把 Python 塞进 APK:Chaquopy 打包实践
android·python
用户928172673901610 小时前
Android Compose版本的AI组件库来了。
android·kotlin