卡顿率降低50%!京东商城APP卡顿监控及优化实践
随着业务需求的不断迭代,京东商城APP功能日趋复杂,性能问题也越发突出。内部统计数据显示,2019年APP非业务相关用户反馈中,性能相关反馈占比达32%,解决线上性能问题已成为提升用户体验、减少用户流失的关键。“工欲善其事,必先利其器”,想要解决线上问题,首先要做到的就是准确定位问题,京东性能监控系统应运而生。本文主要介绍性能监控系统中的APP卡顿监控,围绕卡顿这个性能指标,描述APP卡顿监控系统的搭建过程,分享京东商城APP卡顿优化实战经验和成果。
如何来衡量页面以及APP的卡顿程度,是做卡顿监控首先要考虑的问题。从业界以往经验来看衡量卡顿的指标有很多,如FPS、每帧耗时、卡顿时长等,它们大多是从技术角度来分析卡顿情况,但缺少一个整体的、和业务关联的衡量标准,也无法直接反应受卡顿影响的实际用户比例,所以我们提出了卡顿率的概念。
用户在浏览完当前页退出或进入下个页面时,会将其浏览过程中的卡顿情况进行一次整理上报,将这次上报分为两种情况:用户浏览过程中出现了卡顿/没有出现卡顿,由此可以得到页面卡顿率的定义:
页面卡顿率=该页面存在卡顿的上报数之和/页面总上报量
同样,一段时间内APP整体卡顿上报情况就能反应APP的卡顿率和卡顿影响用户情况:
APP严重卡顿率=存在严重卡顿的上报数之和/总上报量
APP严重卡顿影响用户占比=存在严重卡顿的用户数/总用户数
可以看出,卡顿率从整体来衡量APP的流畅程度,直观的表现出各个业务页面性能的优劣,也能够结合函数调用栈准确定位出影响用户最多的卡顿问题,是整套监控系统对APP卡顿最核心的度量,后续的数据采集、分析和优化也基本围绕卡顿率展开。下面是京东商城卡顿监控系统的整体框架:
整个监控系统分为数据采集、采集策略、数据分析&展示三个模块。
数据采集模块主要负责对卡顿相关指标进行监控并采集对应数据,核心是各指标定义以及采集方案。
采集策略模块通过后台下发的配置控制线上灰度比例,兼顾公共策略以及各指标定制化配置。
数据分析展示模块针对采集的数据进行多维度的聚类分析,去除异常数据,直观的展示APP卡顿性能情况,协助业务定位性能问题。
帧率的定义为大众熟知,它可以直观的表现出用户浏览页面时的整体流畅程度。获取帧率的核心方法如下(以刷新频率60HZ为例):
iOS:
- (void)tick:(CADisplayLink *)link {
_count++;
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
_fps = _count / delta;
_count = 0;
}
Android:
public class MyFrameCallback implements Choreographer.FrameCallback {
@Override
public void doFrame(long frameTimeNanos) {
long times = (frameTimeNanos - lastTime) / 1000000;
recordFrame();//一帧完成渲染
lastTime = frameTimeNanos;
analyseStuck(frameTimeNanos);//卡顿采集
Choreographer.getInstance().postFrameCallback(mFrameCallback);
}
}
这里分享两个采集过程中遇到的问题:
1、iOS:“CPU FPS” VS “GPU FPS”
Android通过 Choreographer 类设置它的 FrameCallback,在每一帧渲染完成后进行统计,所以计算出的是真实帧率。但iOS则是利用了CADisplayLink在主线程回调次数来计算帧率,这种方式丢帧与否取决于主线程是否阻塞;但在iOS页面渲染过程中,当APP通过CPU完成对显示内容的计算等操作后,会提交给一个单独的Render Server进程进行后续处理,该进程主要负责对视图层级进行解码并通过Draw Calls调用GPU进行最后绘制,绘制完成后展示到屏幕上,这就是Core Animation Pipeline的整个过程。由此可见,通过CADisplayLink获取的“帧率”,忽略了Render Server以及GPU部分的阻塞情况,准确的说应该叫做“CPU FPS”。
引用WWDC2019 Metal for Pro Apps[1]里的一张图,当GPU部分耗时超出一个VBL时,屏幕在多个VBL内只能展示同一帧图像(图中的连续显示1号帧、3号帧和5号帧),也会出现丢帧情况。而能造成GPU负担过重的情况很多,例如错误使用离屏渲染就是一种常见场景。所以监控GPU的“丢帧”情况,即“GPU FPS”,是iOS获取帧率的一个重要环节。
参考CPU FPS监控原理,如果可以通过某种方式计算GPU每秒响应次数,即可获得GPU FPS。这里利用OpenGL Synchronization原理[2],当GPU指令队列繁忙时,会导致调用GPU渲染线程阻塞,此时通过对该线程的监控,可以得到GPU FPS具体值。考虑到UIView相关方法只能在主线程调用,最终使用 CAEAGLLayer 来完成GPU FPS监控。效果如下图所示,当模拟页面内滑动时有大量离屏渲染场景,CPUFPS一直稳定在60左右,而GPUFPS则下降比较明显。
2、FPS高真的表示页面流畅么?
就这一问题,一起来看下面一张图。
图中显示的是京东商城Android端某业务在一次性能测试时页面卡顿情况,该场景下FPS最低仅达到57帧/秒,但实际却能明显感觉到滑动卡顿,原因是FPS计算其实仍然是一个1s左右的平均值,图中高FPS是1s内后半段每一帧连续不间断渲染造成的假象,掩盖了前半段某几帧渲染超时的问题。所以连续、平稳的较低帧率比间断性渲染的高帧率拥有更流畅的体验,iOS WWDC2018 Metal Game Performance Optimization[3]中提到的Frame Pacing也是类似的问题。
既然高帧率不完全代表页面流畅,那就需要更精确的卡顿监控方案。
针对指标一FPS中提出的问题,我们设计了一套客户端卡顿信息实时监控系统,以下为整个监控流程:
整个流程的关键点有三处:
①根据每一帧渲染时间长短以及连续出现耗时太长的次数,把客户端出现的卡顿分为三种类型:
严重卡顿:一帧渲染超过O ms称为严重卡顿,表示出现用户能明显感受到的、较为严重的卡顿。经过详细线下测试和线上参数动态调整,同时参考业内标准,这里O取240ms,相当于6帧电影帧的时间(电影帧率一般为24帧/秒)
一般卡顿:连续出现N帧渲染超过M ms称为一般卡顿,表示用户可以感受到的卡顿。和严重卡顿相同,经过线下线上验证,这里N取3,M取80ms,即连续3帧渲染超过80ms。
疑似卡顿:连续出现L帧渲染超过P ms称为疑似卡顿,表示用户可能感受到的卡顿。这里L取2,M取50ms,即连续2帧渲染超过50ms。
对卡顿类型的区分,可以让研发优先关注对用户体验影响更大的卡顿问题。
②判断是否出现卡顿的时机,放在每次单帧渲染耗时正常的节点。此时需要对之前可能出现的卡顿类型以及出现卡顿时采集到的函数调用栈进行提前记录,保证堆栈精确性,以便后续上报。
③离开页面时进行卡顿信息上报,这样可以知道用户在浏览过的各个页面内是否出现过卡顿,后续可以从页面维度分析卡顿率。
在核心代码实现方面,iOS和Android稍有不同:
通用字段释义:
lStuckThreshold//构成疑似卡顿的阻塞次数门槛,目前为2次
cStuckThreshold//构成一般卡顿的阻塞次数门槛,目前为3次
lightBlockTime//疑似卡顿阻塞时间,目前为50ms
criticalBlockTime//一般卡顿阻塞时间,目前为80ms
bigJankTime//严重卡顿阻塞时间,目前为240ms
iOS具体实现示例:
// 监听RunLoop状态改变的回调
static void JDRunLoop_CFRunLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
JDUIStuckMonitor *monitor = (__bridge JDUIStuckMonitor *)info;
monitor->_runLoopActivity = activity;
dispatch_semaphore_t lock = monitor->_lock;
if (lock != NULL) {// RunLoop状态改变,解锁
dispatch_semaphore_signal(lock);
}
}
- (void)initMonitor {
while (YES) {
long result = dispatch_semaphore_wait(_lock, dispatch_time(DISPATCH_TIME_NOW, criticalBlockTime * NSEC_PER_MSEC));
analyseStuck(delta);
}
}
- (void)analyseStuck(NSTimeInterval integerDelta) {
if (_runLoopActivity == kCFRunLoopBeforeSources || _runLoopActivity == kCFRunLoopAfterWaiting) {
if (integerDelta < lightBlockTime) {
if (_lastIntegerDelta >= criticalBlockTime) {
[self p_cstuckTimeArrAppend:integerDelta];//计算单帧真实耗时
}else if (_lastIntegerDelta < lightBlockTime) {
[self p_judgeStuck];//判断是否出现卡顿
}
}else {
if (++_lstuckCount && _lstuckCount % lStuckThreshold == 0) [self recordStackBackTrace];
if (integerDelta < criticalBlockTime) {
if (_lastIntegerDelta >= criticalBlockTime) {
[self p_cstuckTimeArrAppend:integerDelta];//计算单帧真实耗时
}
}else if (integerDelta >= criticalBlockTime) {
if (_lastIntegerDelta >= criticalBlockTime) {
[self p_cstuckTimeArrAppend:integerDelta];//计算单帧真实耗时
}
if (++_cstuckCount && _cstuckCount % cStuckThreshold == 0) [self recordStackBackTrace];
}
}
_lastIntegerDelta = integerDelta;
}else if (_runLoopActivity == kCFRunLoopBeforeWaiting) {//RunLoop空闲,可以记录卡顿信息
if (_lstuckCount == 0 && _cstuckCount == 0) continue;
[self p_judgeStuck];//判断是否出现卡顿
}
}
Android具体实现示例:
//承接获取FPS代码
private void analyseStuck(long frameTimeNanos){
if (mLastFrameTimeNanos != 0) {
long diffMs = TimeUnit.NANOSECONDS.toMillis(frameTimeNanos - mLastFrameTimeNanos);
//严重卡顿
if (diffMs >= bigJankTime) {
bJank = true;
} else if (diffMs >= lightBlockTime) {//开始不正常帧
mLightBlockCount++;
if (diffMs >= criticalBlockTime) {
mCriticalBlockCount++;
}
} else {//开始正常帧了
if (bJank) {
//判定为严重卡顿并上报
} else if (mCriticalBlockCount >= cStuckThreshold) {
//判定为一般卡顿并上报
} else if (mLightBlockCount >= lStuckThreshold) {
//判定为疑似卡顿并上报
}
}
}
mLastFrameTimeNanos = frameTimeNanos;
}
为了方便理解,代码做了一定精简。Android可以直接获取一帧渲染耗时,代码相对清晰,而iOS则需要结合RunLoop状态来判断主线程空闲与否并计算单帧耗时,实现的时候需要留意以下几个问题:
首先,从CFRunLoop源码[4]中可以看到,当RunLoop处于 kCFRunLoopBeforeSources 或者 kCFRunLoopAfterWaiting 状态时,执行 CFRunLoopDoBlocks 均有可能超时,所以判断主线程繁忙需要同时考虑这两种场景,而 Runloop 处于 kCFRunLoopBeforeWaiting 状态则表示主线程空闲。于此同时,RunLoop状态变化的通知需要等到 CFRunLoopDoBlocks 执行完成,仅通过状态来计算单帧耗时稍有滞后,进而导致函数调用栈不准确,所以这里借助信号量来实现循环监听,确保在出现卡顿的第一时间可以精确捕获堆栈信息。最后,考虑到一帧耗时超过80ms时同样会被信号量截断,此时仅通过integerDelta无法计算当前帧耗时,需要结合前一次耗时进行判断,从而得出真实单帧耗时。
iOS watchDog问题也可以使用这套卡顿监控逻辑来采集。大家对于watchDog造成的 0x8badf00d 崩溃并不陌生,这种主线程长时间没有响应导致APP崩溃的问题,由于线上缺少稳定、详细的崩溃日志上报,一直以来难以监控和解决。基于以上的卡顿监控系统,对其稍作改造:
如上图,在主线程正在处理事件时不断记录主线程繁忙时长,而在其空闲时清除记录。如果APP是因线程卡死导致Crash,那在下次启动的时候本地会留存主线程繁忙、卡死时长以及对应函数调用栈的记录,由此可以用来判断线程卡死的发生并进行上报。这种主动监控方案有两个优点:
日志采集时机前置,能准确获取出现线程卡死时函数调用栈,帮助业务准确定位问题
可以捕捉用户遇到线程卡死问题而手动杀死APP的场景,将问题边界补充完整
最后,不同于从技术角度对卡死时长的定义,我们将主线程卡住超过5s导致APP崩溃称为线程卡死问题,这一定义更贴合用户体验,也是在结合一些业内经验并且经过线上数据筛查得出的结论。
介绍完核心指标的采集,继续来看数据分析的几个重要方面。
整个卡顿监控框架中采用了总-分式开关控制系统。
总开关主要控制整个卡顿监控的开启与否,同时考虑到线上性能监控不需要全量采集,在总开关中增加了灰度控制,确保可以随时进行线上切量,在特殊情况下可以完全关闭卡顿监控。再者有时需要观测部分用户遇到的卡顿问题,总开关中也包含了黑白名单系统,用于定向监控。
各指标独立开关主要负责各卡顿指标开关与否以及详细策略(例如几种卡顿阻塞时间和阻塞次数)的下发,保证了各指标监控的独立性;同时针对各业务进行黑白名单控制,确保监控范围可控。
可以从三个维度来分析线上用户的卡顿数据:通用维度、页面维度以及函数调用维度
通用维度包括APP整体卡顿率、整体卡顿影响用户占比以及系统、机型和版本分布几个方面,卡顿率相关之前已经提到其作用,而后面三个方面可以帮助研发关注到一些不同机型、不同系统的特定卡顿问题,以及版本迭代过程中新出现的问题。
页面维度包括页面基本信息、页面帧率信息以及页面卡顿率等详细信息,将各个业务的卡顿诊断详情直观、准确的呈现出来,帮助我们在诸多问题中找到其所属业务,极大提升了问题推进和定位的效率。
函数调用维度,通过对函数调用栈的聚类,整理出影响APP卡顿的Top堆栈,从而在APP整体卡顿出现异常波动时,及时定位到问题所在,集中力量优先解决。在后续的卡顿问题推进过程中,函数调用维度的数据分析,很大程度上保证了APP卡顿问题的快速解决。
实践是检验真理的唯一标准。京东卡顿监控系统上线后收效明显,检测到明显性能问题10+,协助30+业务进行卡顿优化。从京东APP的整体卡顿率上看,iOS卡顿率降低50%+,Android卡顿率也有30%+的优化,很大程度上提升了APP整体流畅程度。
以下为几类常用优化策略:
UI绘制优化:
减少视图层级、使用视图缓存、减少离屏渲染、采用异步渲染等
I/O操作合理化:
减少I/O频次、提高I/O效率等
避免主线程大量计算:
楼层高度计算缓存、异步数据解析等
除了常见优化策略外,也可以根据卡顿上报堆栈进行定向优化,下面是iOS中的一个典型案例:
1、Push截屏造成的卡顿
iOS在页面Push前会进行截屏操作,用来优化页面侧滑返回时的用户体验。最早京东使用的截屏方法是:
- (BOOL)drawViewHierarchyInRect:(CGRect)rect afterScreenUpdates:(BOOL)afterUpdates
但该方法在iOS13初期系统的大屏手机(iPhoneX/iPhonePlus系列)上耗时较为严重,最高可达300多ms,导致用户点击频道入口能明显感知到延迟。由于符合条件的手机上能稳定复现,该问题在整体卡顿率中占比一度高达50%,是导致iOS卡顿率较高的主要原因。与此同时,该问题也会影响页面首屏渲染耗时,如果以页面秒开为标准,截屏花费的300ms耗时也相当可观。
线上监控系统显示,从iOS13.4.1开始,Apple一定程度上修复了drawViewHierarchyInRect耗时太长的问题,但考虑到受影响系统占比仍旧较高、用户量较大,在不修改原有逻辑的前提下,通过替换系统截屏方法snapshotViewAfterScreenUpdates:进行修复。从线上数据来看,修复后受影响机型的截屏耗时平均缩短到100ms以下,对比之前的300ms效果明显。下面是一张问题修复前后的对比图,可以看到用户点击跳转的延迟明显减小。
2、线程卡死
线程卡死监控同样定位到了很多线上问题,这类问题大多难以通过测试环节发现,但在线上这样的大量用户场景下就会频繁出现,同时其对用户体验的影响相当于Crash,非常值得关注。这里分享遇到的几个线程卡死案例:
socket close()
部分业务在使用BSD Socket编程时,在主线程调用了close()方法,在某些场景下该方法会等待传输完成后再返回,从而导致当前线程阻塞[5]:
If O_NONBLOCK is not set and there have been no signals posted for the STREAM, and if there is data on the module's write queue, close() shall wait for an unspecified time (for each module and driver) for any output to drain before dismantling the STREAM.
If fildes refers to a socket, close() shall cause the socket to be destroyed. If the socket is in connection-mode, and the SO_LINGER option is set for the socket with non-zero linger time, and the socket has untransmitted data, then close() shall block for up to the current linger interval until all data is transmitted.
避免在主线程执行close()方法,能有效解决这类线程卡死问题。
UIApplication openURL:
UIApplication提供的openURL:方法是iOS开发中常用的一种路由方式,但该方法同样会同步阻塞当前线程,并且在某些场景下阻塞时间足以触发线程卡死,Apple在iOS10开始提供了新的方法作为替代:
// Options are specified in the section below for openURL options. An empty options dictionary will result in the same behavior as the older openURL call, aside from the fact that this is asynchronous and calls the completion handler rather than returning a result.The completion handler is called on the main queue.
- (void)openURL:(NSURL *)url options:(NSDictionary *)options completionHandler:(void (^ __nullable)(BOOL success))completion;
目前整个京东APP的线程卡死率相对同期Crash率要高出许多,对比业内其他APP也还有不小的优化空间。另外由于京东对OOM定义采用了FaceBook提出的排除法[6],所以FOOM崩溃中也包含了部分线程卡死问题,但由于没有明确的崩溃原因,一直难以和真正的OOM区分开。线程卡死监控上线后,也将这部分数据从OOM中分离开来,对线上FOOM崩溃的分析起到了正向推动作用。
在应用体量越发庞大、交互越发复杂的今天,APP性能监控任重而道远。目前京东性能监控系统已经完成了对APP启动、页面首屏渲染耗时以及卡顿率等指标的监控,同时也构建了从开发到测试再到线上的监控体系,既可以在开发测试阶段暴露性能问题,将风险前置,也能够对线上问题进行及时监控,全面保障用户体验。后续我们会继续采集其他性能指标,攻克线上性能瓶颈,全方位提高APP的性能。
本文作者:陈嘉文、周祥、张吉鑫
[1] WWDC2019 Metal for Pro Apps
[2] https://www.khronos.org/opengl/wiki/Synchronization
[3] WWDC2018 Metal Game Performance Optimization
[4] https://opensource.apple.com/source/CF/CF-1151.16/CFRunLoop.c.auto.html
[5] https://pubs.opengroup.org/onlinepubs/9699919799/functions/close.html
[6] https://engineering.fb.com/ios/reducing-fooms-in-the-facebook-ios-app/