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 生命周期与协程。

相关推荐
从小白到软工1 小时前
4-3 项目-学生成绩管理系统(上)
c语言·开发语言·数据结构·经验分享·ai编程
外收内放2 小时前
Python基础语法练习题(72)
开发语言·python
无忧.芙桃2 小时前
C++语言原理与实践(十三):stack、queue 和 priority_queue的介绍与使用
开发语言·c++·栈·队列
Shaoshing2 小时前
消息发送
java·开发语言
帅得不敢出门2 小时前
Android Framework定制卸载确认窗口
android
乌暮2 小时前
从 `println` 到生产级日志:Spring Boot 日志体系完全指南
java·开发语言·jvm·spring boot·后端·学习·面试
星夜离尘563 小时前
MATLAB 定点量化误差实验 半个 LSB 的界限为什么挡不住削顶失真
开发语言·matlab
Wang's Blog3 小时前
Java 项目实战: 交友项目 - 百度人脸识别:介绍
java·开发语言·交友