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

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. SMFL 教程&个人笔记

    本文大部分来自官方教程的Google翻译 但是加了一点点个人的理解和其他相关知识 转载请注明 原文链接 :https://www.cnblogs.com/Multya/p/16273753.html ...

  2. 『忘了再学』Shell基础 — 23、其他环境变量配置文件

    目录 1.注销时生效的环境变量配置文件 2.其他配置文件 3.Shell登录信息相关文件 (1)/etc/issue文件说明 (2)/etc/issue.net文件说明 (3)/etc/motd文件说 ...

  3. Java基本运算

    目录 运算符 运算符优先级 运算 自增(++)自减(--)运算 数学运算(Math类) 逻辑运算 位运算 拓展运算符 三元运算符 视频课程 运算符 Java语言支持如下运算符: 算术运算符: +, - ...

  4. Simple, Fast Malicious Multiparty Private Set Intersection-解读

    文本记录阅读该论文的笔记. 这是文章框架,来自视频. 介绍 本文主要解决恶意攻击下安全的多方PSI,主要用到两大技术OPPRF和OKVS,构造合谋和不合谋的协议. 基础知识 OPPRF 这部分在OPR ...

  5. JAVA - 缓冲和缓存

    JAVA - 缓冲和缓存 缓冲 Buffer 功能:协调上下层应用之间的性能差异.通过缓冲区的缓冲,当上层组件性能优于下层组件的时候,缓冲可以有效减少上层组件对下层组件的等待时间. 使用场景:IO流中 ...

  6. Cabloy-CMS中区块的开发与效果

    关于区块 Cabloy-CMS引入了区块的概念,通过区块可以快速向文章添加各种类型的内容,比如:插入一个地图子页面.插入一首音乐,等等 Cabloy-CMS中的区块可以类比于Wordpress古腾堡编 ...

  7. 从位图到布隆过滤器,C#实现

    前言 本文将以 C# 语言来实现一个简单的布隆过滤器,为简化说明,设计得很简单,仅供学习使用. 感谢@时总百忙之中的指导. 布隆过滤器简介 布隆过滤器(Bloom filter)是一种特殊的 Hash ...

  8. 关闭windows更新、设置自启动、提高开发机性能

    做Java开发的朋友都知道,每次开机启动一堆的软件和工具,包括未写完的文档,是非常花时间的,加上一桌面的快捷方式,往往不是那么容易直接找到.windows的自动更新往往在凌晨自动启动,导致很多软件被异 ...

  9. Training a classifier

    你已经学习了如何定义神经网络,计算损失和执行网络权重的更新. 现在你或许在思考. What about data? 通常当你需要处理图像,文本,音频,视频数据,你能够使用标准的python包将数据加载 ...

  10. Docker安装NextCloud使用MySQL

    安装 1.拉取并启动MySQL,最好把数据可目录挂载到宿主机,以便容器被误删后恢复: docker run --name=nextcloud_db \ -e MYSQL_ROOT_PASSWORD=X ...