Kotlin Coroutines 与 Flow:让异步任务更清晰

Kotlin Coroutines 与 Flow:让异步任务更清晰

背景

Android 应用离不开异步任务:请求接口、读写数据库、加载图片、处理文件、轮询状态、监听搜索输入。早期项目里,这些事情常见写法是回调、AsyncTaskThreadHandler,或者 RxJava。

这些方案都能解决问题,但也容易带来几个麻烦:

  1. 回调嵌套多,业务流程被拆得很碎。
  2. 线程切换靠人工维护,容易忘记回到主线程更新 UI。
  3. 页面销毁后任务还在跑,可能造成泄漏或无效回调。
  4. 多个异步任务并发、取消、异常处理不够直观。

Kotlin Coroutines(协程)就是为了解决这类异步代码复杂度而来。它让异步代码写起来像同步代码,同时又不会阻塞线程。Flow 则是在协程基础上处理"连续数据流"的工具,适合搜索、数据库监听、分页、状态流等场景。

本文会按 Android 项目最常见的使用方式讲清楚:

  1. suspend 到底是什么。
  2. CoroutineScope 和结构化并发为什么重要。
  3. Dispatchers 如何做线程切换。
  4. ViewModel 中怎样安全启动协程。
  5. Flow 适合解决什么问题。
  6. 常见操作符和生命周期收集方式。

从回调到 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.launchGlobalScope 的生命周期几乎和进程一样长,页面关闭后任务仍可能继续跑,很容易产生不可控的后台任务。

结构化并发:任务要有父子关系

协程强调结构化并发。简单理解就是:协程不是散落在全局的任务,而是有清晰的父子关系。

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 的区别

常用启动方式主要是 launchasync

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 项目里,最常见的热流是 StateFlowSharedFlow

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。 它不跟随页面或业务生命周期,任务容易失控。优先使用 viewModelScopelifecycleScope 或由上层注入的业务作用域。

把耗时任务放在主线程。 协程不等于自动切线程。CPU 计算、文件读写、数据库操作要确认是否切到合适的 Dispatcher。

忘记处理取消。 宽泛捕获 Exception 时,不要误吞 CancellationException

在 Composable 函数体直接 collect。 这会随着重组重复启动收集。UI 状态用 collectAsStateWithLifecycle(),一次性事件用 LaunchedEffect

Flow 没有被 collect 就不会执行。 冷 Flow 只是描述数据流,真正执行要等收集。

把一次性事件放进 StateFlow。 Toast、导航这类事件可能因为页面重建重复触发,更适合 SharedFlow 或专门的事件通道。

总结

Coroutines 和 Flow 可以先记住这几条:

  1. suspend 让异步代码按顺序写,但不会阻塞线程。
  2. 协程应该运行在明确的 CoroutineScope 中,避免滥用 GlobalScope
  3. viewModelScope 适合页面业务任务,ViewModel 清除时会自动取消。
  4. Dispatchers.IO 处理 I/O,Dispatchers.Default 处理 CPU 计算,主线程负责 UI 状态更新。
  5. launch 适合无返回值任务,async 适合需要并发拿结果的任务。
  6. Flow 适合连续数据流,常配合 mapfilterdebounceflatMapLatestcatch 使用。
  7. UI 状态优先用 StateFlow,一次性事件更适合 SharedFlow
  8. Compose 中收集 Flow 要结合生命周期,避免不可见页面继续做无意义工作。

学会协程和 Flow 后,Android 里的网络请求、数据库监听、搜索输入、页面状态管理都会清晰很多。后面再学习 Hilt、Paging 3、Compose 复杂页面时,这套异步基础也会反复用到。

相关推荐
Bigger2 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
lichenyang4532 小时前
JSAPI、NAPI、Biz、Imp:ASCF Demo 如何真正调用系统能力和 C++ 能力
前端
lichenyang4532 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端
用户059540174462 小时前
Redis记忆存储故障恢复测试踩坑实录:手动测试让我漏掉了2个一致性Bug
前端·css
用户2136610035722 小时前
Vue2脚手架工程化与Axios集成
前端·vue.js
我不是外星人2 小时前
我把 Claude Code 搬到网页!自研高颜值 Web 交互工作台
前端·ai编程·claude
mixuecoding3 小时前
零成本搭建全球科技热点情报站:12 个平台,6 小时,0 元
前端
用户059540174463 小时前
用了3年Mock,才发现Redis记忆存储的测试一直漏掉了60%的边界场景
前端·css
石小石Orz3 小时前
AI具身交互:实现一个会说话的3D虚拟伴侣
前端·人工智能·后端