iOS性能检测

《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 <mach/mach.h>

#import <mach/task_info.h>

- (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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容

  • 1.概览 工具通过Xcode工具栏中Product->Profile可以启动,启动后界面如下: Instrumen...
    Amanda_Lhy阅读 460评论 0 0
  • APP的性能监控包括: CPU 占用率、 内存使用情况、网络状况监控、启动时闪退、卡顿、FPS、使用时崩溃、耗电量...
    it彭于晏阅读 4,507评论 2 10
  • 1 前言 优化app,首先是监控app,对于app 性能监控有几个工具 xcode 的debug native包含...
    笨驴爱吃胡萝卜阅读 1,007评论 0 4
  • 001 方式一:制造微小的成功 如果我把大多数时间都花在有意义的工作上,那么我全身心投入工作的概率将大大增加。 0...
    二小姐1010阅读 205评论 0 0
  • 夜,并不是只有黑暗,如果我愿意,就一定能看到光!闭上眼,我依然感受这个世界的光芒,如纱幔中女子,蹀躞身影,婆娑起舞...
    枫聆渡阅读 142评论 0 1