关于 kotlin-coroutines-structured-concurrency
面向 Kotlin 协程代码编写与评审的技能,主张让调用方管理协程作用域,并用挂起 API 保持生命周期、取消和错误处理可见。
主要功能
- 01识别类持有作用域等反模式
- 02建议由调用方管理协程生命周期
- 03区分 UI 状态持有者与业务层职责
- 04检查取消异常是否被吞掉
- 05指导后台任务的显式启动方式
适用场景
- 01评审仓库或用例中的协程作用域
- 02将 fire-and-forget API 改为挂起 API
- 03排查作用域取消导致的静默失败
- 04审查初始化器或单例中的后台启动
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill kotlin-coroutines-structured-concurrency安装后在 Claude Code 中输入/kotlin-coroutines-structured-concurrency确认已加载。
详细介绍
Kotlin 协程:结构化并发
核心原则
结构良好的协程是一个自包含的异步工作单元——只有一个入口和一个出口,其作用域与调用点已知的生命周期绑定。
作用域通常应与调用方的生命周期绑定,而不是作为被调用方的属性存储。 存储的 CoroutineScope 是一个需要重点审查的信号:类必须证明它负责取消、错误报告、重启行为和生命周期。大多数存储库、管理器、用例和数据源无法证明这一点,因此应改为提供 suspend API。
解决方法几乎总是相同的:将 API 改为 suspend,让调用方拥有作用域。
何时使用此技能
编写或审查 Kotlin 代码时,如果看到以下任一情况:
- 类中有
private val scope: CoroutineScope(构造函数参数被存储为属性) - 有
init { scope.launch { ... } }代码块 - 一个非挂起的公共函数,其函数体是
scope.launch { ... } - 在支持挂起的应用代码中使用
runBlocking { ... },或者在应使用runTest的测试中使用它 - 在挂起调用周围使用
runCatching { suspendCall() },或使用catch捕获Exception/Throwable,却没有重新抛出CancellationException - 在挂起操作周围使用
catch (e: CancellationException)(或等效写法),却没有重新抛出异常
静默取消错误
无主的 CoroutineScope 属性之所以如此危险,原因在于:作用域一旦被取消,此后在其上执行的每个 launch 都会静默地以取消状态完成——没有异常、没有日志,什么都没有。 工作就这样没有执行。这是最难诊断的协程错误之一,常见于类持有自身并不拥有的生命周期的长期引用时。
如果 API 是 suspend,就不会发生这种情况:调用方的作用域要么仍然存活(工作会运行),要么调用点取消了调用(调用方会知道)。
反模式与修复方法
1. 将 CoroutineScope 存储为属性
// ❌ BAD
@Inject
class UserRepository(
private val scope: CoroutineScope,
private val api: UserApi,
) {
fun refresh() {
scope.launch { _state.value = api.fetchUser() }
}
}
// ✅ GOOD
@Inject
class UserRepository(
private val api: UserApi,
) {
suspend fun refresh(): User = api.fetchUser()
}
存储库不再需要了解协程。调用方(ViewModel、用例)可以决定使用哪个作用域、如何处理错误以及采用何种取消语义。
2. 在 init 代码块中启动协程
// ❌ BAD: construction-time side effect, unbounded work
class UserSession(private val scope: CoroutineScope, private val api: Api) {
init { scope.launch { _user.value = api.load() } }
}
构造函数会立即返回。调用方无法 await 加载、无法查看错误,也无法取消。这个类虽然“存活”,但其状态未定义。
// ✅ GOOD: explicit bootstrap, caller owns the suspension
class UserSession(private val api: Api) {
private var _user: User? = null
val user: User get() = checkNotNull(_user) { "Call init() first" }
suspend fun init() { _user = api.load() }
}
3. 在非 UI 类中启动即忘协程
非挂起的公共函数位于非 UI 类(存储库、管理器、用例、数据源)中,并在类拥有的作用域内启动协程。调用方无法获得结果、错误或取消能力,也无法保证工作确实运行过。
// ❌ BAD — repository with stored scope and fire-and-forget public API
class AnalyticsClient(private val scope: CoroutineScope, private val api: Api) {
fun track(event: Event) {
scope.launch { api.send(event) } // caller has no idea what happens
}
fun signOut() {
scope.launch { api.signOut() } // silent failure if scope cancelled
}
}
// ✅ GOOD
class AnalyticsClient(private val api: Api) {
suspend fun track(event: Event) = api.send(event)
suspend fun signOut() = api.signOut()
}
例外:UI ↔ 状态持有者边界
UI 框架不支持挂起。Composable 的 onClick、Fragment 的 onKeyEvent、Activity 的 onNewIntent——都不能 suspend。状态持有者(ViewModel、Decompose Component、功能模型等——任何负责接收 UI 事件并持有 UI 状态的对象)就是将一次性 UI 事件转换为与 UI 生命周期绑定的异步工作的边界。这正是它的职责。
// ✅ GOOD — state holder absorbs a non-suspending UI event onto its scope
class FavouritesViewModel(private val repo: FavouritesRepository) : ViewModel() {
fun onToggleFavourite(item: Item) {
viewModelScope.launch { repo.toggleFavourite(item) }
}
}
// in Compose:
ListItem(onClick = { viewModel.onToggleFavourite(item) })
这不是“发起后不管”的反模式。必须同时满足以下三个条件:
- 面向某个 UI 界面的状态持有者——ViewModel、Decompose Component、功能模型或同等的 UI 状态持有者。不能是仓库、管理器、用例或数据源。
- 与生命周期绑定的作用域——
viewModelScope、销毁时会取消的 Component 的coroutineScope、Composable 的rememberCoroutineScope()。不能是AppScope、注入的长生命周期作用域,也不能是临时创建的CoroutineScope(...)。 - 调用方确实是 UI 事件——Composable 回调、按键处理程序、生命周期钩子。不能是其他业务逻辑类通过状态持有者发起调用。
下层的仓库/用例/数据源仍然应公开 suspend API。状态持有者是唯一适合进行从非挂起到挂起转换的层。
“感觉像状态持有者”还不够。关键问题是:“UI 是否直接绑定到它?”如果不是,则不适用此例外。
4. 未注入的已存储作用域
同一种反模式,只是没有注入作用域:
// ❌ BAD — same problem, scope is constructed in-class instead of injected
class FooManager {
private val scope = MainScope()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
此时没有任何对象拥有其生命周期,它会永远存在。应改用 suspend API。
如果实例化嵌套在函数体内,情况也一样——fun foo() { CoroutineScope(...).launch { … } } 只是多绕了几步创建并存储作用域。每次调用都会泄漏一个无法取消的新作用域;将它封装到 by lazy 属性中也无法解决根本问题(这个作用域根本不应该存在)。
5. 由 DI 绑定的单例/启动器发起协程
有一种不易察觉的特定模式:由 DI 绑定的类(@SingleIn(AppScope)、@Singleton、Initializer.initialize())从构造函数/init 块/initialize() 中发起协程。这样启动的工作会:
- 在不确定的时间开始——绑定关系何时被解析,就会在何时启动。冷启动顺序不可见。
- **没有可观察的生命周期。**代码库中没有其他对象能知道它是否正在运行或是否已崩溃。
- **没有
stop()/重启路径。**如果上游进入异常状态,循环就无法取消。 - **没有可供搜索的调用代码。**读者无法查明“谁在何时启动了它”。
§1 指出,作用域应与调用方的生命周期绑定。由 DI 绑定的变体间接违反了这一点:作用域可能是注入的,但 launch 被隐藏在构造过程中——效果相同,却更难发现。
// ❌ BAD — singleton boots work as a side effect of being constructed
@SingleIn(AppScope::class)
@Inject
class TokenRefresher(
@ForScope(AppScope::class) private val scope: CoroutineScope,
private val auth: AuthService,
) {
init {
scope.launch {
while (isActive) {
delay(5.minutes)
auth.refreshIfNeeded()
}
}
}
}
// ❌ ALSO BAD — Initializer.initialize() that *launches*, not just registers
class TokenInvalidatorInitializer @Inject constructor(
@ForScope(AppScope::class) private val scope: CoroutineScope,
private val store: AuthStore,
private val invalidator: TokenInvalidator,
) : Initializer {
override fun initialize() {
scope.launch { store.tokenChanges.collect { invalidator.invalidate() } }
}
}
两种情况看起来都像“应用级单例”,但不适用场景中的例外并不意味着可以从 init/initialize() 中发起协程。它只是允许单例在 API 为挂起函数时拥有作用域。
首先要问:这个后台循环类真的有必要存在吗?
大多数后台循环类之所以存在,只是因为没人反过来审视这个问题。以下有三种方案,按优先级排序:
模式 1——将逻辑移入消费者。 这个类会一直观察状态,并在状态变化时作出反应。但总有人会修改状态——例如登出流程、配置切换或标志更新处理程序。修改状态的位置已经处于协程上下文中,也是直接执行相关工作的自然位置。
// ✅ GOOD — no background loop, no scope, no class. The mutation site does the work.
class Authenticator(
private val authStore: AuthStore,
private val tokenInvalidator: TokenInvalidator,
) {
suspend fun signOut() {
authStore.clearTokens()
tokenInvalidator.invalidate() // direct call at the mutation site
}
}
后台循环类会被删除。状态变化时,就在原处执行相关工作。
适用场景:状态的消费者有明确的生命周期(例如用例、Authenticator 或服务处理程序),并且可以在调用处直接执行响应逻辑。
模式 2——计划任务。 适用于真正定期执行或延迟执行的任务。使用 WorkManager / BGTaskScheduler。入队操作只执行一次;将其设为挂起函数,并从已在启动时运行的协调器中调用一次。
模式 3——明确命名的启动位置。 有时,消费者是没有可观察生命周期的同步 API(例如 OpenTelemetry 的 Sampler.shouldSample(...)、AIDL 存根扇出或广播接收器桥接)。观察逻辑必须放在某个支持协程的位置,但必须放在明确命名的调用位置,而不是类自身的 init 中。
// ✅ GOOD — work is named; an explicit call site owns the launch
@SingleIn(AppScope::class)
class OtelConfigurableSampler(...) : Sampler {
@Volatile private var delegate: Sampler = ...
suspend fun observeRate(featureFlags: FeatureFlags) {
featureFlags.observe(OTEL_SAMPLING_RATE).collect { rate ->
delegate = Sampler.traceIdRatioBased(rate.coerceIn(0.0, 1.0))
}
}
override fun shouldSample(...) = delegate.shouldSample(...)
}
// wired explicitly at the OTel SDK init module:
applicationScope.launch { otelSampler.observeRate(featureFlags) }
适用场景:消费者是一个没有可观察生命周期、会调用你所提供的接口的同步 API。启动操作无法移入消费者,但仍必须位于明确命名的调用位置。
判断适用模式
“我能观察到消费者的生命周期吗?”
- 能,而且消费者已经处于协程上下文中 → 模式 1。将订阅逻辑移入消费者;删除后台循环类。
- 任务需要定期执行或延迟执行 → 模式 2。通过挂起函数入队,并且只调用一次。
- 不能,消费者是没有可观察生命周期的同步 API → 模式 3。使用明确的启动位置,而不是
init。
如果看起来有第四种答案——例如“我想要一个 Bootable 接口来替我启动所有任务”——那只是多加了一层抽象的同一种反模式。重点在于让启动操作清晰可见;通过接口自动发现会违背这一点。
初始化器仍然可以使用——前提是它们只负责注册
当 initialize() 注册监听器或钩子时,Initializer 模式是正确的。问题在于 initialize() 启动了协程。
// ✅ GOOD Initializer — registers a contributor, doesn't launch
class FavouritesContributorInitializer @Inject constructor(
private val registry: ContributorRegistry,
private val favouritesContributor: FavouritesContributor,
) : Initializer {
override fun initialize() {
registry.register(favouritesContributor)
}
}
Initializer.initialize() 不得 launch 协程。 如果你的实现会这样做,就需要考虑模式 1、2 或 3。
代码审查诊断
- 启动时机在哪里定义?如果答案是“DI 实例化我的时候”,那就有问题。
- 谁能观察任务是否正在运行?如果答案是“没人”,那就有问题。
- 谁能停止或重启任务?如果答案是“没人”,那就有问题。
- 读者能否通过 grep 找到启动位置?如果不能,那就有问题。
如果答案分别是“消费者/协调器/明确命名的调用位置”,那就没问题。
6. 吞掉 CancellationException
如果围绕 suspend 调用的 catch 子句会匹配 CancellationException(直接匹配,或通过 Exception / Throwable 匹配),却没有重新抛出异常,通常就会把取消变成悄无声息的成功。父协程会认为子协程已完成;子协程却仍在运行(或其副作用仍在发生);取消契约因此遭到破坏。
这与 §1 中存储作用域的错误有相同的失败模式,只是从另一端来看:§1 将工作从调用者的生命周期中隐藏起来;这里则是将取消从工作中隐藏起来。
// ❌ BAD — catches CancellationException, never rethrows
suspend fun fetch() {
try {
api.load()
} catch (e: Exception) { // matches CancellationException too
logger.warn("load failed", e)
}
}
// ❌ ALSO BAD — runCatching has the same problem
suspend fun fetch() {
runCatching { api.load() }
.onFailure { logger.warn("load failed", it) }
}
可接受的写法:
// ✅ Separate catch first
try { api.load() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { logger.warn("load failed", e) }
// ✅ Conditional rethrow inside the broad catch
try { api.load() }
catch (e: Exception) {
if (e is CancellationException) throw e
logger.warn("load failed", e)
}
// ✅ ensureActive() — good when the catch handles ordinary failures and you only need
// to rethrow if the current coroutine is cancelled
try { api.load() }
catch (e: Exception) {
currentCoroutineContext().ensureActive()
logger.warn("load failed", e)
}
// ✅ runCatching with explicit guard
runCatching { api.load() }
.onFailure {
if (it is CancellationException) throw it
logger.warn("load failed", it)
}
// ✅ runCatching terminated with getOrThrow (cancellation flows back out)
runCatching { api.load() }.getOrThrow()
触发条件是“try 中有挂起调用”,而不是“外围函数声明为 suspend”。这适用于任何可挂起的代码块中,包括 suspend fun、launch { … } lambda、Flow 的 collect { … } 等。
常见的例外情况是有意设置的局部超时:捕获自己调用的 withTimeout 所抛出的 TimeoutCancellationException,并将其转换为领域结果,可能是正确的。应将此 catch 限定在较小范围内,并放在超时逻辑附近。不要以此为由吞掉任意取消。
捕获非取消子类型(IOException、你自己的异常类型)没有问题——它们不是 CancellationException 的子类型。
7. runBlocking
runBlocking 会让当前线程停下来,直到 lambda 执行完毕。在支持挂起或受生命周期作用域约束的应用代码路径中,这种做法是错误的:原本应异步工作的线程现在被阻塞,结构化并发遭到破坏,上游的任何取消也都不会生效。这是最直接的“被调用方替调用方做结构性决定”反模式。
// ❌ BAD — bridging to suspend by blocking the calling thread
fun saveUser(user: User) {
runBlocking { repository.save(user) }
}
根据使用场景,有三种修复方式:
支持挂起的应用代码——将函数改为 suspend:
// ✅ GOOD
suspend fun saveUser(user: User) = repository.save(user)
如果直接调用方也无法挂起(例如非挂起的 UI 回调或 BroadcastReceiver 钩子),则在边界处使用现有的、与生命周期绑定的作用域——参见 §3 中 UI ↔ 状态持有者的例外情况。修复应位于边界处,而不是 saveUser 内部。
确实存在合理的阻塞边界:CLI 工具中的 main、必须同步返回的 Java 互操作 API、没有挂起替代方案的框架回调,以及迁移垫片。将 runBlocking 放在最外层边界,保持其代码块简短,并立即调用挂起代码。
测试——使用 runTest:
// ❌ BAD — real time, slow tests, no virtual delay
@Test fun loadsUser() = runBlocking {
assertThat(repository.load().name).isEqualTo("Alice")
}
// ✅ GOOD
@Test fun loadsUser() = runTest {
assertThat(repository.load().name).isEqualTo("Alice")
}
runTest 提供虚拟时间(delay() 会立即返回)、TestDispatcher 集成,以及妥善的协程清理。在测试中使用实时的 runBlocking 会让测试变慢且不稳定。
ContentProvider 例外情况——从进程外部调用 Android 的 ContentProvider 方法(query、insert、update、delete、onCreate、call)时,这些方法都是同步的。它们无法挂起。在 ContentProvider 子类的成员函数中(直接或间接继承均可,不包括伴生对象),runBlocking 是不可避免的桥接方式。尽可能缩短代码块,并立即调用挂起代码:
// ✅ Acceptable in ContentProvider members only
class MyProvider : ContentProvider() {
override fun query(...): Cursor? = runBlocking { dao.query(...) }
}
此例外情况仅适用于 android.content.ContentProvider 子类。“它和 ContentProvider 类似”并不适用;在 ContentProvider 的伴生对象中使用 runBlocking 仍然属于常规违规,因为该辅助函数不属于框架的同步接口。
快速参考
| 症状 | 反模式 | 修正方法 |
|---|---|---|
类中有 private val scope: CoroutineScope | 在被调用方中存储作用域 | 删除。将公共 API 改为 suspend。 |
init { scope.launch { ... } } | 在构造时启动协程 | 移至 suspend fun init() / login() |
仓库/管理器/用例中的 fun foo() { scope.launch { ... } } | 在非 UI 类中启动后即不再关注 | 改为 suspend fun foo(),让 UI 状态持有者选择作用域 |
状态持有者中的 fun onClick() { viewModelScope.launch { ... } },由 UI 调用 | UI ↔ 状态持有者边界——可以 | 保持不变(见 §3 例外情况) |
private val scope = MainScope() | 在内部创建并存储作用域 | 同上——删除,将 API 改为 suspend |
@SingleIn(AppScope) class X(scope) { init { scope.launch { … } } } | 绑定到 DI 的不透明协程启动(§5) | 暴露 suspend fun run(),由启动协调器启动 |
class Y : Initializer { override fun initialize() { scope.launch { … } } } | 启动协程而非注册任务的初始化器(§5) | 同上——改为 suspend fun run(),由协调器管理生命周期 |
try { suspendCall() } catch (e: Exception|Throwable|CancellationException) { … } 且未重新抛出异常 | 吞掉取消信号(§6) | 优先使用 catch (e: CancellationException) { throw e };仅在符合预期时使用 ensureActive() |
runCatching { suspendCall() }.onFailure { … } 且没有取消保护 | 与上面相同的情况(§6) | 添加 if (it is CancellationException) throw it,或以 .getOrThrow() 结束 |
支持挂起的应用代码中出现 runBlocking { … } | 阻塞线程的桥接(§7) | 将调用方改为 suspend;或在边界处使用生命周期作用域 |
测试中出现 runBlocking { … } | 同上——实时桥接(§7) | 使用 runTest { … } |
ContentProvider.query/insert/… 成员中出现 runBlocking { … } | 例外情况(§7) | 可以接受;保持方法体精简 |
重构指南
移除现有问题代码:
- 从叶子节点开始。 选择距离 UI 最远的类——通常是仓库或数据源。它的公共接口通常最容易转换。
- 逐个将公共函数改为
suspend。编译器会指出所有调用方。 - 在每个调用方处,审慎选择作用域:
viewModelScope、lifecycleScope、coroutineScope { }或显式的 job。这正是之前缺失的选择。 - 在没有任何代码再使用
CoroutineScope构造函数参数后将其删除。 同时移除注入绑定。
不要试图在一个 MR 中修复所有类。移除反模式是一项渐进式工作。
不适用的情况
- 吸收 UI 事件的 UI 状态持有者。 带有
fun onClick(...) { viewModelScope.launch { ... } }的 ViewModel/Component/功能模型是正确的——这是框架所需的边界。参见 §3 例外情况。 - 具有明确取消和错误策略的生命周期所有者。 Actor/服务、应用基础设施或应用级单例可以拥有作用域,前提是它们提供明确的
close/cancel/重启行为,或以其他方式直接对应应用生命周期。应显式注入Application.applicationScope,而非临时创建作用域。这并不意味着可以从init/initialize()中启动协程——参见 §5。 - 已经是挂起函数的 API 不需要进行这些改动。
- 测试有时会有意将
TestScope用作环境作用域——这是另一种模式,具有明确的虚拟时间控制。
审查中的危险信号
出现以下想法,说明反模式又出现了:
| 想法 | 实际情况 |
|---|---|
“我只要给作用域添加一个 CoroutineExceptionHandler 就行了” | 问题不在于错误处理,而在于这个作用域本来就不该存在。 |
“我需要从 init 中启动协程,这样使用方到来时数据就准备好了” | 使用方读取尚未准备好的状态才是问题。使用分阶段处理。 |
“调用方不想处理 suspend” | 那就由调用方在自己的作用域中选择即发即弃。不要替他们做决定。 |
| “这只是一个小小的即发即弃调用” | 取消可能被静默处理,因此每个即发即弃调用都可能静默失败。 |
| “我们捕获并记录了异常,所以没问题” | 捕获异常时有没有重新抛出 CancellationException?如果没有,协程就会在未被取消的情况下静默继续。(§6) |
“这只是一个 runBlocking,而且不在关键路径上” | 每个 runBlocking 都是在断言调用方没有异步选项。如果有,那它就不是合适的原语。(§7) |
“用 runBlocking 编写测试更简单” | 测试会按实际时间运行,无法快进 delay,还会失去 TestDispatcher 的语义。请使用 runTest。(§7) |
相关内容
kotlin-flow-state-event-modeling—StateFlow、SharedFlow、Channel、stateIn、一次性事件及相关建模。