举个例子:A正在请求接口中,此时B也想请求该接口,那么B该选择哪种方案:
- 直接发起B请求
- 取消A请求,再发起B请求
- 等待A请求完成,再发起B请求
这个场景在开发中比较常见,例如列表下拉刷新和加载更多
如果加载更多正在请求中,此时用户又触发了下拉刷新,那么该如何处理?这种场景应该使用第2种方案比较合理,在下拉刷新请求发起之前,取消加载更多请求。
如果直接发起下拉刷新请求,不做任何处理会有什么问题?
假设每页有10条数据,如果下拉刷新接口比加载更多接口先返回结果,可能页面上显示的是20条数据,下拉刷新10条和加载更多10条,而实际上应该只显示用户下拉刷新的10条数据。
如果加载更多接口请求的是第2页数据,那似乎没什么问题,顶多是多显示了一页的数据,但加载更多可能是其他页数据,例如加载第5页数据,此时UI表现为,第1页和第5页的数据一共20条,这显然不是我们所期望的。
当然了,大部分情况下网络请求都很快,不会有什么问题,但是开发一个好的App,这些细节还是很重要的。
至于第3种方案,也有很多场景,后面会有专门的文章介绍。
互斥
有了前面的例子,应该比较好理解所谓的异步任务互斥,安卓开发中,异步任务使用协程的比重越来越多了,那么在使用协程时,应该如何处理这种互斥逻辑呢?
常规写法:
private var job: Job? = null
private fun requestData() {
// 取消上一次发起的请求
job?.cancel()
// 发起新的请求
job = lifecycleScope.launch {
// 模拟请求
delay(1000)
}
}
增加一个需求:显示加载中状态
显然,我们需要一个标志,表示是否正在加载中:
private var job: Job? = null
/** 是否正在加载中 */
private var isLoading = false
private fun requestData() {
job?.cancel()
job = lifecycleScope.launch {
isLoading = true
delay(1000)
isLoading = false
}
}
实际上这段代码有bug:假如正在请求中时,job被其他业务取消了,那后面的isLoading = false就没办法重置,导致UI上面一直显示加载中。
改进一下:
private fun requestData() {
job?.cancel()
job = lifecycleScope.launch {
try {
isLoading = true
delay(1000)
} finally {
isLoading = false
}
}
}
这样总没问题了吧,用try finally,重置一定会执行。
虽然重置一定会执行,但重置的时机可能有问题,我们来分析一下,首先给代码标上序号:
private fun requestData() {
job?.cancel() // 1
job = lifecycleScope.launch {
try {
isLoading = true // 2
delay(1000) // 3
} finally {
isLoading = false // 4
}
}
}
假设有A和B两个请求:
- A正在执行第3步
- 此时B开始执行,执行到第2步,即将执行第3步
- A被取消,执行第4步
- B继续执行
此时的UI表现是,B请求没有显示加载中状态。
B虽然设置了isLoading = true,但是由于A被取消重置了isLoading = false,导致B在请求的过程中,没有加载中状态。
之所以会犯这个错误,是因为我们误以为job?.cancel()会同步取消并等待协程结束。
改进一下:
private fun requestData() {
// 保存最后一次的job
val lastJob = job
job = lifecycleScope.launch {
// 取消最后一次的job,并等待它完成
lastJob?.cancelAndJoin()
// ...
}
}
调用Job.cancelAndJoin()方法,取消Job并等待它结束。
这段代码看起来似乎没什么问题,但是很遗憾,在多线程并发时,还是有问题,可能导致同时执行,这边就不继续展开了。
Google救场
难道实现完美的互斥,就这么难吗?
作者在使用Jetpack Compose动画类Animatable时,发现它执行动画的方法是suspend挂起的,那不同协程调用时,它内部是怎么处理互斥逻辑的?
点开源码,发现Google专门写了一个类来处理这种互斥逻辑,这个类是MutatorMutex
看看Google大佬是怎么写的:
class MutatorMutex {
// 当前正在执行的协程信息
private class Mutator(val priority: MutatePriority, val job: Job) {
fun canInterrupt(other: Mutator) = priority >= other.priority
fun cancel() = job.cancel(MutationInterruptedException())
}
// 当前正在执行的协程信息
private val currentMutator = AtomicReference<Mutator?>(null)
private val mutex = Mutex()
// 根据优先级,取消正在执行的协程,或者取消本次调用的协程
private fun tryMutateOrCancel(mutator: Mutator) {
while (true) {
val oldMutator = currentMutator.get()
if (oldMutator == null || mutator.canInterrupt(oldMutator)) {
if (currentMutator.compareAndSet(oldMutator, mutator)) {
oldMutator?.cancel()
break
}
} else throw CancellationException("Current mutation had a higher priority")
}
}
suspend fun <R> mutate(
priority: MutatePriority = MutatePriority.Default,
block: suspend () -> R
) = coroutineScope {
// 1 保存本次调用的协程信息
val mutator = Mutator(priority, coroutineContext[Job]!!)
// 2 互斥逻辑,取消协程
tryMutateOrCancel(mutator)
// 3 执行block
mutex.withLock {
try {
block()
} finally {
currentMutator.compareAndSet(mutator, null)
}
}
}
}
为了方便阅读,把其他不重要的代码都移除了。
mutate就是执行互斥操作时调用的方法,它有一个参数priority,表示本次调用的优先级:
- 如果本次优先级大于等于正在执行的协程,会取消正在执行的协程
- 如果本次优先级小于正在执行的协程,会取消本次调用的协程
具体有哪些优先级不是本文的重点,默认值MutatePriority.Default即可实现我们要的互斥效果。
互斥取消协程的逻辑在tryMutateOrCancel方法中,就是比较优先级,上面已经列出,就不赘述了。
还记得上文中提到的cancelAndJoin吗?第3步那里,使用mutex.withLock实现了等待被取消协程结束的逻辑。
封装
把MutatorMutex拷贝出来,基于它稍微封装一下,拷贝删减的过程就不演示了,程序员最擅长的不就是拷贝吗? 😊
先定义接口:
interface FLoader {
/** 加载状态流 */
val loadingFlow: Flow<Boolean>
suspend fun <T> load(
notifyLoading: Boolean = true,
onLoad: suspend () -> T,
): Result<T>
}
接口比较简单,加载方法load,监听是否正在加载中loadingFlow,看一下实现类:
private class LoaderImpl : FLoader {
private val _mutator = MutatorMutex()
private val _loadingFlow = MutableStateFlow(false)
override val loadingFlow: Flow<Boolean> = _loadingFlow.asStateFlow()
override suspend fun <T> load(
notifyLoading: Boolean,
onLoad: suspend () -> T,
): Result<T> {
return _mutator.mutate {
try {
if (notifyLoading) {
_loadingFlow.value = true
}
val data = onLoad()
// 返回成功结果
Result.success(data)
} catch (e: Throwable) {
if (e is CancellationException) throw e
// 返回失败结果
Result.failure(e)
} finally {
if (notifyLoading) {
_loadingFlow.value = false
}
}
}
}
}
实现类比较简单,重点是代码逻辑都包裹在mutator.mutate{}里面了,保证互斥。
再提供一个创建实现类的方法:
fun FLoader(): FLoader = LoaderImpl()
模拟使用代码:
private val loader = FLoader()
fun requestData() {
lifecycleScope.launch {
loader.load {
// 请求接口
}
}
}
扩展
取消加载
有时候我们需要取消加载,给MutatorMutex加一个取消方法:
suspend fun cancelAndJoin() {
while (true) {
val mutator = currentMutator.get() ?: return
// 取消当前正在执行的协程
mutator.cancel()
try {
// 等待取消协程结束
mutator.job.join()
} finally {
currentMutator.compareAndSet(mutator, null)
}
}
}
代码比较简单,循环取消当前正在执行的协程,并调用job.join()等待它取消完成。
为什么要使用while循环?因为我们在job.join()等待过程中,可能又有新协程调用mutate方法,所以我们要循环判断,直到没有新协程为止。
有的读者可能会想到既然mutate有互斥功能,那传入一个最高的优先级不就实现取消了吗?
这种方案在多个协程调用取消方法时会有问题,我们假设最高优先级是999:
- A协程调用
mutate(999){} - A协程调用还未完成时,B协程也调用了
mutate(999){}
此时B的调用,会把A协程给取消了,因为它们都是最高优先级999。这对于A协程来说是一个意外,因为它想取消别人,结果它自己被人取消了,所以这种方案有一定风险,不建议使用。
最后,给接口和实现类新增取消方法:
interface FLoader {
// ...
suspend fun cancel()
}
private class LoaderImpl : FLoader {
// ...
override suspend fun cancel() {
_mutator.cancelAndJoin()
}
}
等待加载完成
有时候可能在另一个业务中,需要等待某个loader加载完成,由于上面我们已经封装了loadingFlow,所以要实现这个功能就很简单了,加一个扩展方法:
suspend fun FLoader.awaitIdle() = loadingFlow.first { !it }
调用loader.awaitIdle()即可。
结束
完整的代码放在这里:FLoader
感谢你的阅读,大家在项目中是怎么处理这种互斥场景呢?欢迎一起交流学习。