騰訊課堂小程序性能極致優化——網絡請求優化篇 https://mp.weixin.qq.com/s/g2mLpWhGsrMEud-i8xD6YQ
近期,我們對騰訊課堂小程序做了一次全方位的性能優化,本篇文章將從網絡請求的角度分享一種優化的思路。我們引入了一種請求排隊的策略,通過控制不同優先級請求的發送順序,保障影響頁面渲染的關鍵請求能夠及時發送,並迅速得到返回結果。由請求測速數據統計,我們的關鍵請求耗時實現了 50-100 ms,約 15% 的優化。
1. 導火索 🔥
某個平淡無奇的工作日,我們的小程序在 iOS 上突然無比的卡頓。
緊急排查之后,發現我們使用的一個第三方上報服務異常,導致我們的上報請求一直處於 pending 狀態。
這時,小程序官方文檔的網絡使用說明映入眼簾:
wx.request、wx.uploadFile、wx.downloadFile 的最大並發限制是 10 個。
也就是說,由於並發限制,當處於 pending 狀態的請求數達到 10 個時,后續調用 wx.request 發送的請求可能會被阻塞。
時間較為久遠,沒有留下證物,我們通過模擬請求超時來還原一下當時的現場。
借助 whistle 的 resDelay 方法,可以將 report 和 log 兩種上報請求延遲 5000ms 返回:
可以看到由於上報請求一直處於 pending 狀態,導致后續發送的業務請求 get_homepage_feeds_h5 也一直在 pending 中,頁面加載變得卡頓起來。
排查到問題后,我們緊急下掉了異常請求,現網突發的卡頓問題得以解決。
但是,這次的異常引起了大家的注意:上報請求竟然會對業務請求造成如此大的影響。我們似乎應該通過一些方式,保障與用戶體驗相關的業務請求正常發送。
2. 設想 🔍
網絡請求的耗時與許多因素相關,用戶的網絡環境以及提供接口的服務質量,我們無法在前端控制。
但如果我們可以給小程序的網絡請求設置優先級,當多個請求並發時,讓低優先級的上報請求給高優先級的業務請求讓路,是否也能讓業務請求速度提升,優化用戶體驗呢?
3. 方案實現 📃
我們設計的請求優先策略如下:
- 將請求分為高優先與低優先兩種等級。
- 當並發請求數超過一定閾值時,僅發送高優先級請求,攔截低優先級請求的發送。
- 並發請求數量下降后,補發被攔截的低優先級請求。
- 設置最長等待時間,超時后主動發送低優先請求,避免過度延時。
小程序的 HTTPS 請求是通過 wx.request API 發送的,我們可以通過攔截這個 API 來實現對所有請求的發送順序控制。整塊邏輯我們封裝到一個 排隊請求模塊 來實現,模塊中提供一個與 wx.request 具有相同函數簽名的方法,用來替換原始的請求 API。
3.1. 關鍵配置
threshold: 並發請求數的閾值。當正在進行的請求數超過這個閾值時,延遲發送低優先請求。
maxWaitingTime: 最長等待時間。等待隊列中的請求等待超過該時間后,主動補發,避免過度延時。
lowPriority: 一組匹配方式(正則等)。用於判斷請求是否屬於低優先級。
目前我們設置 threshold 為 5,主要出於以下兩方面考慮:
- 給后續可能到來的業務請求預留 5 個坑位,避免阻塞。
- 已有 5 個請求並發時,延遲上報請求發送,也可減少並發造成的網絡爭搶。
3.2. 初步設計
模塊基本功能由 3 個類實現:
-
請求分發器 QueueRequest:對新的請求進行分發。
-
加入等待隊列:正在進行的請求數超過設置的 threshold,且請求為低優先級時;
-
加入請求池:請求為高優先級,或並發數未達到 threshold。
-
等待隊列 WaitingQueue:維護需要延時發送的請求等待隊列。在請求池空閑或請求超過最長等待時間時,補發等待請求。
-
請求池 RequestPool:發送請求並維護所有正在進行的請求的狀態。對外暴露正在進行的請求數量,並在有請求完成時通知等待隊列嘗試補發。
這里簡單通過各個類的接口來描述一下類之間的包含、調用關系:
QueueRequest 類
class QueueRequest {
// 請求池
private requestPool: RequestPool;
// 等待隊列
private waitingQueue: WaitingQueue;
// 用於替代wx.request的請求方法
request(opts: WechatMiniprogram.RequestOption): WechatMiniprogram.RequestTask;
}
WaitingQueue 類
class WaitingQueue {
// 等待發送的請求隊列
private queue: QueueRequestOption[];
// 檢查隊列是否有超過最大等待時長的請求
private checkQueue();
// 將一個參數為opt的新請求加入等待隊列
public enqueue(opts: QueueRequestOption);
// 發送等待隊列的第一個的請求
public dequeue();
// 獲取等待隊列的長度
public getWaitingNum():number;
}
RequestPool 類
interface RequestPoolConfig {
onReqComplete?: () => void;
}
class RequestPool {
// 真實的wx.request方法
private originRequest = wx.request.bind(wx);
// 請求池,記錄正在進行中的請求
private pool;
// 將一個參數為opts的請求加入請求池並發送
public add(opts: QueueRequestOption);
// 請求完成時,將標識為seq的請求移出請求池,並觸發onReqComplete事件
private remove(seq: number);
// 獲取請求池內請求數量
public getReqNum():number;
}
QueueRequest中維護了 WaitingQueue 和 RequestPool 兩個類的實例。
通過 WaitingQueue.enqueue() 將請求加入等待隊列;
通過 ReuqestPool.add() 將請求放入請求池,直接發送;
QueueRequest.request()方法用於替代 wx.request,在實際調用 wx.request 前執行請求分發邏輯。
此時,我們已經可以通過 QueueRequest.request() 方法,控制不同優先級請求的發送順序了。
3.3. RequestTask維護
然而,根據 API 文檔,調用 wx.request 會返回一個 RequestTask 對象。那么進入等待隊列的請求,在沒有調用 wx.request 時,要如何同步地返回該對象呢?
我們設計了 RequestTaskProxy 類來模擬真實的 RequestTask。
- 在請求未實際發送前先返回該對象代理,其實現了 RequestTask 的接口。
- 記錄下各個接口的調用參數,保存到內部的 operations 隊列中。
- 在請求實際發送后,將 operations 調用記錄重放到真實的 RequestTask。
增加了 RequestTaskProxy 后,新的請求進入排隊請求模塊,形成了如下的調用鏈路:
RequestTaskProxy 類的大致實現如下:
export class RequestTaskProxy implements WechatMiniprogram.RequestTask {
// 真實的RequestTask
private task?: WechatMiniprogram.RequestTask;
// 未綁定真實RequestTask時的操作記錄
private operations: [];
/**
* 設置真正發起請求后返回的requestTask
* @param requestTask 實際wx.request返回對requestTask
*/
public setRequestTask(requestTask: WechatMiniprogram.RequestTask) {
this.task = requestTask;
this.operations.forEach((op) => {
// 將this.operations重放到requestTask上
// ...
});
this.operations = [];
}
// 模擬RequestTask的接口
public abort() {
if (this.task) {
this.task.abort();
} else {
this.operations.push({ type: 'abort' });
}
}
// 模擬RequestTask的接口
public offHeadersReceived(callback?: WechatMiniprogram.OffHeadersReceivedCallback) {
if (this.task) {
this.task.offHeadersReceived(callback);
} else {
this.operations.push({ type: 'off', cb: callback });
}
}
// 模擬RequestTask的接口
public onHeadersReceived(callback: WechatMiniprogram.OnHeadersReceivedCallback) {
// 類似offHeadersReceived
// ...
}
}
3.4. 替換 wx.request
由於 wx.request 屬性的 writable 被設置為 false,不能直接賦值,我們只能通過 Object.defineProperty 替換該屬性的 value 值。
class QueueRequest {
request(opts: WechatMiniprogram.RequestOption): RequestTask;
// 替換wx.request方法
make() {
Object.defineProperty(wx, 'request', { value: this.request.bind(this) });
}
}
/**
* 對外暴露的方法,調用后注入排隊請求邏輯
* @param config 配置項
*/
export function useRequestQueue(config: QueueRequestConfig) {
const queueRequest = new QueueRequest(config);
queueRequest.make();
return queueRequest;
}
在小程序中,調用 useRequestQueue() 方法,即可攔截 wx.request API,按照配置的優先級控制請求順序了。
4. 優化效果 📈
在后台服務沒有任何改動的情況下,我們在前端干預了部分用戶網絡請求的順序,並上報統計了從發起業務請求到獲得請求結果的耗時。
該耗時的計算近似於請求 API 觸發 complete 回調的時間 - 調用注入后的 wx.request 的時間。
我們隨機選擇了一批用戶進行灰度,經過一段時間后,得到了對比數據:
4.1. 請求平均耗時對比
注:圖表的縱軸為用戶 業務請求(高優先) 的平均耗時,橫軸為時間軸
綠色曲線來自不做干預的普通用戶
黃色曲線來自應用請求優先策略的灰度用戶
從圖中可以看到,對網絡請求順序的干預效果明顯,灰度用戶業務請求耗時平均有 50-100ms,約 15% 的優化。
4.2. 不同耗時請求的優化效果對比
注:圖表的縱軸為用戶 業務請求(高優先) 耗時,橫軸為時間軸
曲線大致可分為三組,每組兩條曲線,選取不同百分位的請求耗時對比,從上到下依次為:
- 請求耗時的 80 分位
- 請求耗時的 50 分位(中位數)
- 請求耗時的 20 分位
我們發現:對網絡請求順序的干預在耗時長的網絡請求中效果更為明顯。也就是說,這次的優化效果主要作用在弱網用戶上。
該表現是符合預期的,因為在弱網環境下,更容易發生請求堆積,對業務請求造成阻塞。
這是騰訊課堂小程序優化的第二篇專項優化文章,前一篇《騰訊課堂小程序性能極致優化——綜合篇》已收獲滿滿好評,后面我們還有【獨立分包與性能測試】這個專項優化,敬請期待。