《iOS APP 性能检测》
原文
原创: colawyeeqiu 腾讯Bugly 2017-09-28
| 导语 最近组里在做性能优化,既然要优化,就首先要有指标来描述性能水平,并且可以检测到这些指标,通过指标值的变化来看优化效果,于是笔者调研了iOS APP性能检测的一些方法,在此总结一下。
首先,要明确性能检测都需要关注哪些指标,笔者列举了以下几个主要的,后面会详细说:
启动时间
内存占用量,内存告警次数
CPU使用率
页面渲染时间,刷新帧率
网络请求时间,流量消耗
UI阻塞次数,不可操作时长,主线程阻塞超过400毫秒次数
耗电功率
对于静态页面来讲,页面的渲染时间就是从viewDidLoad第一行到viewDidAppear最后一行代码的时间。但是大多数页面是需要网络请求回数据才能正常展示。
主线程阻塞超过400毫秒就会让用户感知到卡顿,跟用户交互的操作如渲染,管理触摸反应,回应输入等都是在主线程的,所以不要让主线程承担过多耗时操作,耗时操作放到子线程中进行。
性能检测的途径主要分三大类:
Xcode自带的Instrument
使用第三方SDK
自行开发检测代码
Instrument
Xcode自带的Instrument工具是一个以独立APP形式存在的工具集,包含了很多强大的检测功能:其中包括在真机和模拟器上进行性能测试,对APP进行性能分析,检查一个或多个应用或进程的行为。
检查设备相关的功能,比如:Wi-Fi、蓝牙等。 查找 App 中的内存问题,比如内存泄露(Leaked memory)、废弃内存(Abandoned memory)、僵尸(zombies)等。
让我们来大概看一下Instrument都可以做什么
1.Blank(空模板):创建一个空的模板,可以从Library库中添加其他模板
2.Activity Monitor(活动监视器):监控进程级别的CPU,内存,磁盘,网络使用情况,可以得到你的应用程序在手机运行时总共占用的内存大小
3.Allocations(内存分配):跟踪过程的匿名虚拟内存和堆的对象提供类名和可选保留/释放历史,可以检测每一个堆对象的分配内存情况
4.Cocoa Layout :观察NSLayoutConstraint对象的改变,帮助我们判断什么时间什么地点的constraint是否合理。观察约束变化,找出布局代码的问题所在
5.Core Animation(图形性能):这个模块显示程序显卡性能以及CPU使用情况
6.CoreData:这个模块跟踪Core Data文件系统活动
7.Counters :收集使用时间或基于事件的抽样方法的性能监控计数器(PMC)事件
8.Energy Log: 耗电量监控
9.File Activity :检测文件创建,移动,变化,删除等
10.Leaks(泄漏):一般的措施内存使用情况,检查泄漏的内存,并提供了所有活动的分配和泄漏模块的类对象分配统计信息以及内存地址历史记录;
11.Metal System Trace:Metal API是apple 2014年在ios平台上推出的高效底层的3D图形API,它通过减少驱动层的API调用CPU的消耗提高渲染效率。
12.Network: 用链接工具分析你的程序如何使用TCP/IP和UDP/IP链接
13.System Trace:系统跟踪,通过显示当前被调度线程提供综合的系统表现,显示从用户到系统的转换代码通过两个系统调用或内存操作
14.System Usage: 这个模板记录关于文件读写,sockets,I/O系统活动, 输入输出
15.Time Profiler(时间探查):执行对系统的CPU上运行的进程低负载时间为基础采样。
16.Zombies: 测量一般的内存使用,专注于检测过度释放的【野指针】对象,也提供对象分配统计,以及主动分配的内存地址历史
下面这张图把上面的工具按照不同类别的诉求分了类,但是这张图比较早,有的工具被合并入上面的工具之中了。
Instrument还可以配合UI Test,通过脚本记录一个用户行为序列,这就为可重复多次的自动化测试提供了基础。这个真的很神奇,因为这个脚本不是需要程序员来写的,而是Xcode自动生成的!具体做法是这样的。在工程项目中File→New→Target,选择iOS UI Testing Bundle
打开生成的UITest文件,把光标放在-(void)testExample函数里,或者自己新建一个函数也可以,点击下图所示的红点,应用程序就会以profile的模式运行,这个时候你的一系列操作都会有相应的代码自动生成到这个函数中,操作结束之后点击结束的按钮。生成的代码有可能会有报错的地方,比如点击了中文的按钮,代码中是显示的是unicode转义序列,需要手工改成中文才行。
代码不报错了以后,先编译运行一遍,再通过Xcode的Product→perform action→profile testExample(如果是自己新建的函数就选择对应的函数名),这时程序就会按照你刚刚的操作路径进行一模一样的操作了,包括你在某个页面停留了多久,点击的顺序是如何的。我们在测试性能的时候,一般需要通过对比来说明优化的结果,然而对比就需要控制变量,两次一模一样的操作就很重要。需要说明的一点是,要保证很多其他因素都是相同的,比如两次对比的应用中,一个是登录态的,另一个没有登录,操作路径记录的包括了一些登录态特有的操作,那么当这个操作路径运行在没有登录的版本上就会crash。
Instrument主要用于在调试过程中随时发现问题,及时优化,但是这个工具只能供有应用源码的程序员使用,无法测量用户真实使用场景下的性能。
第三方SDK
有一些第三方的专门用于性能检测和用户行为、属性分析的SDK,比如Bugly,OneAPM,听云,Firebase Analytics,把它们接入项目可以短期内达成性能检测目标,这些第三方的工具原理都是类似的,利用 swizzle 的方法进行AOP(面向切面编程)处理,在关键函数之前和之后自动埋点记录上报。有的平台也支持上传符号表文件精确定位代码执行位置以及以埋点的方式手工添加日志记录。使用起来还是比较方便的,基本上引入SDK和相关库,在程序入口处启动检测即可。
然而使用第三方SDK的缺点也是非常明显的,首先是缺乏定制性,我们需要的一些指标的统计SDK没有,SDK有的我们又不完全需要,很有可能为了简单的几个值,让安装包增大许多。SDK具体统计了什么有可能我们并不完全知道,这又涉及一个很重要的问题就是安全性,这些SDK涉及的统计数据都是APP的商业机密信息,对于有一定市场影响力的APP肯定会顾忌这一点。当然,一些小的创业公司刚刚起步时,人力相对不足,产品前景也未知的情况下,使用这类第三方SDK还是一个好的选择。还有一点就是,这类产品是收费的,平时自己开发个demo练手也不适合连这种SDK,土豪请忽略。
自行添加检测代码
自行在项目中植入检测代码当然就安全可靠啦,而且想要什么指标都可以定制化,有针对性。当然这么做就免不了需要开发成本。而且还有一个问题,在代码中检测APP的性能本身可能也会带来额外的性能损耗,这也是需要考虑和权衡的。
自行添加检测代码也大体分为两类:
AOP:采用切面的方式,统一的为大量的类增加检测代码。具体做法是写一个类作为UIViewController的分类,增加几个方法如XXXviewdidload , XXXviewdidappear等,用swizzle替换一些对应的生命周期方法,塞入一些统计的代码。示例代码如下:
@implementation UIViewController (APMUIViewController)
+ (void) load {
Class clz = [self class];
SEL oldSEL = @selector(viewDidLoad);
SEL newSEL = @selector(newViewDidLoad);
Method originalMethod = class_getInstanceMethod(clz, oldSEL);
Method swizzledMethod = class_getInstanceMethod(clz, newSEL);
BOOL didAddMethod =class_addMethod(clz,
oldSEL,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(clz,
oldSEL,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
- (void) newViewDidLoad {
NSLog(@"start logging");//获取性能的函数
[super viewDidLoad];
NSLog(@"end logging");
}
@end
埋点:直接在想要的地方埋上你需要计算的性能指标、开始和结束时间的采集点,这种方式更加灵活,只关心自己关心的页面。
AOP是“大锅饭”,量大管饱,一次性为大量的类增加了检测代码,对原有代码侵入性也较小;埋点是“开小灶”,随心所欲,但是分散的代码管理起来也是一个问题。
自行开发检测代码还需要考虑以下问题:
1.想获取哪些指标,系统的API支持你获取哪些值
2.合理的检测时机是什么地方,比如什么样的指标检测代码添加到什么函数的哪一步中最合理
3.合理的上报策略和上报时机:我们不能每得到一个值就上报一次,这太消耗网络资源了。应该累积一段时间的数据,一次性上报。此外,上报的请求要错开正常业务请求的高峰,可以给请求设定优先级,业务请求的优先级高于性能检测的上报请求,如果有正常的业务请求在进行,就暂缓上报。以及,尽量在Wi-Fi环境下上传。
4.如果必须获取用户在4G或3G环境下的性能指标,我们就要尽可能的少消耗用户的流量,可以采用的方法有采用map关系,以简短的代码来代表一个复杂的意思;以及对上传的内容进行压缩
下面就每个指标详细说一下检测方法。
启动时间
启动时间可谓是用户对你的APP的第一印象,用户好不容易下载了APP,而且有兴致点开“宠幸”一下,启动时间过长很可能会让用户直接把APP打入冷宫。就算用户非常有耐心,苹果的watch dog机制也会kill掉启动时间过长的APP,这种情况下给用户的感觉就是这APP怎么一启动就卡死然后崩溃了,不可用。这里还要说一下,Xcode在debug模式下是没有开启watch dog的,所以不要以为调试时候没问题就真的没问题了,至少要在真机上试验一下。
首先大概了解一下APP的启动过程:
笔者在加断点调试的时候得到的是下面的顺序:
Launch页
main()
UIApplicationMain()
willFinishLaunchingWithOptions()
didFinishLaunchingWithOptions()
loadView()
viewDidLoad()
applicationDidBecomeActive()
注意Launch页是先于main函数出来的,main 函数就不说了,应用程序入口,里面调用了UIApplicationMain。当App从didFinishLaunchingWithOptions返回的时候,实际的UI立刻开始加载。这里的loadView是指你的app启动后加载的第一个view,这个view会在其controller的viewDidLoad执行完后被加载,这也是页面最终的初始化的时间。虽然UI 已经被初始化,但是在applicationDidBecomeActive这个回调完成之前UI仍旧被阻塞着。
我们要计算的启动时间就是从main()到applicationDidBecomeActive()的时间,这个代码很好加,分别在main的最开始和applicationDidBecomeActive的最后一行增加时间获取的代码即可。
还有一种使用环境变量的方法,在Xcode的Edit scheme中增加DYLD_PRINT_STATISTICS这个环境变量,如下图所示:
运行项目后在控制台会打印出如下信息,每个阶段都耗时多少。
这里涉及到iOS APP首次加载时的几个阶段,本文就不详细展开了,有兴趣的可以参看http://www.jianshu.com/p/65901441903e。
通过Instrument的Time Profiler,找到包含-[UIApplication _reportAppLaunchFinished]的最后一帧,也可计算出启动时间。
想得到应用程序的启动时间还是很容易的,还是开头那句话,启动时间是用户对APP的第一印象,尽量越快越好,在启动阶段(上述函数中)只进行必要的操作,尽量精简逻辑,不要链接不必要的库等等。
内存
Instrument里面的内存测量相关的工具上面已经提过了,网上也有很多手把手的逐步截图版教程,在这里就不赘述了。贴一下获取内存使用量的代码:
#import
#import
- (unsigned long)memoryUsage
{
struct task_basic_info info;
mach_msg_type_number_t size = sizeof(info);
kern_return_t kr = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)&info, &size);
if (kr != KERN_SUCCESS) {
return -1;
}
unsigned long memorySize = info.resident_size >> 10;//10-KB 20-MB
return memorySize;
}
返回的数值单位是KB,如果想要MB的话把10改为20。
增加App的内存占用的操作有创建对象,定义变量,调用函数的堆栈,多线程,密集的网络请求或长链接等等,我们可以对一些大的对象、view进行复用,懒加载资源,及时清理不再使用的资源(ARC下这个问题没那么严重)。
CPU使用率
同样的Instrument的方式就不说了,直接贴代码:
- (float)cpu_usage
{
kern_return_t kr = { 0 };
task_info_data_t tinfo = { 0 };
mach_msg_type_number_t task_info_count = TASK_INFO_MAX;
kr = task_info( mach_task_self(), TASK_BASIC_INFO, (task_info_t)tinfo, &task_info_count );
if ( KERN_SUCCESS != kr )
return 0.0f;
task_basic_info_t basic_info = { 0 };
thread_array_t thread_list = { 0 };
mach_msg_type_number_t thread_count = { 0 };
thread_info_data_t thinfo = { 0 };
thread_basic_info_t basic_info_th = { 0 };
basic_info = (task_basic_info_t)tinfo; // get threads in the task
kr = task_threads( mach_task_self(), &thread_list, &thread_count );
if ( KERN_SUCCESS != kr )
return 0.0f;
long tot_sec = 0;
long tot_usec = 0;
float tot_cpu = 0;
for ( int i = 0; i < thread_count; i++ )
{
mach_msg_type_number_t thread_info_count = THREAD_INFO_MAX;
kr = thread_info( thread_list[i], THREAD_BASIC_INFO, (thread_info_t)thinfo, &thread_info_count );
if ( KERN_SUCCESS != kr )
return 0.0f;
basic_info_th = (thread_basic_info_t)thinfo;
if ( 0 == (basic_info_th->flags & TH_FLAGS_IDLE) )
{
tot_sec = tot_sec + basic_info_th->user_time.seconds + basic_info_th->system_time.seconds;
tot_usec = tot_usec + basic_info_th->system_time.microseconds + basic_info_th->system_time.microseconds;
tot_cpu = tot_cpu + basic_info_th->cpu_usage / (float)TH_USAGE_SCALE;
}
}
kr = vm_deallocate( mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t) );
if ( KERN_SUCCESS != kr )
return 0.0f;
return tot_cpu * 100.; // CPU 占用百分比}
返回的是CPU占用百分比。
大部分app都是在刚启动不久内cpu占用较大, 之后就渐渐趋于稳定,所以建议在刚开始采集间隔短一点比如1s,之后采集间隔逐渐加大,最后稳定到5分钟获取一次。此外,再有动画的地方也要增加采集点。
影响CPU使用情况的主要是计算密集型的操作,比如动画、布局计算和Autolayout、文本的计算和渲染、图片的解码和绘制。比较常见的一种优化方式就是缓存tableview的cell高度,避免每次计算。想要降低CPU的使用率就要尽量避免大量的计算,能缓存的缓存,不得不计算的,看看是否可以使用一些算法进行优化,降低时间复杂度。
刷新帧率
刷新帧率可以通过Instrument里的Core Animation查看,也可以使用CADisplayLink,它是一个以和屏幕刷新率相同的频率将内容画到屏幕上的定时器,最快能每秒调用60次,在正常情况下会在每次刷新结束都被调用,精确度相当高。如果是CPU或是GPU某个步骤耗时导致渲染错过了一次垂直信号,那这个方法就不会被调用了,之后统计的帧数也就随之降低了。
下面是笔者在自选股项目中增加的一个实时显示当前帧率的一个demo,在每个页面都有这样的一个弹窗,显示在用户进行操作时的刷新帧率,静止不动时是60,展示动画时这个值会掉的挺厉害。除了动画之外,在页面加载、tableview/scrollview滑动的时候也会明显降低。
耗电功率
把耗电功率放到最后,是因为耗电功率是个比较综合的指标,影响因素很多。跟性能相关的,密集的网络请求,长链接,密集的CPU操作(比如大量的复杂计算)都会使耗电功率增加。此外,耗电量还会被很多其他因素影响,比如用户在不同光线下使用,iPhone会自动调整屏幕亮度,就会导致耗电量不同;网络状况(流畅的Wi-Fi还是信号不好的3G)
由于耗电量的影响因素太多,统计出来并不能精准的反应你的APP的性能,所以笔者认为,一般的APP不必把耗电量当作一个优化指标,只要把可能影响耗电量的、可优化的部分尽量优化即可,比如网络请求和CPU操作。毕竟对于大多数APP来说,还谈不上耗电太多的问题,需要重点考虑耗电问题的应该是像微信这种用户重度依赖(人均使用时长)或者是视频类应用这种耗电大户。不是说不优化耗电量,而是优化了其他的,耗电量自然就会减少了,单纯从这个值来讲不好检测。
首先测量耗电量的时候不能用模拟器,模拟器下得到的电量值是负数,也不能用真机连着电脑debug,因为这个过程本身就在给手机充电。正确的做法是在手机上设置Settings→developer→logging on your device→enable energy logging再开始记录,一段时间以后再stop,再用手机连接到电脑的instrument上,import log记录进行分析。
还有就是在代码中获取电量值,在特定场景之前、之后检查电量使用情况,计算差值。电量的计算要有一定的时间长度才可以,不可能是一个函数的前后就有能看得见的变化(要是有这样的函数也太恐怖了)。
UIDevice.currentDevice.batteryMonitoringEnabled = true;
NSLog(@"电量:%f%%",[UIDevice currentDevice].batteryLevel * 100);
Last but not the least
做性能方面的检测工作时,一定要在真机上测试,而不是模拟器。模拟器的性能是Mac的,跟iPhone不可同日而语,测出来的数据不准也就没有了意义。比如电池电量这种指标,模拟器下是负数-.-!
还有性能测试要用发布配置,也就是说要用release包,而不是调试模式。因为当用发布环境打包的时候,编译器会引入一系列提高性能的优化,例如去掉调试符号或者移除并重新组织代码。想要测试用户真实的使用情况还是要用跟真实包最最接近的release版。
最好在你支持的设备中性能最差的设备上测试
性能对比实验要基于完全相同的实验场景或是取大量真实数据的平均值,其实对于用户的真实使用场景来说,很难做到完全一样,可能的影响因素有很多:网络状况,硬件,系统版本,是否越狱,设备上的可用空间,同时开着的其他app。