Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Android および KMP 向けの Kotlin コルーチンと Flow パターン — 構造化並行性、Flow オペレーター、StateFlow、エラーハンドリング、テスト。
.claude/skills/affaan-m-kotlin-coroutines-flows/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-13 | ✓→✓ | = Same ✓ | 124% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 126% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 159% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 54% | 0% |
适用于 Android 和 Kotlin 多平台项目的结构化并发模式、基于 Flow 的响应式流以及协程测试。
Application
└── viewModelScope (ViewModel)
└── coroutineScope { } (结构化子作用域)
├── async { } (并发任务)
└── async { } (并发任务)始终使用结构化并发——绝不使用 GlobalScope:
kotlin// BAD GlobalScope.launch { fetchData() } // GOOD — scoped to ViewModel lifecycle viewModelScope.launch { fetchData() } // GOOD — scoped to composable lifecycle LaunchedEffect(key) { fetchData() }
使用 coroutineScope + async 处理并行工作:
kotlinsuspend fun loadDashboard(): Dashboard = coroutineScope { val items = async { itemRepository.getRecent() } val stats = async { statsRepository.getToday() } val profile = async { userRepository.getCurrent() } Dashboard( items = items.await(), stats = stats.await(), profile = profile.await() ) }
当子协程失败不应取消同级协程时,使用 supervisorScope:
kotlinsuspend fun syncAll() = supervisorScope { launch { syncItems() } // failure here won't cancel syncStats launch { syncStats() } launch { syncSettings() } }
kotlinfun observeItems(): Flow<List<Item>> = flow { // Re-emits whenever the database changes itemDao.observeAll() .map { entities -> entities.map { it.toDomain() } } .collect { emit(it) } }
kotlinclass DashboardViewModel( observeProgress: ObserveUserProgressUseCase ) : ViewModel() { val progress: StateFlow<UserProgress> = observeProgress() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = UserProgress.EMPTY ) }
WhileSubscribed(5_000) 会在最后一个订阅者离开后,保持上游活动 5 秒——可在配置更改时存活而无需重启。
kotlinval uiState: StateFlow<HomeState> = combine( itemRepository.observeItems(), settingsRepository.observeTheme(), userRepository.observeProfile() ) { items, theme, profile -> HomeState(items = items, theme = theme, profile = profile) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState())
kotlin// Debounce search input searchQuery .debounce(300) .distinctUntilChanged() .flatMapLatest { query -> repository.search(query) } .catch { emit(emptyList()) } .collect { results -> _state.update { it.copy(results = results) } } // Retry with exponential backoff fun fetchWithRetry(): Flow<Data> = flow { emit(api.fetch()) } .retryWhen { cause, attempt -> if (cause is IOException && attempt < 3) { delay(1000L * (1 shl attempt.toInt())) true } else { false } }
kotlinclass ItemListViewModel : ViewModel() { private val _effects = MutableSharedFlow<Effect>() val effects: SharedFlow<Effect> = _effects.asSharedFlow() sealed interface Effect { data class ShowSnackbar(val message: String) : Effect data class NavigateTo(val route: String) : Effect } private fun deleteItem(id: String) { viewModelScope.launch { repository.delete(id) _effects.emit(Effect.ShowSnackbar("Item deleted")) } } } // Collect in Composable LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message) is Effect.NavigateTo -> navController.navigate(effect.route) } } }
kotlin// CPU-intensive work withContext(Dispatchers.Default) { parseJson(largePayload) } // IO-bound work withContext(Dispatchers.IO) { database.query() } // Main thread (UI) — default in viewModelScope withContext(Dispatchers.Main) { updateUi() }
在 KMP 中,使用 Dispatchers.Default 和 Dispatchers.Main(在所有平台上可用)。Dispatchers.IO 仅适用于 JVM/Android——在其他平台上使用 Dispatchers.Default 或通过依赖注入提供。
长时间运行的循环必须检查取消状态:
kotlinsuspend fun processItems(items: List<Item>) = coroutineScope { for (item in items) { ensureActive() // throws CancellationException if cancelled process(item) } }
kotlinviewModelScope.launch { try { _state.update { it.copy(isLoading = true) } val data = repository.fetch() _state.update { it.copy(data = data) } } finally { _state.update { it.copy(isLoading = false) } // always runs, even on cancellation } }
kotlin@Test fun `search updates item list`() = runTest { val fakeRepository = FakeItemRepository().apply { emit(testItems) } val viewModel = ItemListViewModel(GetItemsUseCase(fakeRepository)) viewModel.state.test { assertEquals(ItemListState(), awaitItem()) // initial viewModel.onSearch("query") val loading = awaitItem() assertTrue(loading.isLoading) val loaded = awaitItem() assertFalse(loaded.isLoading) assertEquals(1, loaded.items.size) } }
kotlin@Test fun `parallel load completes correctly`() = runTest { val viewModel = DashboardViewModel( itemRepo = FakeItemRepo(), statsRepo = FakeStatsRepo() ) viewModel.load() advanceUntilIdle() val state = viewModel.state.value assertNotNull(state.items) assertNotNull(state.stats) }
kotlinclass FakeItemRepository : ItemRepository { private val _items = MutableStateFlow<List<Item>>(emptyList()) override fun observeItems(): Flow<List<Item>> = _items fun emit(items: List<Item>) { _items.value = items } override suspend fun getItemsByCategory(category: String): Result<List<Item>> { return Result.success(_items.value.filter { it.category == category }) } }
GlobalScope——会导致协程泄漏,且无法结构化取消init {} 中收集 Flow——应使用 viewModelScope.launchMutableStateFlow 与可变集合一起使用——始终使用不可变副本:_state.update { it.copy(list = it.list + newItem) }CancellationException——应让其传播以实现正确的取消flowOn(Dispatchers.Main) 进行收集——收集调度器是调用方的调度器@Composable 中创建 Flow 而不使用 remember——每次重组都会重新创建 Flow关于 Flow 在 UI 层的消费,请参阅技能:compose-multiplatform-patterns。 关于协程在各层中的适用位置,请参阅技能:android-clean-architecture。
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-13 | pass→pass | 10,331 | 11,747 | +14% | 1 | 1 | 0% | 1,850 | 4,140 | +124% | 0 | 0 | — |
case-01 | pass→pass | 9,835 | 8,909 | -9% | 1 | 1 | 0% | 1,647 | 3,714 | +126% | 0 | 0 | — |
case-02 | pass→pass | 6,134 | 7,136 | +16% | 1 | 1 | 0% | 1,257 | 3,260 | +159% | 0 | 0 | — |
case-03 | pass→pass | 12,057 | 9,240 | -23% | 1 | 1 | 0% | 2,361 | 3,642 | +54% | 0 | 0 | — |
case-04 | pass→pass | 7,382 | 3,890 | -47% | 1 | 1 | 0% | 1,366 | 2,681 | +96% | 0 | 0 | — |
case-05 | pass→pass | 11,982 | 7,502 | -37% | 1 | 1 | 0% | 2,029 | 3,363 | +66% | 0 | 0 | — |
case-06 | fail→fail | 15,679 | 19,502 | +24% | 1 | 1 | 0% | 2,582 | 4,865 | +88% | 0 | 0 | — |
case-07 | fail→pass | 13,365 | 8,658 | -35% | 1 | 1 | 0% | 2,237 | 3,417 | +53% | 0 | 0 | — |
case-08 | pass→pass | 10,940 | 9,923 | -9% | 1 | 1 | 0% | 1,916 | 3,793 | +98% | 0 | 0 | — |
case-09 | pass→pass | 11,046 | 11,180 | +1% | 1 | 1 | 0% | 1,915 | 4,043 | +111% | 0 | 0 | — |
case-10 | pass→pass | 12,645 | 11,326 | -10% | 1 | 1 | 0% | 2,344 | 4,021 | +72% | 0 | 0 | — |
case-11 | pass→pass | 12,840 | 11,049 | -14% | 1 | 1 | 0% | 2,405 | 4,132 | +72% | 0 | 0 | — |
case-12 | pass→pass | 9,985 | 8,312 | -17% | 1 | 1 | 0% | 1,794 | 3,390 | +89% | 0 | 0 | — |
case-14 | pass→pass | 10,285 | 6,090 | -41% | 1 | 1 | 0% | 1,916 | 3,042 | +59% | 0 | 0 | — |
case-15 | pass→pass | 11,125 | 8,602 | -23% | 1 | 1 | 0% | 2,105 | 3,659 | +74% | 0 | 0 | — |
case-16 | pass→pass | 10,721 | 8,449 | -21% | 1 | 1 | 0% | 1,855 | 3,479 | +88% | 0 | 0 | — |
case-17 | pass→pass | 7,704 | 6,096 | -21% | 1 | 1 | 0% | 1,473 | 3,008 | +104% | 0 | 0 | — |
case-18 | pass→pass | 8,386 | 6,252 | -25% | 1 | 1 | 0% | 1,331 | 3,041 | +128% | 0 | 0 | — |
case-19 | pass→pass | 11,378 | 10,230 | -10% | 1 | 1 | 0% | 2,006 | 3,847 | +92% | 0 | 0 | — |
case-20 | pass→pass | 7,819 | 6,283 | -20% | 1 | 1 | 0% | 1,447 | 3,223 | +123% | 0 | 0 | — |
case-21 | pass→pass | 8,672 | 5,781 | -33% | 1 | 1 | 0% | 1,586 | 2,936 | +85% | 0 | 0 | — |
case-22 | pass→pass | 8,357 | 6,338 | -24% | 1 | 1 | 0% | 1,639 | 3,191 | +95% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.