飛書前端提到的競態問題,在 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 對象的方式來取消請求,也算有所收穫。關注我,我們下次見。
參考資料
- 如何解決前端常見的競態問題 —— 飛書技術團隊 著
你的點贊對我意義重大!微信搜索公眾號 [彭旭銳],希望大家可以一起討論技術,找到志同道合的朋友,我們下次見!
生活不只有眼前的苟且,還有逐月而行的田野。