Kotlin Coroutines 与 Flow:让异步任务更清晰
背景
Android 应用离不开异步任务:请求接口、读写数据库、加载图片、处理文件、轮询状态、监听搜索输入。早期项目里,这些事情常见写法是回调、AsyncTask、Thread、Handler,或者 RxJava。
这些方案都能解决问题,但也容易带来几个麻烦:
- 回调嵌套多,业务流程被拆得很碎。
- 线程切换靠人工维护,容易忘记回到主线程更新 UI。
- 页面销毁后任务还在跑,可能造成泄漏或无效回调。
- 多个异步任务并发、取消、异常处理不够直观。
Kotlin Coroutines(协程)就是为了解决这类异步代码复杂度而来。它让异步代码写起来像同步代码,同时又不会阻塞线程。Flow 则是在协程基础上处理"连续数据流"的工具,适合搜索、数据库监听、分页、状态流等场景。
本文会按 Android 项目最常见的使用方式讲清楚:
suspend到底是什么。CoroutineScope和结构化并发为什么重要。Dispatchers如何做线程切换。- ViewModel 中怎样安全启动协程。
- Flow 适合解决什么问题。
- 常见操作符和生命周期收集方式。
从回调到 suspend
先看一个传统回调写法:
kotlin
api.getUser(userId, object : Callback<User> {
override fun onSuccess(user: User) {
api.getOrders(user.id, object : Callback<List<Order>> {
override fun onSuccess(orders: List<Order>) {
showOrders(orders)
}
override fun onError(t: Throwable) {
showError(t)
}
})
}
override fun onError(t: Throwable) {
showError(t)
}
})
逻辑很简单:先取用户,再取订单。但代码被回调拆开后,错误处理、取消、状态更新都会变复杂。
如果接口改成 suspend 函数,可以写成这样:
kotlin
suspend fun loadOrders(userId: String): List<Order> {
val user = api.getUser(userId)
return api.getOrders(user.id)
}
suspend 的意思不是"这个函数一定开线程",而是"这个函数可以挂起"。挂起时,当前协程会暂停,线程可以去做别的事情;等结果回来后,协程再从暂停的位置继续执行。
这就是协程最重要的价值:异步任务仍然可以按顺序写,代码更接近业务流程本身。
启动协程:CoroutineScope
suspend 函数不能随便从普通函数直接调用,它需要运行在协程里。启动协程通常需要一个 CoroutineScope:
kotlin
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
fun loadUser(userId: String) {
viewModelScope.launch {
val user = repository.getUser(userId)
// 更新 UI 状态
}
}
}
Android 中最常用的作用域有两个:
viewModelScope:跟随 ViewModel 生命周期,ViewModel 清除时自动取消。lifecycleScope:跟随 LifecycleOwner 生命周期,Activity 或 Fragment 销毁时自动取消。
不建议在业务代码里随手写 GlobalScope.launch。GlobalScope 的生命周期几乎和进程一样长,页面关闭后任务仍可能继续跑,很容易产生不可控的后台任务。
结构化并发:任务要有父子关系
协程强调结构化并发。简单理解就是:协程不是散落在全局的任务,而是有清晰的父子关系。
kotlin
viewModelScope.launch {
val profileDeferred = async { repository.getProfile() }
val settingsDeferred = async { repository.getSettings() }
val profile = profileDeferred.await()
val settings = settingsDeferred.await()
_uiState.value = UserUiState.Success(profile, settings)
}
上面代码里,两个 async 启动的子协程都属于外层 launch。如果外层协程被取消,两个子协程也会被取消;如果其中一个子任务失败,默认也会影响整个父任务。
这比自己保存一堆线程引用、手动取消要清晰得多。
launch 和 async 的区别
常用启动方式主要是 launch 和 async。
launch 用来启动"不需要返回值"的任务:
kotlin
viewModelScope.launch {
repository.refreshToken()
}
async 用来启动"需要返回值"的并发任务,返回的是 Deferred<T>,通过 await() 拿结果:
kotlin
viewModelScope.launch {
val userTask = async { repository.getUser() }
val messageTask = async { repository.getMessages() }
val user = userTask.await()
val messages = messageTask.await()
}
如果只是顺序执行,没有并发需求,不要为了"用了协程"而硬写 async:
kotlin
viewModelScope.launch {
val user = repository.getUser()
val messages = repository.getMessages(user.id)
}
这种写法反而更直观。
Dispatchers:切换合适的线程
协程不是线程,但协程需要运行在线程上。Dispatchers 决定协程代码在哪类线程执行。
Android 常见 Dispatcher:
Dispatchers.Main:主线程,适合更新 UI。Dispatchers.IO:I/O 密集任务,比如网络请求、数据库、文件读写。Dispatchers.Default:CPU 密集任务,比如排序、JSON 大量解析、图片处理。
在 ViewModel 中,viewModelScope.launch 默认运行在主线程。耗时任务应该切到合适线程:
kotlin
viewModelScope.launch {
_uiState.value = UiState.Loading
val articles = withContext(Dispatchers.IO) {
repository.loadArticles()
}
_uiState.value = UiState.Success(articles)
}
withContext 会切换上下文,并等待代码块执行完成后再继续往下走。这里网络请求在 IO 线程,状态更新仍回到主线程。
如果使用 Retrofit 的 suspend 接口,Retrofit 已经会处理网络等待,不一定每次都要手动包一层 Dispatchers.IO。但数据库、文件、CPU 计算仍要根据具体库和任务成本判断。
异常处理
协程里的异常可以用普通的 try-catch 处理:
kotlin
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("加载失败")
}
}
有一个细节要注意:取消也是通过异常传播的,类型是 CancellationException。通常不要把取消当成普通错误吞掉。如果你写了很宽泛的 catch (e: Exception),并且在底层工具函数里处理异常,需要确认不会误吞取消信号。
kotlin
suspend fun loadDataSafely(): Result<Data> {
return try {
Result.success(api.loadData())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
这样页面退出、请求取消时,协程仍能正常结束。
一个完整的 ViewModel 示例
下面是一个更接近真实项目的写法:
kotlin
data class ArticleUiState(
val loading: Boolean = false,
val articles: List<Article> = emptyList(),
val errorMessage: String? = null
)
class ArticleViewModel(
private val repository: ArticleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ArticleUiState())
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
fun refresh() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
loading = true,
errorMessage = null
)
try {
val articles = repository.fetchArticles()
_uiState.value = ArticleUiState(articles = articles)
} catch (e: IOException) {
_uiState.value = _uiState.value.copy(
loading = false,
errorMessage = "网络异常,请稍后重试"
)
}
}
}
}
这个例子里,ViewModel 负责请求和状态组装,UI 只负责订阅 uiState 并展示。这样页面旋转后,ViewModel 仍能保留当前状态。
Flow:处理连续数据流
协程适合处理"一次性异步任务",比如请求一次接口。Flow 更适合处理"会连续发出多个值"的数据流,比如:
- 数据库表变化后自动推送新列表。
- 搜索框输入关键词时不断触发查询。
- 下载进度从 0% 到 100%。
- 用户登录状态变化。
- 页面 UI 状态持续更新。
最简单的 Flow:
kotlin
fun countDown(): Flow<Int> = flow {
for (i in 3 downTo 1) {
emit(i)
delay(1000)
}
}
收集 Flow:
kotlin
viewModelScope.launch {
countDown().collect { value ->
println(value)
}
}
Flow 默认是冷流。也就是说,只有调用 collect 时,里面的代码才会真正执行。每次新的 collect 都会重新触发一次数据生产。
Flow 常见操作符
Flow 的强大之处在于操作符。它可以像流水线一样处理数据。
map:转换数据
kotlin
val titlesFlow: Flow<List<String>> = repository.observeArticles()
.map { articles -> articles.map { it.title } }
filter:过滤数据
kotlin
val importantMessages = messageFlow
.filter { it.important }
debounce:搜索防抖
搜索框输入时,不应该每输入一个字符就立刻请求接口。可以用 debounce 等用户停顿一小段时间:
kotlin
val searchResult = keywordFlow
.debounce(300)
.filter { it.isNotBlank() }
.flatMapLatest { keyword ->
repository.search(keyword)
}
flatMapLatest 的含义是:如果新关键词来了,取消上一次还没完成的搜索,只保留最新一次。这非常适合搜索场景。
catch:处理上游异常
kotlin
repository.observeArticles()
.catch { e ->
emit(emptyList())
}
.collect { articles ->
_uiState.value = _uiState.value.copy(articles = articles)
}
catch 只能捕获它上游的异常。下游 collect 里的异常不归这个 catch 管。
StateFlow 和 SharedFlow
Android 项目里,最常见的热流是 StateFlow 和 SharedFlow。
StateFlow 适合表示"当前状态"。它一定有当前值,新订阅者会立刻拿到最新值:
kotlin
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
页面状态、登录状态、筛选条件,都适合用 StateFlow。
SharedFlow 更适合表示"一次性事件"或广播事件,比如 Toast、Snackbar、导航命令:
kotlin
private val _events = MutableSharedFlow<LoginEvent>()
val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
fun login() {
viewModelScope.launch {
_events.emit(LoginEvent.ShowToast("登录成功"))
}
}
不要把一次性事件硬塞进 StateFlow,否则页面重建后可能重复消费旧事件。
在 Compose 中收集 Flow
如果项目使用 Compose,推荐使用生命周期感知的收集方式:
kotlin
@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ArticleContent(
loading = uiState.loading,
articles = uiState.articles,
errorMessage = uiState.errorMessage,
onRefresh = viewModel::refresh
)
}
collectAsStateWithLifecycle() 来自 androidx.lifecycle:lifecycle-runtime-compose,它会结合生命周期,避免页面不可见时仍然无意义地收集 UI 状态。
一次性事件可以用 LaunchedEffect 收集:
kotlin
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.ShowMessage -> {
snackbarHostState.showSnackbar(event.message)
}
LoginEvent.NavigateHome -> {
// 调用导航逻辑
}
}
}
}
}
注意不要把事件收集直接写在 Composable 函数体里。Composable 可能反复重组,副作用应该放进 LaunchedEffect 这类 Effect API 中。
在 XML View 中收集 Flow
如果项目还在使用 XML + Fragment,也可以安全收集 Flow:
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
repeatOnLifecycle 会在生命周期进入 STARTED 时开始收集,低于该状态时自动取消收集,再次进入时重新收集。这比在 onViewCreated 里直接 launch { collect {} } 更适合 UI。
Room 和 Flow
Room 对 Flow 支持很好。DAO 可以直接返回 Flow:
kotlin
@Dao
interface ArticleDao {
@Query("SELECT * FROM article ORDER BY updateTime DESC")
fun observeArticles(): Flow<List<ArticleEntity>>
}
当数据库表数据变化时,Flow 会自动发出新的列表。Repository 可以继续转换成 UI 需要的数据:
kotlin
fun observeArticles(): Flow<List<Article>> {
return articleDao.observeArticles()
.map { entities -> entities.map { it.toDomain() } }
}
ViewModel 再把它转成 StateFlow:
kotlin
val uiState: StateFlow<ArticleUiState> = repository.observeArticles()
.map { articles -> ArticleUiState(articles = articles) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ArticleUiState(loading = true)
)
stateIn 可以把冷 Flow 转成 StateFlow,方便 UI 层直接收集当前状态。
常见坑
在业务代码里滥用 GlobalScope。 它不跟随页面或业务生命周期,任务容易失控。优先使用 viewModelScope、lifecycleScope 或由上层注入的业务作用域。
把耗时任务放在主线程。 协程不等于自动切线程。CPU 计算、文件读写、数据库操作要确认是否切到合适的 Dispatcher。
忘记处理取消。 宽泛捕获 Exception 时,不要误吞 CancellationException。
在 Composable 函数体直接 collect。 这会随着重组重复启动收集。UI 状态用 collectAsStateWithLifecycle(),一次性事件用 LaunchedEffect。
Flow 没有被 collect 就不会执行。 冷 Flow 只是描述数据流,真正执行要等收集。
把一次性事件放进 StateFlow。 Toast、导航这类事件可能因为页面重建重复触发,更适合 SharedFlow 或专门的事件通道。
总结
Coroutines 和 Flow 可以先记住这几条:
suspend让异步代码按顺序写,但不会阻塞线程。- 协程应该运行在明确的
CoroutineScope中,避免滥用GlobalScope。 viewModelScope适合页面业务任务,ViewModel 清除时会自动取消。Dispatchers.IO处理 I/O,Dispatchers.Default处理 CPU 计算,主线程负责 UI 状态更新。launch适合无返回值任务,async适合需要并发拿结果的任务。- Flow 适合连续数据流,常配合
map、filter、debounce、flatMapLatest、catch使用。 - UI 状态优先用
StateFlow,一次性事件更适合SharedFlow。 - Compose 中收集 Flow 要结合生命周期,避免不可见页面继续做无意义工作。
学会协程和 Flow 后,Android 里的网络请求、数据库监听、搜索输入、页面状态管理都会清晰很多。后面再学习 Hilt、Paging 3、Compose 复杂页面时,这套异步基础也会反复用到。