前端性能优化方法与实战 - 前 58 集团技术副总监 - 拉勾教育
上一讲我们介绍了首屏时间的指标采集,这一讲我们来聊聊前端其他的性能指标采集,比如白屏、卡顿和网络环境。
你乘火车、地铁、飞机都走过安检吧?如果说首屏时间类似你过安检的时刻,那么,白屏时间就是你排队到安检点的时间,而卡顿,就是你排的队伍停止了,前面人的不动了。本来,大家都希望过安检的时间越快越好,结果,排队花时间,停滞不动更耗时间,这无疑会让人越来越没有耐心。
浏览器的白屏和卡顿也是如此,它们直接影响用户的体验,影响用户对平台的信任。而网络环境呢,它的影响更大,同时也是性能优化的盲区,这一点我在之前的移动端 M 站性案例分析里面就中介绍过。所以,这一讲,我们就专门聊聊这三方面的指标采集。
白屏指标采集
白屏时间是指从输入内容回车(包括刷新、跳转等方式)后,到页面开始出现第一个字符的时间。白屏时间的长短会影响用户对 App 或站点的第一印象。
白屏指标怎么采集呢?我们先来回顾一下前面讲过的浏览器的页面加载过程:
客户端发起请求 -> 下载 HTML 及 JS/CSS 资源 -> 解析 JS 执行 -> JS 请求数据 -> 客户端解析 DOM 并渲染 -> 下载渲染图片 -> 完成渲整体染。
在这个过程中,客户端解析 DOM 并渲染之前的时间,都算白屏时间。所以,白屏时间的采集思路如下:白屏时间 = 页面开始展示时间点 - 开始请求时间点。如果你是借助浏览器的 Performance API 工具来采集,那么可以使用公式:白屏时间 FP = domLoading - navigationStart。
这是浏览器页面加载过程,如果放在 App 场景下,就不太一样了,App 下的页面加载过程:
初始化 WebView -> 客户端发起请求 -> 下载 HTML 及 JS/CSS 资源 -> 解析 JS 执行 -> JS 请求数据 -> 服务端处理并返回数据 -> 客户端解析 DOM 并渲染 -> 下载渲染图片 -> 完成整体渲染。
App 下的白屏时间,多了启动浏览器内核,也就是 Webview 初始化的时间。这个时间必须通过手动采集的方式来获得,而且因为线上线下时间差别不大,线下采集即可。具体来说,在 App 测试版本中,程序在 App 创建 WebView 时打一个点,然后在开始建立网络连接打一个点,这两个点的时间差就是 Webview 初始化的时间。
卡顿指标采集
所谓卡顿,简单来说就是页面出现卡住了的不流畅的情况。 提到它的指标,你是不是会一下就想到 FPS(Frames Per Second,每秒显示帧数)?FPS 多少算卡顿?网上有很多资料,大多提到 FPS 在 60 以上,页面流畅,不卡顿。但事实上并非如此,比如我们看电影或者动画时,素虽然 FPS 是 30 (低于 60),但我们觉得很流畅,并不卡顿。
FPS 低于 60 并不意味着卡顿,那 FPS 高于 60 是否意味着一定不卡顿呢?比如前 60 帧渲染很快(10ms 渲染 1 帧),后面的 3 帧渲染很慢( 20ms 渲染 1 帧),这样平均起来 FPS 为 95,高于 60 的标准。这种情况会不会卡顿呢?实际效果是卡顿的。因为卡顿与否的关键点在于单帧渲染耗时是否过长。
但难点在于,在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。
以 H5 为例,H5 场景下获取 FPS 方案如下:
var fps_compatibility= function () {
return (
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
}
);
}();
var fps_config={
lastTime:performance.now(),
lastFameTime : performance.now(),
frame:0
}
var fps_loop = function() {
var _first = performance.now(),_diff = (_first - fps_config.lastFameTime);
fps_config.lastFameTime = _first;
var fps = Math.round(1000/_diff);
fps_config.frame++;
if (_first > 1000 + fps_config.lastTime) {
var fps = Math.round( ( fps_config.frame * 1000 ) / ( _first - fps_config.lastTime ) );
console.log(`time: ${new Date()} fps is:`, fps);
fps_config.frame = 0;
fps_config.lastTime = _first ;
};
fps_compatibility(fps_loop);
}
fps_loop();
function isBlocking(fpsList, below=20, last=3) {
var count = 0
for(var i = 0; i < fpsList.length; i++) {
if (fpsList[i] && fpsList[i] < below) {
count++;
} else {
count = 0
}
if (count >= last) {
return true
}
}
return false
}
利用 requestAnimationFrame 在一秒内执行 60 次(在不卡顿的情况下)这一点,假设页面加载用时 X ms,这期间 requestAnimationFrame 执行了 N 次,则帧率为 1000* N/X,也就是 FPS。
由于用户客户端差异很大,我们要考虑兼容性,在这里我们定义 fps_compatibility 表示兼容性方面的处理,在浏览器不支持 requestAnimationFrame 时,利用 setTimeout 来模拟实现,在 fps_loop 里面完成 FPS 的计算,最终通过遍历 fpsList 来判断是否连续三次 fps 小于 20。
如果连续判断 3 次 FPS 都小于 20,就认为是卡顿。
那么,在 App 侧,怎么采集卡顿指标呢?
App 侧可以拿到单帧渲染时长,直接让 App 取到单帧渲染时长,如果在 Android 环境下,可以直接取到单帧渲染时长。代码如下:
private void calculateLag(long frameTimeNanos){
mLastFrameTimeNanos = System.nanoTime();
if (mLastFrameTimeNanos != 0) {
long costTime= (frameTimeNanos - mLastFrameTimeNanos)/ 1000000.0F;
if (costTime>= bigJankTime) {
bJank = true;
} else if (costTime>= criticalBlockTime) {
mCriticalBlockCount++;
} else {
if (bJank) {
} else if (mCriticalBlockCount >= cStuckThreshold) {
}
}
}
mLastFrameTimeNanos = frameTimeNanos;
}
通过 mChoreographer.getFrameTimeNanos 和 System.nanoTime 计算出单帧渲染时长,如果单帧渲染时长超过 250ms,则严重卡断,反之连续 5 次超过 50ms,判定为卡顿。
如果是 iOS 场景,要复杂一些,需要借助 CFRunLoop 来取到单帧渲染时长(CFRunLoop,它负责监听输入源,并调度处理)。代码如下:
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
MyClass *object = (__bridge MyClass*)info;
object->activity = activity;
dispatch_semaphore_t semaphore = moniotr->semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)registerObserver
{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
semaphore = dispatch_semaphore_create(0);
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (st != 0)
{
if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
{
if (++timeoutCount < 5)
continue;
}
}
timeoutCount = 0;
}
});
}
通过 CFRunLoopObserverContext 将休眠、唤醒的状态通知 Observer,然后通过 dispatch_async 在子线程时监控节点之间的时间,来计算主线程的时长。
这里监控主线程是否卡顿这块儿,借鉴了导航 App 对交通堵塞问题的判断逻辑。
导航 App 无法判断某个地点是否出了问题,如车坏在当路,正在施工,或者发生事故剐蹭了这些,但可以借助 GPS 和定位仪,拿到你两个节点之间的行驶速度,就可以推断出这个地点是否拥堵。这里的监控思路也正是如此,使用状态 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 两个节点之间的运行时间,和某个阈值(250ms)做比较,根据比较结果判定主线程是否出现卡顿。
为什么会出现 App 白屏时间过长或卡顿问题呢?一般 WebView 初始化慢、DNS 解析慢、视图树过于复杂和主线程被阻塞等都会导致问题出现,但很多情况下白屏时间和卡顿都和网络环境有关。为了保证页面顺畅,我们需要做一些服务降级处理,比如对电商网站来说,高清图可以用文本代替,仅展示购买按钮和价格等核心内容。而要实现这个功能,就必须先做好网络环境采集。
网络环境采集
为什么不能直接拿到网络环境数据呢?如果在 App 内, 我们可以通过 App 提供的接口获取到网络情况,但在端外(App 外部环境,比如微信里面的页面,或者 PC 站、手机浏览器下的页面)我们就没法直接拿到当前网络情况了。这时怎么办呢?
一个做法是拿到两张不同尺寸图片的加载时间,通过计算结果来判定当前网络环境。
具体来说,我们在每次页面加载时,通过客户端向服务端发送图片请求,比如,请求一张 1_1 像素的图片和一张 3_3 像素的图片,然后在图片请求之初打一个时间点,在图片 onLoad 完成后打一个时间点,两个时间点之差,就是图片的加载时间。
接着,我们用文件体积除以加载时间,就能得出两张图片的加载速度,然后把两张图片的加载速度求平均值,这个结果就可以当作网络速度了。
因为每个单页面启动时,都会做一次网速采集,得到一个网络速度,我们可以把这些网络速度做概率分布,就能得出当前网络情况是 2G (750-1400ms)、3G (230-750ms)、4G 或者 WiFi(0-230ms)。
下面这张图是 2016 年我在做移动端 M 站性能优化项目时,做的图片测速结果分布。横坐标是速度,纵坐标是网速在分布中的分位值,最左侧是 wifi 网络,中间是 3G 网络,最右侧是 2G 网络。
图片测速结果分布
根据这张图,你会发现自己的用户都停留在什么网段。比如,我在 2016 年发现,58 同城的用户测速分布,50% 的用户停留在 2G 水平。知道了这点,我们后续针对的优化手段就会更多侧重 2G 下的网络优化方案了。
小结
前面我们详细讲了白屏时间采集、卡顿指标采集和网络环境采集,有了这个采集,我们就能很容易定位用户体验层的很多问题,比如加载感受、交互感受和弱网下的服务降级处理,等等。
在白屏部分,里面提到的更偏加载阶段的白屏,实际工作中我们会遇到不少广义上的白屏,比如后端接口异常导致的白屏,数据加载中产生的白屏,甚至还有图片与视频加载过程或等待过程中的白屏。
那么,现在就给你留一个问题:这些广义的白屏问题怎么采集监控呢?
欢迎在评论区和我沟通。下一讲,我们进入上报 SDK 及策略设计部分。
源码地址:https://github.com/lagoueduCol/WebPerformanceOptimization-xifeng/tree/master/chapter5