首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >[Android 从零到一] Kotlin Coroutines 与 Flow

[Android 从零到一] Kotlin Coroutines 与 Flow

原创
作者头像
hunter android
发布2026-06-29 10:18:02
发布2026-06-29 10:18:02
820
举报

背景

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

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

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

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

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

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

从回调到 suspend

先看一个传统回调写法:

代码语言:javascript
复制
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 函数,可以写成这样:

代码语言:javascript
复制
suspend fun loadOrders(userId: String): List<Order> {

    val user = api.getUser(userId)

    return api.getOrders(user.id)

}

suspend 的意思不是“这个函数一定开线程”,而是“这个函数可以挂起”。挂起时,当前协程会暂停,线程可以去做别的事情;等结果回来后,协程再从暂停的位置继续执行。

这就是协程最重要的价值:异步任务仍然可以按顺序写,代码更接近业务流程本身。

启动协程:CoroutineScope

suspend 函数不能随便从普通函数直接调用,它需要运行在协程里。启动协程通常需要一个 CoroutineScope

代码语言:javascript
复制
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 的生命周期几乎和进程一样长,页面关闭后任务仍可能继续跑,很容易产生不可控的后台任务。

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

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

代码语言:javascript
复制
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 用来启动“不需要返回值”的任务:

代码语言:javascript
复制
viewModelScope.launch {

    repository.refreshToken()

}

async 用来启动“需要返回值”的并发任务,返回的是 Deferred<T>,通过 await() 拿结果:

代码语言:javascript
复制
viewModelScope.launch {

    val userTask = async { repository.getUser() }

    val messageTask = async { repository.getMessages() }



    val user = userTask.await()

    val messages = messageTask.await()

}

如果只是顺序执行,没有并发需求,不要为了“用了协程”而硬写 async

代码语言:javascript
复制
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 默认运行在主线程。耗时任务应该切到合适线程:

代码语言:javascript
复制
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 处理:

代码语言:javascript
复制
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),并且在底层工具函数里处理异常,需要确认不会误吞取消信号。

代码语言:javascript
复制
suspend fun loadDataSafely(): Result<Data> {

    return try {

        Result.success(api.loadData())

    } catch (e: CancellationException) {

        throw e

    } catch (e: Exception) {

        Result.failure(e)

    }

}

这样页面退出、请求取消时,协程仍能正常结束。

一个完整的 ViewModel 示例

下面是一个更接近真实项目的写法:

代码语言:javascript
复制
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:

代码语言:javascript
复制
fun countDown(): Flow<Int> = flow {

    for (i in 3 downTo 1) {

        emit(i)

        delay(1000)

    }

}

收集 Flow:

代码语言:javascript
复制
viewModelScope.launch {

    countDown().collect { value ->

        println(value)

    }

}

Flow 默认是冷流。也就是说,只有调用 collect 时,里面的代码才会真正执行。每次新的 collect 都会重新触发一次数据生产。

Flow 常见操作符

Flow 的强大之处在于操作符。它可以像流水线一样处理数据。

map:转换数据

代码语言:javascript
复制
val titlesFlow: Flow<List<String>> = repository.observeArticles()

    .map { articles -> articles.map { it.title } }

filter:过滤数据

代码语言:javascript
复制
val importantMessages = messageFlow

    .filter { it.important }

debounce:搜索防抖

搜索框输入时,不应该每输入一个字符就立刻请求接口。可以用 debounce 等用户停顿一小段时间:

代码语言:javascript
复制
val searchResult = keywordFlow

    .debounce(300)

    .filter { it.isNotBlank() }

    .flatMapLatest { keyword ->

        repository.search(keyword)

    }

flatMapLatest 的含义是:如果新关键词来了,取消上一次还没完成的搜索,只保留最新一次。这非常适合搜索场景。

catch:处理上游异常

代码语言:javascript
复制
repository.observeArticles()

    .catch { e ->

        emit(emptyList())

    }

    .collect { articles ->

        _uiState.value = _uiState.value.copy(articles = articles)

    }

catch 只能捕获它上游的异常。下游 collect 里的异常不归这个 catch 管。

StateFlow 和 SharedFlow

Android 项目里,最常见的热流是 StateFlowSharedFlow

StateFlow 适合表示“当前状态”。它一定有当前值,新订阅者会立刻拿到最新值:

代码语言:javascript
复制
private val _uiState = MutableStateFlow(LoginUiState())

val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

页面状态、登录状态、筛选条件,都适合用 StateFlow

SharedFlow 更适合表示“一次性事件”或广播事件,比如 Toast、Snackbar、导航命令:

代码语言:javascript
复制
private val _events = MutableSharedFlow<LoginEvent>()

val events: SharedFlow<LoginEvent> = _events.asSharedFlow()



fun login() {

    viewModelScope.launch {

        _events.emit(LoginEvent.ShowToast("登录成功"))

    }

}

不要把一次性事件硬塞进 StateFlow,否则页面重建后可能重复消费旧事件。

在 Compose 中收集 Flow

如果项目使用 Compose,推荐使用生命周期感知的收集方式:

代码语言:javascript
复制
@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 收集:

代码语言:javascript
复制
@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:

代码语言:javascript
复制
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:

代码语言:javascript
复制
@Dao

interface ArticleDao {

    @Query("SELECT * FROM article ORDER BY updateTime DESC")

    fun observeArticles(): Flow<List<ArticleEntity>>

}

当数据库表数据变化时,Flow 会自动发出新的列表。Repository 可以继续转换成 UI 需要的数据:

代码语言:javascript
复制
fun observeArticles(): Flow<List<Article>> {

    return articleDao.observeArticles()

        .map { entities -> entities.map { it.toDomain() } }

}

ViewModel 再把它转成 StateFlow

代码语言:javascript
复制
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 可以先记住这几条:

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

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景
  • 从回调到 suspend
  • 启动协程:CoroutineScope
  • 结构化并发:任务要有父子关系
  • launch 和 async 的区别
  • Dispatchers:切换合适的线程
  • 异常处理
  • 一个完整的 ViewModel 示例
  • Flow:处理连续数据流
  • Flow 常见操作符
    • map:转换数据
    • filter:过滤数据
    • debounce:搜索防抖
    • catch:处理上游异常
  • StateFlow 和 SharedFlow
  • 在 Compose 中收集 Flow
  • 在 XML View 中收集 Flow
  • Room 和 Flow
  • 常见坑
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档