banner
NEWS LETTER

Kotlin 协程实践:结构化并发、异常处理和取消

Scroll down

一、协程解决什么问题

协程不是为了让代码“看起来更高级”,而是为了用同步写法组织异步任务。网络请求、数据库读写、文件 IO、定时任务都可以放进协程里,让代码更容易串联和取消。

传统回调写法容易出现几个问题:

  • 多层回调嵌套。
  • 错误处理分散。
  • 页面销毁后任务还在执行。
  • 多个异步任务之间的生命周期不清楚。

协程的价值是把这些异步任务放到明确的作用域中管理。

二、结构化并发

结构化并发的核心是:协程必须属于某个作用域,父协程结束时,子协程也应该一起结束。

1
2
3
4
5
6
7
8
9
suspend fun loadHomeData(): HomeData = coroutineScope {
val banner = async { api.getBanner() }
val articles = async { api.getArticles() }

HomeData(
banner = banner.await(),
articles = articles.await()
)
}

这里 bannerarticles 是并发执行的,但它们都受 coroutineScope 管理。如果其中一个失败,整个作用域会失败,另一个任务也会被取消。

这比随手使用全局协程更可靠:

1
2
3
GlobalScope.launch {
// 不推荐
}

GlobalScope 的生命周期太长,容易造成页面退出后任务继续执行。

三、常用作用域

Android 中常见作用域:

  • viewModelScope:跟随 ViewModel 生命周期。
  • lifecycleScope:跟随 Activity 或 Fragment 生命周期。
  • rememberCoroutineScope:Compose 中跟随当前组合生命周期。

一般建议:

  • 页面业务请求放在 viewModelScope
  • 和界面生命周期强相关的动作放在 lifecycleScope
  • Compose 点击事件中触发 Snackbar、滚动等 UI 动作用 rememberCoroutineScope

示例:

1
2
3
4
5
6
7
8
class UserViewModel : ViewModel() {
fun refresh() {
viewModelScope.launch {
val user = repository.getUser()
_uiState.value = UserUiState.Success(user)
}
}
}

四、调度器选择

协程调度器决定任务在哪类线程执行:

  • Dispatchers.Main:主线程,更新 UI。
  • Dispatchers.IO:网络、文件、数据库等 IO 操作。
  • Dispatchers.Default:CPU 密集型计算。
  • Dispatchers.Unconfined:特殊场景,不建议日常业务使用。

Repository 中可以显式切换到 IO:

1
2
3
4
5
6
7
8
9
10
class UserRepository(
private val api: UserApi,
private val dao: UserDao
) {
suspend fun refreshUser(): User = withContext(Dispatchers.IO) {
val user = api.getUser()
dao.insert(user.toEntity())
user
}
}

ViewModel 不需要关心底层线程细节,只负责调用业务方法和更新状态。

五、异常处理

协程异常处理要先区分两种场景:当前任务可以失败,还是失败后要影响整个并发流程。

普通串行任务可以用 try/catch

1
2
3
4
5
6
7
8
9
10
11
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val data = repository.loadData()
_uiState.value = UiState.Success(data)
} catch (e: IOException) {
_uiState.value = UiState.Error("网络异常")
} catch (e: Exception) {
_uiState.value = UiState.Error("加载失败")
}
}

多个任务中,如果希望某个任务失败不影响其他任务,可以使用 supervisorScope

1
2
3
4
5
6
7
8
9
suspend fun loadDashboard(): Dashboard = supervisorScope {
val profile = async { runCatching { api.getProfile() } }
val message = async { runCatching { api.getMessages() } }

Dashboard(
profile = profile.await().getOrNull(),
messages = message.await().getOrDefault(emptyList())
)
}

这种方式适合首页、工作台等多个模块相互独立的页面。

六、取消要可响应

协程取消是协作式的。挂起函数通常能响应取消,但如果自己写了长循环,就要主动检查。

1
2
3
4
5
6
suspend fun parseLargeFile(lines: List<String>) = withContext(Dispatchers.Default) {
for (line in lines) {
ensureActive()
parseLine(line)
}
}

不要吞掉取消异常:

1
2
3
4
5
try {
repository.loadData()
} catch (e: Exception) {
// 如果这里直接吞掉 CancellationException,取消会失效
}

更稳妥的写法:

1
2
3
4
5
catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.value = UiState.Error("加载失败")
}

七、Flow 和协程

Flow 适合表达持续变化的数据,例如数据库观察、搜索关键词、分页状态。

1
2
3
4
5
6
7
val articles: StateFlow<List<Article>> =
repository.observeArticles()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)

搜索输入可以用防抖:

1
2
3
4
5
6
val result = keywordFlow
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { keyword ->
repository.search(keyword)
}

flatMapLatest 的特点是新关键词到来时取消旧搜索,很适合搜索框场景。

八、实践建议

协程代码建议遵守几个习惯:

  • 不在业务代码里使用 GlobalScope
  • Repository 提供 suspendFlow API。
  • ViewModel 负责启动协程。
  • 长耗时任务放到合适的 Dispatcher。
  • 不吞掉 CancellationException
  • 并发任务优先使用 coroutineScopesupervisorScope

九、小结

协程写得好不好,关键不在于用了多少 API,而在于生命周期、异常和取消是否清晰。

可以用一句话记住:任务属于作用域,失败要有边界,取消必须尊重。

其他文章
目录导航 置顶
  1. 1. 一、协程解决什么问题
  2. 2. 二、结构化并发
  3. 3. 三、常用作用域
  4. 4. 四、调度器选择
  5. 5. 五、异常处理
  6. 6. 六、取消要可响应
  7. 7. 七、Flow 和协程
  8. 8. 八、实践建议
  9. 9. 九、小结
请输入关键词进行搜索