(TOP)移动应用性能监控CIMP

随着应用产品迭代,产品功能日趋完善,我们会越来越关注功能外的东西,比如:用户体验,而应用的性能会直接影响用户体验。那么,如何提升应用性能?

本文研究部分产出已经开源,项目地址:AndroidGodEye

事实上,性能优化是一个长期且反复的过程,我们不妨把性能优化的过程概括如下:

  1. 监控App版本1的线上性能情况
  2. 版本2围绕有问题的点,定位代码问题并修复
  3. 版本2发布之后继续监控并观察是否有性能提升

而本文主要关注第一点:监控性能情况。

在研究了美团、腾讯GT等性能监控方案后,制定了自己的一套完备的性能监控方案CIMP。

首先,CIMP把应用性能分为两大类,一是资源耗费,二是流畅度,前者指的是应用消耗系统多少资源,比如电量、流量等等,后者是广义上的”流畅度”,直接关系到用户体验,比如网络请求慢、页面绘制时间久、app启动等待时间长等等。

根据以上分类,CIMP整理了若干性能细分指标并设计了一个较为完善的分层方案,方案自底向上分别为信息采集层,数据处理层和输出层,信息采集层主要根据Android和iOS的平台差异,采取不同的方式获取原始性能数据,并交由数据处理层进行筛选加工,处理后的数据根据不同的阶段(Debug和Release阶段)输出到不同的看板,如图:

performance_framework

性能指标

启动时间

应用一般都会有闪屏页,毫无疑问,闪屏页作为用户进入应用的入口,停留时间不宜过长,所以优化应用启动时间至关重要,那么如何衡量启动时间?

CIMP认为,启动时间就是用户看到首页的时间,在Android中,我们会从application启动开始计算,直到主页绘制完成,当然这过程中会有若干异常情况,比如:权限请求弹窗,用户强制退出等等,在这些情况下,CIMP会认为本次启动时间无效,另外,在Android中,app如果只是普通的退出页面而进程没有被杀死,那么再次进入应用的时候是非常快的,这时候application中的流程不会执行,而杀死进程则会走创建application的流程,所以CIMP会将启动类型分为热启动和冷启动分别进行记录。

流量

流量虽然不会直接影响用户体验,但是应用如果不注意节制流量,同样会引起用户的不满,尤其是漫游等一些流量敏感的情况下更甚,现在Android手机上都会有流量统计,每个应用耗费流量多少一目了然,所以监控流量使用也十分必要。

那么如何衡量应用的流量消耗?CIMP定义:

流量消耗速度 = session使用时间内的流量总量/session时间

这里的session指的是用户使用应用的时长,CIMP定义的session阶段是应用进入前台的时候开始,进入后台(包括退出应用)的时候结束。session的概念会在下文多次出现。

在Android中,流量的获取很简单,使用TrafficStats即可,比如:

1
2
3
4
5
TrafficSnapshot snapshot = new TrafficSnapshot();
snapshot.rxTotalKB = TrafficStats.getTotalRxBytes() / 1024f;
snapshot.txTotalKB = TrafficStats.getTotalTxBytes() / 1024f;
snapshot.rxUidKB = TrafficStats.getUidRxBytes(android.os.Process.myUid()) / 1024f;
snapshot.txUidKB = TrafficStats.getUidTxBytes(android.os.Process.myUid()) / 1024f;

CPU

CPU使用率过高容易导致卡顿,手机发热等,所以对应用而言也是一个重要的指标。

我们知道,每个线程的执行都需要cpu分配时间片,线程在这个时间里努力执行任务,然后等待或争取下次时间片的分配,所以可以认为,应用占用的cpu是系统分配给应用的cpu时间片比cpu总时间片:

应用cpu占用 = session内应用占用cpu时间片/session内cpu总时间片

Android sdk没有提供获取CPU的api,所以CIMP通过读取系统文件(/proc/stat和/proc/pid/stat),解析并计算出CPU的各项指标,包括应用占用的cpu、系统进程占用的cpu、用户进程占用的cpu等等。

由于获取cpu本身也是会消耗一定的性能,所以不建议定时或者频繁获取cpu的值,而是在需要的时候获取一次即可。

内存

对Android而言,内存分为RAM(物理内存)、PSS(应用实际占用物理内存)和heap(虚拟机堆内存),三个指标对于应用的意义是完全不同的。

更多关于内存的概念可以移步:Android内存–你需要知道的一切

RAM

RAM就是我们一般意义的内存,一般来说只需要知道用户的RAM总体占用即可,当然,除了主动去获取RAM占用情况之外,我们也可以在某些特定场景下去获取内存的占用,比如页面加载的时候或者卡顿的时候,方便我们了解问题发生时的上下文。RAM的获取也十分方便,直接使用Android sdk提供的api即可:

1
2
3
4
5
6
7
8
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
final ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
am.getMemoryInfo(mi);
RamMemoryInfo ramMemoryInfo = new RamMemoryInfo();
ramMemoryInfo.availMem = mi.availMem / 1024;
ramMemoryInfo.isLowMemory = mi.lowMemory;
ramMemoryInfo.lowMemThreshold = mi.threshold / 1024;
ramMemoryInfo.totalMem = totalMem;

PSS

PSS指的是应用占用的实际物理内存,也叫比例分配共享库占用的内存。

比例分配共享库:由于多个应用会共享一些系统库,对于这部分内存,系统计算PSS的时候会平均分摊给每个应用。

同样的,我们也可以主动收集和在特定场景收集这一指标,示例代码如下:

1
2
3
4
5
6
7
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
Debug.MemoryInfo memoryInfo = am.getProcessMemoryInfo(new int[]{pid})[0];
PssInfo pssInfo = new PssInfo();
pssInfo.totalPss = memoryInfo.getTotalPss();
pssInfo.dalvikPss = memoryInfo.dalvikPss;
pssInfo.nativePss = memoryInfo.nativePss;
pssInfo.otherPss = memoryInfo.otherPss;

Heap

Heap指虚拟机的堆内存,关于JVM或Dalvik内存模型这里不做赘述,我们只需要清楚,我们的对象分配在Heap中,这部分的内存分配及释放关系着应用卡顿与否及崩溃率(臭名昭著的OutOfMemoryError),即使虚拟机有GC机制可以让我们尽量少的关注内存释放,但由于程序的一些问题,依然会导致内存泄漏、部分对象占用过大内存、新生代内存分配及释放过于频繁(内存抖动)甚至内存溢出等等问题。

虽然在JVM规范中,堆、方法区、虚拟机栈和本地方法栈都规定了内存溢出,但实际在Android中,我们一般关心堆内存的溢出问题即可。

由于堆内存的值变化非常频繁且GC时机不好掌握,所以不适合采取定时获取,CIMP只会在部分场景,诸如卡顿、内存泄漏等时机收集。

在Android中,Heap的获取同样有sdk提供的api:

1
2
3
4
5
Runtime runtime = Runtime.getRuntime();
DalvikHeapMem dalvikHeapMem = new DalvikHeapMem();
dalvikHeapMem.freeMem = runtime.freeMemory() / 1024;
dalvikHeapMem.maxMem = Runtime.getRuntime().maxMemory() / 1024;
dalvikHeapMem.allocated = (Runtime.getRuntime().totalMemory() - runtime.freeMemory()) / 1024;

内存泄漏

另外,由于在开发过程中避免不了遇到内存泄漏问题,所以CIMP采用了Square公司的LeakCanary方案,在Debug阶段输出到CIMP的面板来监控内存泄漏的情况。

LeakCanary的基本原理就是使用Reference类的特性:Reference引用的对象在被回收的时候,该Reference会加入到ReferenceQueue中,这样,只需要在认为某一个对象应该释放(加入队列)的时候观察ReferenceQueue中是否存在该Reference即可。

在Debug阶段,开发人员在浏览器中可以实时观察内存泄漏的情况以及泄漏对象的引用路径,便于分析泄漏原因,如图:

ctrip_ibu_performance_leak

由于检测内存泄漏需要dump并分析hprof文件,在这个过程中会挂起所有线程几百毫秒甚至几秒时间,所以不能用于生产环境!

卡顿

毫无疑问,卡顿是非常重要的一个性能指标,一般我们会想到使用帧率(fps)来表示页面刷新频率,fps值越高,表示渲染越流畅。所以一开始,CIMP使用了fps来描述卡顿程度。

Fps

通过比较手机的实际帧率和理论帧率,我们可以一定程度看出流畅度的变化。

CIMP定义

帧率流畅度 = 实际帧率/理论帧率

在Android中,我们可以通过Choreographer类来获取帧率信息:

1
2
3
4
5
6
7
8
9
10
11
if (mFrameCallback == null) {
mFrameCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
//做些计算
//每次执行时间 0.45 毫秒内
Choreographer.getInstance().postFrameCallback(mFrameCallback);
}
}
};
Choreographer.getInstance().postFrameCallback(mFrameCallback);

但是实践下来,由于Choreographer的回调次数非常频繁,而每次的回调消耗的时间也比较长,再加上帧率的变化也不稳定,诸如此类问题导致fps在描述卡顿程度方面不够准确,所以CIMP仅在Debug阶段会获取fps值,Release阶段则使用下面的这种:主线程消息的处理时长。

主线程消息处理时长

这是衡量应用流畅度的一个有效且性能消耗较少的手段,通过监控主线程的消息处理时间,我们可以知道所有需要主线程处理的消息是否能够及时处理,比如UI绘制流畅度、用户交互响应时间等等。

主线程消息处理时长监控的原理参考了BlockCanary:给主线程的Looper注入logging。在Android sdk中,我们可以看到Looper类有这一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what);
}
final long traceTag = me.mTraceTag;
if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
}
try {
msg.target.dispatchMessage(msg);
} finally {
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

Android在处理主线程任何消息前后都会用logging打印消息,也就是说,只要我们注入这个logging对象,那么就可以让Looper调用到我们的println方法了。根据这个原理,我们就可以计算出主线程处理每个消息的时间,需要特别注意的是:虽然主线程消息没有Choreographer处理回调那么频繁,但也要特别注意println方法体的执行时长及内存分配,CIMP实际测试下来的消耗时长在几十微秒级别。

另外,CIMP把卡顿分为长卡顿和短卡顿,两者分别设有一个卡顿阀值,在发生卡顿的时候,如果匹配为短卡顿,CIMP会累计卡顿时长,并在session结束后进行上报,而如果是长卡顿,那么CIMP会将卡顿发生时的上下文,包括代码堆栈、内存信息等等进行实时上报并同样计入session卡顿中。

为了防止卡顿信息重复上传,CIMP会过滤类似卡顿并控制上传频次。

根据上面的上报数据,卡顿的输出分为卡顿概览和卡顿详情,前者用于了解应用整体的卡顿情况,包括卡顿时长、次数,而后者可以通过代码堆栈及其他上下文了解卡顿具体的原因。

Debug阶段的卡顿监控:

ctrip_ibu_performance_block

卡顿如果属于长卡顿类型的话还可以输出卡顿堆栈等上下文信息,便于开发者分析卡顿原因:

ctrip_ibu_performance_block_detail

页面加载

从用户体验的角度,CIMP抽象出了页面加载的性能指标,用于量化用户感知的页面加载速度。

页面加载相较于其他的指标来说显得复杂许多,我们把页面加载分几个阶段:

  1. 页面首次绘制
  2. 若干网络请求
  3. 若干次重新绘制页面

ctrip_ibu_performance_pageload_arch

那么问题来了:

  1. 如何对外部透明且准确地计算页面绘制时间?
  2. 由于网络请求和页面相对独立,所以网络请求如何与页面无法直接关联,怎么办?

对于第一个问题,开始CIMP参考了美团的方案,在根布局中插入了一层rootLayout,因为我们知道,由于view的绘制是自底向上的流程,也就是说最底层的layout首先绘制,然后遍历children,逐个绘制,所以通过计算rootLayout的绘制时间就可以知道页面绘制时间,但是有一些顾虑:布局层级增加无疑会有些绘制的性能问题,我们能不能在不增加布局层级,不影响绘制效率的基础上来获取页面加载时间?实际上,我们只需要干预系统的view inflate过程,动态地把系统最底层的contentLayout替换为我们自己的rootLayout即可解决这个问题。

而针对第二个问题:网络请求与页面关联,CIMP的方案是:在网络请求加入队列的时候观察栈顶页面关心的接口与当前接口是否可以匹配,如果可以匹配,那么该请求与当前栈顶页面进行绑定,后续的请求流程(请求的三个节点:加入请求队列、开始请求、请求结束)则可以通过这个绑定关系进行计算。

那么如何知道栈顶页面关心哪些接口?

CIMP提供了两种方式:

第一种是基础Activity提供接口,让各业务页面传入当前页面加载关心的接口列表,这种方式的好处是简单清晰,而且能在复杂的业务逻辑下更精确地记录请求的时间节点,但是缺点也很显然,对业务页面不透明,业务页必须手动添加代码和逻辑,而且在迭代过程中,难免会有逻辑变动带来的统计失效。所以,在大部分情况下,CIMP使用第二种方式:配置文件。

配置文件描述了每个页面的信息及该页面关心的请求列表,这样做的好处是对业务透明,与业务代码彻底解耦,且能够通过接口动态下发配置,避免了统计失效,需要下个版本修复的尴尬,缺点则是只能够处理大部分请求逻辑不复杂的页面。

所以两种方式互有优劣,业务可以根据自己的需求进行选择。

Debug阶段的页面加载:
ctrip_ibu_performance_pageload

实践结果

除了上文中的部分Debug看板外,CIMP还提供了内存、Cpu、流量、启动时间等看板:

ctrip_ibu_performance_other

ctrip_ibu_performance_cpu_pss_traffic

而在Release阶段,CIMP同样提供了看板(部分报表):

release_preformance_board

写在最后

移动性能调优是一项长期工程,而对这些性能指标进行监控则是调优的基础,根据这些报表,我们可以更清楚地了解app存在的问题,并有针对性地进行优化、排查问题。

目前而言,CIMP还是会有许多的不足,比如debug阶段如何更好地提供更有效的信息给开发测试人员,release阶段如何更明确地指出问题所在,如何更好地结合自动化测试等等,在性能调优的路上,我们要做的还有很多。

参考文献