Kotlin 异步与并发:从一次搜索请求走到状态流

搜索页连续输入两个关键词,第二次结果先回来,第一次结果随后把界面覆盖了。给网络函数加上 suspend 并不能自动解决这个问题:请求属于谁、旧任务何时取消、结果怎样送到界面,是三件需要一起设计的事。**协程用作用域组织任务,用挂起函数表达等待,用 Flow 传递随时间变化的值;共享可变状态还要单独处理并发访问。**本文以 Kotlin 2.4.21 为语言基线,launch、Flow、Mutex 来自 kotlinx.coroutines,Android 生命周期 API 来自 AndroidX。

先画出任务的归属

在 Android 中,一次页面搜索通常从 ViewModel 发起,仓库执行查询,结果回到状态持有者,界面按生命周期收集状态。suspend 只允许函数在合适的调用环境中挂起;它不会自行启动协程,也不代表一定切到后台线程。CoroutineScope 才决定任务从哪里启动、由哪个 Job 持有。viewModelScope 会在 ViewModel 清理时取消子任务,适合页面相关请求;要求离开页面后继续完成的上传,应交给更持久的任务机制。

kotlin 复制代码
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
    private var searchJob: Job? = null
    private val _result = MutableStateFlow<List<Item>>(emptyList())
    val result: StateFlow<List<Item>> = _result

    fun search(query: String) {
        searchJob?.cancel()
        searchJob = viewModelScope.launch {
            _result.value = repo.search(query)
        }
    }
}

新查询先取消旧 Job,然后启动新的子任务。代码假定 repo.search() 是支持协作取消的挂起调用;如果它内部另起不受父任务管理的工作,或长时间运行阻塞式代码,外层取消未必能及时阻止旧结果。遇到"页面退出后还有回调",先沿着作用域、Job、仓库 API 三层追踪,而不是只看调用处有没有 cancel()。取消通常以 CancellationException 传播,宽泛的 catch (Exception) 不应把它当普通错误吞掉;资源释放放在 finally。

如果业务需要"只展示最新关键词的结果",还可以让输入成为流,在仓库边界使用适合最新值语义的转换操作。这样取消规则跟着输入变化走,不必在每个点击或输入回调里维护一份 Job。但要先确认仓库调用的取消能向下传播,且旧查询返回的缓存写入不会越过这条规则。取消控制的是正在执行的任务,不会自动撤销已经写入数据库的副作用。

suspend 也不等于"异步函数总会立即返回":调用方会在该挂起函数完成后继续执行,只是等待期间可以释放线程。纯 CPU 循环若不调用可取消的挂起点,需要主动检查取消状态;阻塞式 SDK 若无法配合取消,可能还得安排线程中断或设计结果丢弃策略。取消之后的清理若本身需要挂起,要明确在取消状态下如何执行,普通资源关闭则优先用同步的 finally。

并发请求怎样合到一次结果

详情页同时需要用户资料和订单,两次读取互不依赖时,可以让等待重叠:

kotlin 复制代码
suspend fun loadScreen(api: Api): ScreenData = coroutineScope {
    val user = async { api.loadUser() }
    val orders = async { api.loadOrders() }
    ScreenData(user.await(), orders.await())
}

async 返回 Deferred<T>,结果要通过 await() 取得;launch 返回 Job,适合不需要返回值的任务。coroutineScope 等待两个子任务结束,其中一个失败时,普通父作用域会取消另一个并把异常交给调用方。若第二次请求依赖第一次的用户 ID,就应顺序调用;写两个 async 不会凭空产生有效并发。需要兄弟任务失败后互不取消,可考虑 supervisorScope,同时明确每个失败结果由谁处理。

调度器解决的是执行资源,不是任务归属。Dispatchers.Main 用于 UI 线程,Default 适合 CPU 工作,IO 常用于阻塞 I/O;已经异步的网络客户端不一定需要再包一层 withContext(IO)。async 也不等于"开一个新线程"。先把父子任务关系写清,再决定哪段工作需要切换执行环境。

异常处理取决于任务的边界。最外层 viewModelScope.launch 可以把业务失败映射成页面错误状态;async 的失败通常在 await() 处被观察,不能因为调用处没有立刻抛异常就忽略它。把每个子任务都包进独立 GlobalScope 会让父任务失去统一收尾能力,也让异常无处归属。若并发读取必须"一个失败仍显示另一个",应把两个结果建模成可独立失败的状态,而不是简单切换到监督作用域后丢掉错误。

结果会变化时,用 Flow 表达持续观察

如果仓库提供 Flow<List<Item>>,每次数据库内容变化都会发出新列表。普通 flow {} 通常是冷流:每位收集者都可能重新运行上游;多个界面组件直接收集它,可能重复查询。可以在 ViewModel 中用 stateIn 共享上游并提供初始状态:

kotlin 复制代码
val items: StateFlow<List<Item>> = repo.observeItems()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = emptyList()
    )

StateFlow 持有当前值,适合页面状态。它按相等性合并更新,也不能保证慢收集者逐条收到每个中间值;审计日志或必须逐个处理的事件不能只靠它保存。Flow 的 map、debounce、collectLatest 管值和时间,生命周期仍由 UI 侧负责。传统 View 可在 repeatOnLifecycle 中收集;Compose 可用 collectAsStateWithLifecycle()。这些是 AndroidX API,与 Kotlin 语言版本分开选型。界面不可见时仍在永久 collect,通常说明收集任务挂错了位置。

单次查询只需一个结果时,普通 suspend 函数已经足够;数据真的会持续变化,再让调用方长期收集流。不要为了统一接口把所有请求都包成 Flow,也不要把一次性导航事件强行塞进"当前状态"。

还有两个常见诊断点。第一,普通冷流被收集两次,上游一般会运行两次;stateIn 共享后,又要确认它绑定的 viewModelScope 是否符合数据寿命。第二,StateFlow 永远有当前值,重新订阅者会先看到这个值,适合渲染状态,却可能让"弹一次提示"重复发生。对于错误提示、导航等事件,要先定义丢失和重复哪一种更不能接受,再选择事件通道或显式消费协议。WhileSubscribed(5_000) 中的超时是示例策略,不是必须照抄的默认值。

最后才是共享状态的锁

两个协程同时执行 balance += amount,读取和写回之间可能交错。所有访问入口若共享同一把 Mutex,可以在短临界区里维护不变量:

kotlin 复制代码
class Wallet {
    private val mutex = Mutex()
    private var balance = 0

    suspend fun deposit(amount: Int) {
        require(amount >= 0)
        mutex.withLock { balance += amount }
    }
}

Mutex 等待锁时可以挂起协程;JVM 的 synchronized 竞争时阻塞线程。withLock 的代码块不是挂起 Lambda,不应把长时间 I/O 放在其中。锁也只保护遵守同一协议的访问:普通线程若绕过 mutex 修改 balance,一致性仍会破坏。数据库事务、跨进程协调各有自己的机制,Mutex 不能替代它们。若状态只由单一 actor 持有,可能根本不需要锁。

这把锁应当是对象长期持有的同一实例,而非每次 deposit() 临时创建一把新锁。Mutex 默认不可重入,持锁后调用另一个也试图获取同一把锁的方法,可能让协程一直等待。临界区真正要保护的是业务不变量:若余额与流水号必须一起变化,两个字段的读写都要按同一协议完成;只给写入加锁,却在另一个入口分开读取,仍可能拿到不一致的组合。

排查异步问题时,按"任务归属 → 取消传播 → 结果流向 → 共享状态"走一遍,通常比先换 Dispatcher 更快找到原因。

参考:协程基础、取消与超时、组合挂起函数、Flow、Android 生命周期与协程。

相关推荐
心祭丫丫32 分钟前
【每天一个 Python 小案例17】Python 实现通讯录
开发语言·人工智能·笔记·python·学习
露天赏雪1 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
zhaoshuzhaoshu1 小时前
Android 系统启动与调试中的日志抓取方式全解析
android
雨声不在2 小时前
android 动画掉帧
android
在多学一点3 小时前
MYSQL第二次作业
android·数据库·mysql
yeccheng3 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
联犀3 小时前
纯 Go 的 CAD 开源库 go-cad:直接解析 DWG/DXF,现在也能导出 PDF
开发语言·golang·pdf
骑着蜗牛撵大象3273 小时前
MySQL 8.0 迁移后 SQL 变慢:执行计划里 3 类被忽略的成本差异
android·数据库·sql·mysql·优化器·执行计划
我叫张土豆4 小时前
Android 14+ 模拟器抓包实录:把 Burp CA 装成系统 CA,让 targetSdk≥34 的 App 乖乖解密
android
zjxtxdy4 小时前
C++面向对象程序开发-类与对象
开发语言·c++