导语
在开发过程中,功能不仅要满足业务需求,也要关注功能对App性能带来的一些问题。开发人员在开发阶段检测性能比较容易,iOS端可以直接通过instruments工具进行检测。但是在测试阶段,测试人员要检测性能需要下载开发工具成本比较高。如果客户端能够将性能数据上传到服务端并且通过一些界面进行展示,对测试人员来说是一种可以检测性能的比较好的方法。
本文章主要介绍iOS端如何通过代码采集性能数据,其中包括电池数据,CPU数据,内存数据,卡顿数据,流量数据以及冷启动时间等。
电池数据
首先来看一下电池数据,iOS电池数据采集方案主要有以下三种方案:UIDevice,IOKit,越狱。
1、UIDevice:提供了获取设备电池的相关信息,包括当前电池的状态以及电量。获取电池信息之前需要先将 batteryMonitoringEnabled 属性设置为 YES,然后就可以通过 batteryState 和 batteryLevel 获取电池信息。
优点:api简单,易于使用。
缺点:粗粒度,能够采集到的数据较少,不符合需求。
2、IOKit: 是一个iOS 系统的私有框架,它可以被用来获取硬件和设备的详细信息,也是与硬件和内核服务通信的底层框架。通过它可以获取设备电量信息,精确度达到1%。
优点:可以获取较多的电池相关的数据。
缺点:因为要访问私有api,不能通过苹果审核,只能在线下取值。获取到的值是设备的电池数据,无法达到应用级别的数据获取。
3、越狱方案:通过iOSDiagnosticsSupport 私有库,Runtime 拿到 MBSDevice 实例,获取电量日志信息表,日志信息表中包含了 iOS 系统采集的小时级别的耗电量。
优点:可以获取到应用的耗电量。
缺点:获取到的耗电量是以小时为单位的,时间间隔太长,不符合需求。
最后,为了能够采集更多的电池数据,我们选择的方案是通过访问IOKit的私有api获取数据,并且在提交到app store时将这部门代码从包里移除掉,以免影响app的审核结果。
核心代码如下:
通过以上方式可以获取到的数据包括但不限于:
当前充电状态
电量
是否连接USB(支持iOS10以下系统)
是否有电池
最大值
电压
温度(支持iOS10以下系统)
CPU数据
iOS的线程技术是基于Mach 线程技术实现的,在 Mach 层中thread_basic_info 结构体提供了线程的基本信息,并且每个线程中包含线程的cpu_usage。获取当前App的占用率就是所有线程的cpu_usage之和。
通常一个 task 包含多个线程,在内核提供了 task_threads API 调用获取指定 task 的线程列表以及线程个数,也就是target_task 任务中的所有线程保存在 act_list 数组中,数组中包含 act_listCnt 个条目。然后可以通过 thread_info API 调用来查询指定线程的信息。
因此,获取当前APP的CPU占用率需要遍历所有线程,将cpu_usage求和。
接下来就是获取当前设备的CPU总占用率。 iOS中CPU状态一般包括CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLE 和 CPU_STATE_NICE等四种。
1、CPU_STATE_USER:运行在用户态空间或者说是用户进程。
2、CPU_STATE_SYSTEM:在内核空间运行的分配内存、IO操作、创建子进程……等。
3、CPU_STATE_IDLE:空闲状态。
4、CPU_STATE_NICE:用户空间进程的CPU的调度优先级。
因此,除了空闲状态都属于CPU占用状态,因此当前CPU的总使用率为(用户+系统+调度)/(用户+系统+调度+空闲)。通过host_statistics获取host_cpu_load_info结构体数据,该结构体中 cpu_ticks 包含了 CPU 运行时四种不同该状态的时钟脉冲的数量,并且根据这四个不同状态的时间脉冲,计算出CPU的总占用率。
内存数据
获取内存数据同样也可以通过mach_task_basic_info结构获取resident_size值作为当前App已占用的内存大小。
但是在测试中发现,通过该结构体获取的值与Xcode中的内存数据对不上,往往差好几兆甚至好几十兆。因此通过查找资料,有一篇文章介绍通过逆向Xcode来获取Xcode计算内存方法以及结构体。该方法获取到的已占用内存大小与Xcode的值几乎一致,可以作为一个判断标准。具体参考代码如下:
接下来,如果要获取当前设备可以使用的空闲内存,首先要了解iOS系统的内存分配。
Free Memory:未使用的 RAM 容量,随时可以被应用分配使用。
Wired Memory:用来存放内核代码和数据结构,它主要为内核服务,如负责网络、文件系统之类的;对于应用、framework、一些用户级别的软件是没办法分配此内存的。但是应用程序也会对 Wired Memory 的分配有所影响。
Active Memory:活跃的内存,正在被使用或很短时间内被使用过。
Inactive Memory:最近被使用过,但是目前处于不活跃状态。
Purgeable Memory:可以理解为可释放的内存,主要是大对象或大内存块才可以使用的内存,此内存会在内存紧张的时候自动释放掉。
因此,空闲内存看成总内存大小减去 Wired Memory大小,Active Memory大小以及Inactive Memory大小。在32位系统通过这种方式获取空闲内存与Xcode数据作比较误差范围较小,而在64位系统上的数据与Xcode数据一比较误差较大,同样找到一个逆向Xcode获取Xcode的计算内存方法。64位系统获取空闲内存的具体代码如下:
卡顿数据
检测卡顿数据的方式通常有两种:一种是FPS卡顿检测,另一种是主线程卡顿检测。
FPS卡顿检测:检测当前页面的帧率,帧率越高意味着界面越流畅,通过计算丢帧率来检测当前页面的卡顿情况。
主线程卡顿检测:通过开辟一个子线程来监控主线程的RunLoop,当两个状态区域之间的耗时大于阈值时,就记为发生一次卡顿。
一、FPS卡顿检测
目前我们要采集的方主要是基于CADisplayLink以屏幕刷新频率同步绘图的特性,观察屏幕当前帧数的指示器,若帧率少于指定的帧率看成一个FPS卡顿。具体代码如下:
二、主线程卡顿检测
在主线程在Runloop的某个阶段进行长时间的耗时操作,因此主要思路就是开辟一个子线程去计算kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting两个状态区域之间的耗时是否超过某个阀值来断定主线程的卡顿情况。
那么,为什么要用kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting状态进行判定呢?首先要理清楚Runloop的运行机制,以下为RunLoop 顺序:
看完RunLoop顺序,就可以看到处理事件主要有两个时间段 — kCFRunLoopBeforeSources 发送之后与 kCFRunLoopAfterWaiting 发送之后。dispatch_semaphore_t 是一个信号量机制,信号量到达或者超时会继续向下进行。若超时则返回的结果必定不为0,若信号量到达返回的结果为0。利用这个特性我们判断卡顿出现的条件为在信号量发送 kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting后进行了大量的操作,在一段时间内没有再发送信号量,看成超时。也就是主线程长时间的停留在这两个状态上。转换为代码就是判断有没有超时,若超时了,再判断当前停留的状态是不是这两个状态,如果是,就判定为卡顿。具体参考代码如下:
流量数据
流量数据主要统计在当前App内发生的所有网络请求相应的数据大小。首先,先通过facebook提供的sonar框架捕捉app内的所有request和response。在实际的网络请求中 Request 和 Response 不一定是成对的,如果网络断开、或者突然关闭进程,都会导致不成对现象,如果将 Request 和 Response 记录在同一条数据,将会对统计造成偏差。因此request和response分开统计流量。若有对SonarKit框架感兴趣的同学可以直接访问官网进一步了解,官网:https://fbsonar.com/docs/getting-started.html
一、统计Request流量
首先需要了解请求报文的组成,如图:
那么,Request所花费的流量就是将把Line的大小,Header的大小,空格以及Body大小累加的合。
1、Line大小的统计
Line没有可以直接转换成CFNetwork相关数据的私有接口,但是我们很清楚 HTTP 请求报文 Line 部分的组成,因此可以手动计算Line的大小。
2、Header大小统计
通过request.allHTTPHeaderFields 拿到的头部数据是有很多缺失的,并不是完整的数据。同时由于无法直接转换到 CFNetwork 层,所以一直拿不到完整的 Header 数据。缺少的数包括但不限于以下几个字段:Accept,Connection,Host,当前Request的Cookie。由于基本上缺失的都是固定的几个字段,忽略这几个字段对统计的结果影响不大。因此主要针对cookie的数据并且手动大小进行补全。因此总Header的大小可以看成request.allHTTPHeaderFields数据大小加上cookie大小。
3、Body大小统计
最后是body部分,通过resquest.HTTPBody来计算Body大小。这里要注意的地方就是通过 NSURLConnection 发出的网络请求 resquest.HTTPBody 拿到的是 nil。需要通过 HTTPBodyStream 读取 stream 来获取 request 的 Body 大小。
最后,将Line大小,Header大小,Body大小相加就是当前request所话费的流量。
二、统计Response流量
请求报文的组成如下:
那么Response所花费的流量就是将把Status Line的大小,Header的大小,空格以及Body大小累加的合。
1、StatusLine大小
NSURLResponse没有接口能直接获取报文中的 Status Line。因此,最后通过转换到 CFNetwork 相关类拿到了Status Line 的数据后计算它的大小,这其中可能涉及到了读取私有 API,因此需要注意审核问题。
2、Header大小
通过 httpResponse.allHeaderFields拿到 Header 字典,转换成 NSData 计算大小。
3、Body大小
对于 Body 的计算,采用 expectedContentLength 或者去 NSURLResponse 对象的 allHeaderFields 中获取 Content-Length 值,其实都不够准确。Content-Length 只是表示 Body 部分的大小,因此采取直接获取body大小的方式。还有一个需要注意对 gzip 情况进行区别分析。我们知道 HTTP 请求中,客户端在发送请求的时候会带上 Accept-Encoding,这个字段的值将会告知服务器客户端能够理解的内容压缩算法。而服务器进行相应时,会在 Response 中添加 Content-Encoding 告知客户端选中的压缩算法。若Content-Encoding使用了 gzip,则模拟一次 gzip 压缩,再计算字节大小。
冷启动时间
App的冷启动就是,当应用启动时,后台没有该应用的进程,这时系统会重新创建一个新的进程分配给该应用, 这个启动方式就叫做冷启动(后台不存在该应用进程)。下面先看看苹果官方文档给的应用的启动时序图,图中可以看到冷启动是一个User taps app icon到Final initialization(applicationDidFinishLaunching: withOptions:)的过程,所以冷启动时间就是从用户唤醒App开始一直到App已启动所消耗的时间。
因此,冷启动时间 =DidLauching时间 - main()函数执行之前的时间。类的+ load方法在main函数执行之前调用,所以我们采取在+ load方法记录开始时间的方案。具体参考代码如下:
当applicationDidFinishLaunching:withOptions:方法执行完毕后,添加一个回调获取AppDidFinishLaunching后的时间。并且将开始时间与load开始时间相减作为应用冷启动时间。
总结
以上介绍了iOS中通过代码采集性能数据的方案,目前还在继续优化采集方案,希望本文章能够帮助大家对iOS性能数据采集的了解。
参考文章:
https://fbsonar.com/docs/getting-started.html
http://www.cocoachina.com/ios/20170629/19680.html
http://www.cocoachina.com/ios/20180606/23691.html
https://cloud.tencent.com/developer/article/1006222
https://www.jianshu.com/p/6c10ca55d343
http://ddrccw.github.io/2017/12/30/2017-12-30-reverse-xcode-with-lldb-and-hopper-disassembler/