昨天,看到飞书团队一篇技术分享 《如何解决前端常见的竞态问题》 ,自己的项目中也存在类似的问题,也是容易出 Bug的地方。字节这篇文章是从 Web 端的视角切入的,借鉴意义有限,这篇文章我们从 Android 的视角展开讨论。
其实,异步竞态问题并不是一个难题,但是本着精益求精的态度,对问题做一次全面分析,再思考有哪些解决方案,哪些是最优最适合的方案,对自己和社区都会有帮助。
学习路线图:
简单来说, 竞态问题就是用户短时间内重复地触发同一个动作产生多个异步请求,而由于请求的响应时延是不稳定的,可能会出现早发起的请求反而比晚发起的请求慢响应的情况,导致界面呈现效果出现混乱、重复、覆盖等异常。
为了帮助你理解问题,以下列举出更多常见的竞态场景:
我们试着对竞态问题进行拆解,梳理出竞态问题的必要条件:
在充分理解问题后,现在我们开始思考解决方案。前面我们分解出了竞态问题的 3 个必要条件,那么解决问题的思路是否可以从破坏竞态问题的必要条件下手呢?
竞态问题的第 2 个条件是响应与某个状态或调用顺序关联,那么我们可以尝试通过过滤或取消的手段,保证程序只接收最新状态或时序下的响应:
如果响应时延非常稳定,就不会打破请求和响应的顺序,那我们可以尝试提高响应稳定性:
下面,我们展开对此具体分析。
第 1 个方案在前一个请求的响应返回(成功或失败)前,限制用户触发请求的交互动作,从而将多个异步请求转换为多个同步请求。这样的话,就破坏了竞态请求的第 1 个条件异步请求,自然就可以确保请求顺序和响应顺序一致。 例如,在请求过程中增加 Loading、Toast 、置灰、防抖等等。
这个方案最大的问题是对用户体验有影响,因此有的同学会认为这个方案不合理。 这需要转变下思考方式了,解决方案的设计过程是多维度的目标优化的过程,而不是单一维度的判断过程。 虽然限制用户交互对用户体验有受损,但是在当前场景下用户对体验受损的容忍程度如何,对并发的要求是否强烈,都需要根据当前场景具体分析的,不能一概而论。
比如,在哪些场景下同步请求是合理的呢?
第 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() // 过滤过期响应开关 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 } }
相对于前面几种方案,取消过期请求的价值最大(拦截请求到服务端的数量),对业务的侵入最小。
Call#cancel()
方法取消请求: OkHttp Call 接口提供了取消请求的 API,缺点是需要维护旧请求的 Call 对象;okhttp3.Call.kt
interface Call : Cloneable { fun cancel() }
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, Any> ) { // 获取标记,Key 为 Any.class fun tag(): Any? = tag(Any::class.java) // 获取标记,Key 为 type fun tag(type: Class ): T? = type.cast(tags[type]) // 设置标记,Key 为 value 对象的类型 open fun tag(type: Class , 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() } } }
实际项目中我们会更多地使用 Retrofit 框架,我们都知道 Retrofit 是对 OkHttp 的封装,那 Retrofit 是否良好地继承了 OkHttp 取消请求的能力呢?
retrofit2.Call.java
public interface Callextends 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> /** * suspend 方法 */ @GET("/pengxurui/FakeServer/posts") suspend fun fetchBooks(@Query("type") type: String?, @Tag tag: String): List
}
看一眼处理 @TAG
注解的源码:
retrofit2.ParameterHandler.java
abstract class ParameterHandler{ static final class Tag extends ParameterHandler { final Class cls; Tag(Class cls) { this.cls = cls; } @Override void apply(RequestBuilder builder, @Nullable T value) { builder.addTag(cls, value); } } }
retrofit2.RequestBuilder.java
final class RequestBuilder {void addTag(Class cls, @Nullable T value) { // OKHttp API requestBuilder.tag(cls, value); } }
本文提到的示例程序我已经放到 Github 上了,源码地址: DemoHall/RaceRequestDemo at main · pengxurui/DemoHall · GitHub ,你可以直接运行来体验和观察忽略响应或取消请求的效果。有用请给 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
今天,我们分析了 Android 竞态请求的问题,并思考了相应的解决方案,最后找到 OkHttp 或 Retrofit 通过 TAG 批量取消请求的方法。小彭之前还不知道 Retrofit @TAG
这个注解,所以在使用 Retrofit 时都是采用 方法 1 维护旧 Call 对象的方式来取消请求,也算有所收获。关注我,我们下次见。
你的点赞对我意义重大!微信搜索公众号 [彭旭锐],希望大家可以一起讨论技术,找到志同道合的朋友,我们下次见!
生活不只有眼前的苟且,还有逐月而行的田野。