飞书前端提到的竞态问题,在 Android 上怎么解决?
请点赞关注,你的支持对我意义重大。
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 上怎么解决?的更多相关文章
- Linux内核中的并发与竞态概述
1.前言 众所周知,Linux系统是一个多任务的操作系统,当多个任务同时访问同一片内存区域的时候,这些任务可能会相互覆盖内存中数据,从而造成内存中的数据混乱,问题严重的话,还可能会导致系统崩溃. 2. ...
- LDD3之并发和竞态-completion(完毕量)的学习和验证
LDD3之并发和竞态-completion(完毕量)的学习和验证 首先说下測试环境: Linux2.6.32.2 Mini2440开发板 一開始难以理解书上的书面语言,这里<linux中同步样例 ...
- linux设备驱动归纳总结(四):5.多处理器下的竞态和并发【转】
本文转载自:http://blog.chinaunix.net/uid-25014876-id-67673.html linux设备驱动归纳总结(四):5.多处理器下的竞态和并发 xxxxxxxxxx ...
- UNIX高级环境编程(10)进程控制(Process Control)- 竞态条件,exec函数,解释器文件和system函数
本篇主要介绍一下几个内容: 竞态条件(race condition) exec系函数 解释器文件 1 竞态条件(Race Condition) 竞态条件:当多个进程共同操作一个数据,并且结果依赖 ...
- 【Linux开发】linux设备驱动归纳总结(四):5.多处理器下的竞态和并发
linux设备驱动归纳总结(四):5.多处理器下的竞态和并发 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...
- JustAuth 1.15.9 版发布,支持飞书、喜马拉雅、企业微信网页登录
新增 修复并正式启用 飞书 平台的第三方登录 AuthToken 类中新增 refreshTokenExpireIn 记录 refresh token 的有效期 PR 合并 Github #101:支 ...
- linux设备驱动归纳总结(四):4.单处理器下的竞态和并发【转】
本文转载自:http://blog.chinaunix.net/uid-25014876-id-67005.html linux设备驱动归纳总结(四):4.单处理器下的竞态和并发 xxxxxxxxxx ...
- Smart210---学习记录 竞态与并发
竞态与并发 自旋锁 若一个进程要访问临界资源,测试锁空闲,则进程获得这个锁并继续执行:若测试结果表明锁扔被 占用,进程将在一个小的循环内重复“测试并设置”操作,进行所谓的“自旋”,等待自旋锁持有者释 ...
- Linux驱动设计——并发与竞态控制
并发的概念:多个执行单元同时.并行被执行. 共享资源:硬件资源(IO/外设等),软件上的全局变量.静态变量等. 四种并发控制机制(对共享资源互斥的访问):原子操作.自旋锁(spinlock).信号量( ...
随机推荐
- 16岁男生信息竞赛成瘾心理出现问题 妈妈:他竟说要AK我
16岁男生信息竞赛成瘾心理出现问题 -- 妈妈:他竟说要AK我 "我儿子最近快走火入魔了,医生,你救救他吧."40出头的林女士拉着儿子走进江苏省人民医院临床心理科.近几年,信息竞赛 ...
- ARM学习1
ARM相关概念 1.ARM的发展史 1. 1978年,CPU公司 Cambridge processing Unit 2. 1979年 Acorn 3. 1985年, 32位,8MHz, 使用的精简指 ...
- CF1665A GCD vs LCM
- 谷歌浏览器Chrome官方下载地址
经常看到朋友的电脑上安装是魔改的谷歌浏览器.这里将谷歌浏览器官方的下载地址放在这里.有需要的朋友可以自己去下载. 下载地址 Chrome最新稳定在线安装版:https://www.google.cn/ ...
- 面试突击58:truncate、delete和drop的6大区别
在 MySQL 中,使用 truncate.delete 和 drop 都可以实现表删除,但它们 3 个的使用场景和执行效果完全不同,接下来我们来盘点一下. truncate.delete.drop区 ...
- Skywalking光会用可不行,必须的源码分析分析 - Skywalking Agent &插件解析
3 Skywalking源码导入 接上文,已经学习了Skywalking的应用,接下来我们将剖析Skywalking源码,深度学习Skywalking Agent. 3.1 源码环境搭建 当前最新版本 ...
- 基于Vue.js的Web视频播放器插件vue-vam-video@1.3.6 正式发布
前言 今日正式发布一款基于Vue.js的Web视频播放器插件.可配置,操作灵活.跟我一起来体验吧! 线上地址体验 基于vue3.0和vue-vam-video,我开发了一款在线视频播放器. 网址: h ...
- iNeuOS工业互联网操作系统,视图建模(WEB组态)增加2154个行业矢量图元、大屏背景及相关图元
1. 概述 现在三维数字孪生(3D)比较流行,各行业各领域的项目也都在上数字孪生项目或是项目中包括数字孪生模块,能做的厂家也很多.从全厂区的应用视觉的冲击力还是比较震撼,但是数字孪生不太可能包括 ...
- Python音频处理基础知识,这不是轻轻松松~~~
大家好鸭,我是小熊猫 咱今天来讲一讲音频处理的基础知识上才艺~~~ 1.声音的基础 2.python读取.wav音频 欢迎加入白嫖Q群:660193417### import wave import ...
- Redis入门到精通01
Redis入门到精通 目录 Redis入门到精通 一.Redis缓存框架基本介绍 1.1Redis的应用场景 二.Redis的安装方式 2.1Windows操作系统安装Redis 2.2Linux操作 ...