请点赞关注,你的支持对我意义重大。

Hi,我是小彭。本文已收录到 GitHub · AndroidFamily 中。这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] 带你建立核心竞争力。

前言

昨天,看到飞书团队一篇技术分享 《如何解决前端常见的竞态问题》 ,自己的项目中也存在类似的问题,也是容易出 Bug的地方。字节这篇文章是从 Web 端的视角切入的,借鉴意义有限,这篇文章我们从 Android 的视角展开讨论。

其实,异步竞态问题并不是一个难题,但是本着精益求精的态度,对问题做一次全面分析,再思考有哪些解决方案,哪些是最优最适合的方案,对自己和社区都会有帮助。

学习路线图:


1. 什么是竞态问题

1.1 问题定义

简单来说, 竞态问题就是用户短时间内重复地触发同一个动作产生多个异步请求,而由于请求的响应时延是不稳定的,可能会出现早发起的请求反而比晚发起的请求慢响应的情况,导致界面呈现效果出现混乱、重复、覆盖等异常。

为了帮助你理解问题,以下列举出更多常见的竞态场景:

  • 1、搜索关联词: 在搜索输入栏中,随着用户输入显示对应的关联词,竞态问题可能会展示旧的搜索词的关联词;
  • 2、类型切换: 在列表流中,点击不同的类型选项展示对应类型的数据,竞态问题可能会展示旧类型数据,或重复展现多个状态的数据;
  • 3、下拉刷新: 在加载分页数据的同时下拉刷新,竞态问题可能会导致刷新后展示旧的分页数据,而不是最新的数据。

1.2 问题分解

我们试着对竞态问题进行拆解,梳理出竞态问题的必要条件:

  • 必要条件 1 - 异步请求: 并发执行多个异步请求才可能出现竞争,同步请求不存在竞争;
  • 必要条件 2 - 关联状态或时序: 当请求的响应与某个状态或调用顺序相关联时才可能出现竞争,与状态无关或与调用顺序无关的场景说明能够容忍混乱的结果,不考虑竞态问题(例如,页面分步加载时,哪个请求先返回都可以,不存在竞争);
  • 必要条件 3 - 响应不稳定: 当请求的响应时延不稳定才可能出现竞争,如果响应时延非常稳定,就不会打破请求和响应的顺序,也就不会存在竞争。

1.3 解决方案

在充分理解问题后,现在我们开始思考解决方案。前面我们分解出了竞态问题的 3 个必要条件,那么解决问题的思路是否可以从破坏竞态问题的必要条件下手呢?

  • 方案 1 - 破坏异步请求条件: 在前一个请求的响应返回(成功或失败)前,限制用户触发请求的交互动作,从而将多个异步请求转换为多个同步请求;

竞态问题的第 2 个条件是响应与某个状态或调用顺序关联,那么我们可以尝试通过过滤或取消的手段,保证程序只接收最新状态或时序下的响应:

  • 方案 2 - 忽略过期响应: 在响应的数据结构中增加标识 ID,在响应返回后,先检查标识 ID 是否与最新状态的 ID 是否相同。如果不相同则直接将该响应丢弃。
  • 方案 3 - 取消过期请求: 在同位竞争的请求中增加同一个标识 TAG,在发起新请求时,先取消相同标识 TAG 的请求。相较于忽略过期响应,取消过期请求有可能拦截未发送的请求,对服务端比较友好。

如果响应时延非常稳定,就不会打破请求和响应的顺序,那我们可以尝试提高响应稳定性:

  • 方案 4 - 提高稳定性: 通过本地缓存或内存缓存等方案提高响应的稳定性,或者增加一层请求包装层,强行控制响应的顺序。由于稳定性不能绝对保证,只能作为辅助方案。

下面,我们展开对此具体分析。


2. 破坏异步请求条件

第 1 个方案在前一个请求的响应返回(成功或失败)前,限制用户触发请求的交互动作,从而将多个异步请求转换为多个同步请求。这样的话,就破坏了竞态请求的第 1 个条件异步请求,自然就可以确保请求顺序和响应顺序一致。 例如,在请求过程中增加 Loading、Toast 、置灰、防抖等等。

这个方案最大的问题是对用户体验有影响,因此有的同学会认为这个方案不合理。 这需要转变下思考方式了,解决方案的设计过程是多维度的目标优化的过程,而不是单一维度的判断过程。 虽然限制用户交互对用户体验有受损,但是在当前场景下用户对体验受损的容忍程度如何,对并发的要求是否强烈,都需要根据当前场景具体分析的,不能一概而论。

比如,在哪些场景下同步请求是合理的呢?

  • 1、分页场景: 用户对列表滑动过程中的分页加载是有预期的,并且并发请求也不能加快显示速度,因此这同步的分页请求是合理的,并且会在加载过程中给予局部 Loading 而不是全局 Loading。
  • 2、金融场景: 用户对金融交易操作的结果是非常敏感,用户对体验受损的容忍度高。

3. 忽略过期响应

第 2 个方案是在响应的数据结构中增加标识 ID,随后在响应返回后,先检查响应中的标识 ID 是否与最新状态的 ID 是否相同。如果不相同则直接将该响应丢弃。但是,这个前提是服务端接口响应中的数据结构必须带上这个标记 ID,否则,就需要客户端自行在接口响应中拼接。

示例程序

class BookModel {
suspend fun fetchBooks(type: String?): BooksEntry? {
return try {
val api: BookApi = RetrofitHolder.retrofit.create(BookApi::class.java)
val list = api.fetchBooks(type)
// 由于服务端接口没有提供 type 类型,所以需要自己包装一层
BooksEntry(type, list)
} catch (ex: Exception) {
null
}
}
}
class BookViewModel : ViewModel() {

    private val mModel = BookModel()

    val mBooks = MutableSharedFlow<BooksEntry?>()

    // 过滤过期响应开关
private var filterResponseEnabled = false // 取消过期请求开关
private var filterRequestEnabled = false // 最新状态标识
private var mSelectedType: String = "" // 请求热门图书
fun onClickHot(context: Context) {
viewModelScope.launch {
mSelectedType = "热门图书"
val books = mModel.fetchBooks(context, mSelectedType, filterRequestEnabled)
// 忽略过期响应
if (filterResponseEnabled && mSelectedType != books?.type) {
Toast.makeText(context, "一次响应被过滤", Toast.LENGTH_SHORT).show()
return@launch
}
// 返回
mBooks.emit(books)
}
} fun enableFilterResponse(enable: Boolean) {
filterResponseEnabled = enable
} fun enableFilterRequest(enable: Boolean) {
filterRequestEnabled = enable
}
}

4. 取消过期请求

相对于前面几种方案,取消过期请求的价值最大(拦截请求到服务端的数量),对业务的侵入最小。

4.1 取消 OkHttp 请求

  • 方法 1 - 通过 Call#cancel() 方法取消请求: OkHttp Call 接口提供了取消请求的 API,缺点是需要维护旧请求的 Call 对象;

okhttp3.Call.kt

interface Call : Cloneable {
fun cancel()
}
  • 方法 2:通过 Request#tag() 批量取消请求: OkHttp Request 提供了打标记的 API,那么我们可以给同位竞争的请求都打上相同的 TAG 标记,在每次发起请求时先批量取消所有相同 TAG 的请求,这样就不需要维护旧请求的 Call 对象了。

批量取消请求

object RetrofitHolder {

    /**
* 全局 Retrofit 对象
*/
val client by lazy {
OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.eventListener(eventListener)
.build()
} /**
* 批量删除请求
*
* @param tag 标签
*/
fun cancelCallWithTag(tag: String) {
// 等待队列
for (call in client.dispatcher.queuedCalls()) {
// 注意,不能用 tag()
if (call.request().tag(String::class.java) == tag) {
call.cancel()
}
}
// 请求队列
for (call in client.dispatcher.runningCalls()) {
// 注意,不能用 tag()
if (call.request().tag(String::class.java) == tag) {
call.cancel()
}
}
}
}

示例程序

// 批量取消过期请求
RetrofitHolder.cancelCallWithTag("BOOKS")
// 发起新请求
val request = Request.Builder()
.tag("BOOKS")
.build()
...

需要注意一下,cancelCallWithTag() 方法内不能使用 tag() 去匹配标签。Request 内部使用了一个 Key 为 Class 对象的散列表来存储 TAG 标记,tag(”BOOKS”) 对应的是 Key 为 String.class 的键值对,而 tag() 对应的是 Key 为 Any.class 的键值对,两者就匹配不上了。

okhttp3.Request.kt

class Request internal constructor(
...,
internal val tags: Map<Class<*>, Any>
) { // 获取标记,Key 为 Any.class
fun tag(): Any? = tag(Any::class.java) // 获取标记,Key 为 type
fun <T> tag(type: Class<out T>): T? = type.cast(tags[type]) // 设置标记,Key 为 value 对象的类型
open fun <T> tag(type: Class<in T>, tag: T?) = apply {
if (tag == null) {
tags.remove(type)
} else {
if (tags.isEmpty()) {
tags = mutableMapOf()
}
tags[type] = type.cast(tag)!! // Force-unwrap due to lack of contracts on Class#cast()
}
}
}
  • 方法 3 - 自定义 OkHttp 拦截器: 在想到方法 2 之前,我最初的想法是在 Request 中增加特殊的请求头 Header 字段,自定义拦截器或 EventListener 中维护 Header 和请求的映射关系,在发起新请求时通过 Header 来取消过期请求。后面了解到方法 2 之后,就没必要走这个思路了。相比之下,自定义拦截器会更灵活,将来有特殊的需求可以考虑往这个思路上靠。

4.2 取消 Retrofit 请求

实际项目中我们会更多地使用 Retrofit 框架,我们都知道 Retrofit 是对 OkHttp 的封装,那 Retrofit 是否良好地继承了 OkHttp 取消请求的能力呢?

retrofit2.Call.java

public interface Call<T> extends Cloneable {
void cancel();
}

可以看到 Retrofit Call 方法也是提供了取消请求的 API 的,使用 方法 1 - 通过 Call#cancel() 方法取消请求 是支持的, 方法 2:通过 Request#tag() 批量取消请求 支持吗?最后发现 Retrofit 提供了一个 @TAG 注解来设置标签,最终也是调用了 OkHttp Request 的 tag() API,那么批量请求也支持了。Nice!

示例程序

interface BookApi {

    /**
* 普通方法
*/
@GET("/pengxurui/FakeServer/posts")
fun fetchBooks(@Query("type") type: String?, @Tag tag: String): Call<List<BooksEntry.Book>> /**
* suspend 方法
*/
@GET("/pengxurui/FakeServer/posts")
suspend fun fetchBooks(@Query("type") type: String?, @Tag tag: String): List<BooksEntry.Book>
}

看一眼处理 @TAG 注解的源码:

retrofit2.ParameterHandler.java

abstract class ParameterHandler<T> {
static final class Tag<T> extends ParameterHandler<T> {
final Class<T> cls; Tag(Class<T> cls) {
this.cls = cls;
} @Override
void apply(RequestBuilder builder, @Nullable T value) {
builder.addTag(cls, value);
}
}
}

retrofit2.RequestBuilder.java

final class RequestBuilder {
<T> void addTag(Class<T> cls, @Nullable T value) {
// OKHttp API
requestBuilder.tag(cls, value);
}
}

5. 示例程序

本文提到的示例程序我已经放到 Github 上了,源码地址:https://github.com/pengxurui/DemoHall/tree/main/RaceRequestDemo ,你可以直接运行来体验和观察忽略响应或取消请求的效果。有用请给 Star 鼓励,谢谢。

弱网环境使用 Charles 进行模拟:

使用 XIAOPENG 来过滤日志,观察请求开始和请求响应:

logcat

XIAOPENG: 请求开始:https://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E6%8E%A8%E8%8D%90%E5%9B%BE%E4%B9%A6
XIAOPENG: 请求结束:https://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E6%8E%A8%E8%8D%90%E5%9B%BE%E4%B9%A6
XIAOPENG: 请求开始:https://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E7%83%AD%E9%97%A8%E5%9B%BE%E4%B9%A6
XIAOPENG: 请求结束:https://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E7%83%AD%E9%97%A8%E5%9B%BE%E4%B9%A6

6. 总结

今天,我们分析了 Android 竞态请求的问题,并思考了相应的解决方案,最后找到 OkHttp 或 Retrofit 通过 TAG 批量取消请求的方法。小彭之前还不知道 Retrofit @TAG 这个注解,所以在使用 Retrofit 时都是采用 方法 1 维护旧 Call 对象的方式来取消请求,也算有所收获。关注我,我们下次见。


参考资料

你的点赞对我意义重大!微信搜索公众号 [彭旭锐],希望大家可以一起讨论技术,找到志同道合的朋友,我们下次见!

生活不只有眼前的苟且,还有逐月而行的田野。

飞书前端提到的竞态问题,在 Android 上怎么解决?的更多相关文章

  1. Linux内核中的并发与竞态概述

    1.前言 众所周知,Linux系统是一个多任务的操作系统,当多个任务同时访问同一片内存区域的时候,这些任务可能会相互覆盖内存中数据,从而造成内存中的数据混乱,问题严重的话,还可能会导致系统崩溃. 2. ...

  2. LDD3之并发和竞态-completion(完毕量)的学习和验证

    LDD3之并发和竞态-completion(完毕量)的学习和验证 首先说下測试环境: Linux2.6.32.2 Mini2440开发板 一開始难以理解书上的书面语言,这里<linux中同步样例 ...

  3. linux设备驱动归纳总结(四):5.多处理器下的竞态和并发【转】

    本文转载自:http://blog.chinaunix.net/uid-25014876-id-67673.html linux设备驱动归纳总结(四):5.多处理器下的竞态和并发 xxxxxxxxxx ...

  4. UNIX高级环境编程(10)进程控制(Process Control)- 竞态条件,exec函数,解释器文件和system函数

    本篇主要介绍一下几个内容: 竞态条件(race condition) exec系函数 解释器文件    1 竞态条件(Race Condition) 竞态条件:当多个进程共同操作一个数据,并且结果依赖 ...

  5. 【Linux开发】linux设备驱动归纳总结(四):5.多处理器下的竞态和并发

    linux设备驱动归纳总结(四):5.多处理器下的竞态和并发 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...

  6. JustAuth 1.15.9 版发布,支持飞书、喜马拉雅、企业微信网页登录

    新增 修复并正式启用 飞书 平台的第三方登录 AuthToken 类中新增 refreshTokenExpireIn 记录 refresh token 的有效期 PR 合并 Github #101:支 ...

  7. linux设备驱动归纳总结(四):4.单处理器下的竞态和并发【转】

    本文转载自:http://blog.chinaunix.net/uid-25014876-id-67005.html linux设备驱动归纳总结(四):4.单处理器下的竞态和并发 xxxxxxxxxx ...

  8. Smart210---学习记录 竞态与并发

    竞态与并发 自旋锁 若一个进程要访问临界资源,测试锁空闲,则进程获得这个锁并继续执行:若测试结果表明锁扔被 占用,进程将在一个小的循环内重复“测试并设置”操作,进行所谓的“自旋”,等待自旋锁持有者释 ...

  9. Linux驱动设计——并发与竞态控制

    并发的概念:多个执行单元同时.并行被执行. 共享资源:硬件资源(IO/外设等),软件上的全局变量.静态变量等. 四种并发控制机制(对共享资源互斥的访问):原子操作.自旋锁(spinlock).信号量( ...

随机推荐

  1. 【Java】在IDEA中将Javafx项目打包成为可运行的.jar文件

    在使用Javafx制作一个图形化界面程序的时候,我遇到了打包文件的难题. 按照网上给出的解决方案构建出来的jar文件总是没有办法运行. 以下是我最终的解决方案. 我使用的IDE是IntelliJ ID ...

  2. [gym102978C] Count Min Ratio

    [gym102978C] Count Min Ratio 给定 \(B\) 个蓝色的球. \(R\) 个红色的球以及一个绿色的球,同颜色的球不可区分.对于一种球的排列方式,记 \(l_B,r_B,l_ ...

  3. 如何用HMS Core位置和地图服务实现附近地点路径规划功能

    日常出行中,路径规划是很重要的部分.用户想要去往某个地点,获取到该地点的所有路径,再根据预估出行时间自行选择合适的路线,极大方便出行.平时生活中也存在大量使用场景,在出行类App中,根据乘客的目的地可 ...

  4. 跨域问题和使用 cookie 的限制

    前言 在我的文章 使用 cookie 的身份验证和授权 的最后,讲到了跨域问题,这篇文章就简单介绍跨域的相关知识,并说明在 net core 中怎么设置跨域. 使用的版本为 net6,并使用 Mini ...

  5. 【python】tile函数简单介绍

    转:https://blog.csdn.net/april_newnew/article/details/44176059格式:tile(A,reps)* A:array_like* 输入的array ...

  6. Acwing 1927 自动补全(知识点:hash,二分,排序)

    读完题目第一想法是trie树 ,不过好像没怎么做过trie树的题,看y总给的知识点是二分排序,所以就有了如下思路: 但是但是,看完其他题解之后才坚定了我的想法,原来真的是这样排序,暴力啊! 具体步骤 ...

  7. 用QT制作3D点云显示器——QtDataVisualization

    因为QT的三维显示模块QtDataVisualization已经对个人开发免费开放了,所以在制作点云,地图,表格之类的东西的时候,其实我们都不需要使用QtCharts或者QOpenGL模块了.直接使用 ...

  8. 实测:云RDS MySQL性能是自建的1.6倍

    1. 摘要 基于之前写的「云厂商 RDS MySQL 怎么选」的文章,为了进一步了解各云厂商在RDS MySQL数据库性能上的差异,本文将对自建MySQL.阿里云.腾讯云.华为云和AWS 的 RDS ...

  9. go: 如何编写一个正确的udp服务端

    udp的服务端有一个大坑,即如果收包不及时,在系统缓冲写满后,将大量丢包. 在网上通常的示例中,一般在for循环中执行操作逻辑.这在生产环境将是一个隐患.是的,俺就翻车了. go强大简易的并发能力可以 ...

  10. EasyExcel导出添加批注

    直接看代码.根据个人需要做改动 注:POI也可以做批注,文章链接https://www.cnblogs.com/qq1445496485/p/15622664.html /** * 导出(批注) * ...