搜索页连续输入两个关键词,第二次结果先回来,第一次结果随后把界面覆盖了。给网络函数加上
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 更快找到原因。