关于 kotlin-coroutines
面向编码 Agent 的 Kotlin Coroutines 参考技能,聚焦结构化并发、作用域所有权、取消与异常处理,也涵盖 Kotlin Multiplatform 和协程测试建议。
主要功能
- 01结构化并发与作用域所有权
- 02兼顾取消的异常处理
- 03Dispatcher 与主线程安全指导
- 04Kotlin Multiplatform 作用域实践
- 05协程测试提示
适用场景
- 01审查协程作用域的所有权
- 02安全处理取消与异常
- 03设计 KMP 协程 API
- 04规划协程和 Dispatcher 测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill kotlin-coroutines安装后在 Claude Code 中输入/kotlin-coroutines确认已加载。
详细介绍
Kotlin 协程
基于结构化并发:每个协程都在一个作用域中运行;取消和错误沿着父子层级传播。本参考重点介绍最常出错的两项原则:谁拥有作用域,以及如何处理异常而不破坏取消机制。
**主线程安全:**执行阻塞工作的函数负责使用 withContext(ioDispatcher);调用方调用挂起函数前不要切换调度器。
作用域所有权 — 优先使用 suspend fun,让调用方拥有作用域
在非 UI 类(存储库、管理器、用例、数据源)中保存 CoroutineScope,是一个强烈的代码审查警示信号:该类必须证明自己负责取消、错误报告、重启和生命周期——但大多数类做不到。解决方法几乎总是:将 API 改为 suspend,让调用方拥有作用域。
// DO — suspend fun; the caller owns the scope, cancellation propagates, exceptions surface
class ArticlesRepository(private val dataSource: ArticlesDataSource, private val io: CoroutineDispatcher) {
suspend fun bookmark(article: Article) = withContext(io) { dataSource.bookmark(article) }
}
class BookmarkViewModel(private val repo: ArticlesRepository) : ViewModel() {
fun onBookmark(a: Article) { viewModelScope.launch { repo.bookmark(a) } }
}
// DO NOT — store a scope and launch inside the repository
class ArticlesRepository(private val externalScope: CoroutineScope, /* ... */) {
suspend fun bookmark(a: Article) { externalScope.launch { dataSource.bookmark(a) }.join() }
}
**保存作用域为何危险:**作用域一旦被取消,此后在该作用域上执行的每个 launch 都会静默地以取消状态完成——没有异常,也没有日志。如果取消是由进程终止或配置错误的 DI 图引起的,该类仍会接受调用,却什么也不做,且不会发出任何提示。
工作必须在调用方结束后继续时
用户离开页面后仍必须完成的写入操作,不属于存储库——它应交由 WorkManager 任务处理,或交给一个具名的应用级类,该类有意拥有自己的作用域(例如注入 applicationScope 的 OfflineBookmarkQueue,仅在进程终止时取消)。具名类能明确、可测试且可观测地表明其生命周期——不像隐藏在某处的 externalScope.launch。
状态持有者例外 — 非挂起方法中何时可以使用 launch
UI 状态持有者只有在同时满足以下三项时,才能从非挂起事件回调中启动协程:(1)它确实拥有由视图层收集的 UI 状态;(2)它使用绑定到生命周期的作用域(viewModelScope / rememberCoroutineScope);(3)触发源是 UI 事件(点击、滑动、生命周期)——而不是存储库调用、后台计时器或 DI 钩子。任何一项不满足,都应公开 suspend fun,让真正的状态持有者拥有作用域。
反模式:init { launch } 和 DI 单例中的 launch
init { viewModelScope.launch { while (isActive) { … } } 会让工作变得不可见——没有具名触发器、没有重启路径,而且导航返回或前进时会悄悄重新启动。应改为通过 stateIn 暴露 StateFlow。同样,从构造函数中启动协程的 @Singleton(或 Hilt Initializer)会让协程“在 DI 实例化我的任何时候”启动,无人能观察到它——应使用 WorkManager.enqueueUniquePeriodicWork、将工作反转到消费者中,或将其放入具有显式 startX() 方法的具名类中。
诊断任何由 DI 管理或使用已保存作用域的 launch:启动时机在哪里定义?谁能观察它是否正在运行?不重启进程就能重新启动它吗? 如果答案是*“无处不在 / 无人 / 不能”*,就意味着需要重构。
取消
协作式取消:在长循环开头调用 ensureActive()(取消时会抛出异常),使用 isActive 检查但不抛出异常,使用 yield() 还能让出线程。delay / withContext 本身就支持取消。取消后必须执行的清理工作应放在 finally { withContext(NonCancellable) { … } } 中——NonCancellable 只能用于此处,不能作为通用的逃生通道。withTimeout 抛出的 TimeoutCancellationException 也是 CancellationException——捕获它时绝不能不重新抛出。(网络超时不要使用 withTimeout——这些由 OkHttp/Retrofit/Ktor 管理。)
异常处理——不要破坏取消机制
// DO — specific types
try { repo.login(u, p) } catch (e: IOException) { _state.value = Error("Network") }
// DO NOT — catch (e: Exception) / Throwable swallows CancellationException and breaks structured cancellation
- 捕获特定类型(
IOException、HttpException),不要捕获Exception/Throwable。 - 绝不要捕获
CancellationException后不重新抛出。 - 绝不要在挂起函数中使用
runCatching——它会捕获CancellationException。请使用会重新抛出该异常的suspendRunCatching:
suspend inline fun <R> suspendRunCatching(block: () -> R): Result<R> = try {
Result.success(block())
} catch (e: CancellationException) { throw e } catch (e: Throwable) { Result.failure(e) }
try/catch不会包裹launch {}——请将其放在协程体内部。- 在
supervisorScope中未等待的async {}会吞掉其异常——如果不需要结果,请使用launch。 viewModelScope已经使用了SupervisorJob——不要再额外添加一个。
KMP
不要使用 MainScope()——它是非结构化的(Dispatchers.Main + SupervisorJob()),存在与 GlobalScope 相同的问题。请从平台层注入绑定生命周期的 CoroutineScope(Android 上使用 viewModelScope;iOS 上使用绑定到视图控制器的作用域)。为平台实现注入 CoroutineDispatcher;如果某个目标平台缺少 Dispatchers.Main.immediate,请使用 expect/actual。
测试
协程和调度器测试见 android-skills:android-testing——尤其要注意双调度器陷阱(runTest 和 MainDispatcherRule 必须共用一个调度器),以及 Compose 测试中 StandardTestDispatcher 的默认设置。