飞书前端提到的竞态问题,在 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 上了,源码地址://github.com/pengxurui/DemoHall/tree/main/RaceRequestDemo ,你可以直接运行来体验和观察忽略响应或取消请求的效果。有用请给 Star 鼓励,谢谢。

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

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

logcat

XIAOPENG: 请求开始://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E6%8E%A8%E8%8D%90%E5%9B%BE%E4%B9%A6
XIAOPENG: 请求结束://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E6%8E%A8%E8%8D%90%E5%9B%BE%E4%B9%A6
XIAOPENG: 请求开始://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E7%83%AD%E9%97%A8%E5%9B%BE%E4%B9%A6
XIAOPENG: 请求结束://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 对象的方式来取消请求,也算有所收获。关注我,我们下次见。


参考资料

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

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

Tags: