Android - 异步任务互斥

682 阅读7分钟

举个例子:A正在请求接口中,此时B也想请求该接口,那么B该选择哪种方案:

  1. 直接发起B请求
  2. 取消A请求,再发起B请求
  3. 等待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两个请求:

  1. A正在执行第3步
  2. 此时B开始执行,执行到第2步,即将执行第3步
  3. A被取消,执行第4步
  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

感谢你的阅读,大家在项目中是怎么处理这种互斥场景呢?欢迎一起交流学习。