腾讯课堂小程序性能极致优化——网络请求优化篇
腾讯课堂小程序性能极致优化——网络请求优化篇 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 分位
我们发现:对网络请求顺序的干预在耗时长的网络请求中效果更为明显。也就是说,这次的优化效果主要作用在弱网用户上。
该表现是符合预期的,因为在弱网环境下,更容易发生请求堆积,对业务请求造成阻塞。
这是腾讯课堂小程序优化的第二篇专项优化文章,前一篇《腾讯课堂小程序性能极致优化——综合篇》已收获满满好评,后面我们还有【独立分包与性能测试】这个专项优化,敬请期待。