飛書前端提到的競態問題,在 Android 上怎麼解決?

語言: CN / TW / HK

請點贊關注,你的支援對我意義重大。

:fire: 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 上了,原始碼地址: http://github.com/pengxurui/DemoHall/tree/main/RaceRequestDemo ,你可以直接執行來體驗和觀察忽略響應或取消請求的效果。有用請給 Star 鼓勵,謝謝。

弱網環境使用 Charles 進行模擬:

使用 XIAOPENG 來過濾日誌,觀察請求開始和請求響應:

logcat

XIAOPENG: 請求開始:http://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E6%8E%A8%E8%8D%90%E5%9B%BE%E4%B9%A6
XIAOPENG: 請求結束:http://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E6%8E%A8%E8%8D%90%E5%9B%BE%E4%B9%A6
XIAOPENG: 請求開始:http://my-json-server.typicode.com/pengxurui/FakeServer/posts?type=%E7%83%AD%E9%97%A8%E5%9B%BE%E4%B9%A6
XIAOPENG: 請求結束:http://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 物件的方式來取消請求,也算有所收穫。關注我,我們下次見。

參考資料

你的點贊對我意義重大!微信搜尋公眾號 [彭旭銳],希望大家可以一起討論技術,找到志同道合的朋友,我們下次見!

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