天之道,损有余而补不足
一、概述
1、APM是什么
- 我们平时关注更多的是:需求是否delay,线上bug有多少?每个周期(比如2-3周) 关注下App的DAU、DNU、这些产品指标;但是团队中需要有人去关注App的技术质量指标:如Crash率、启动时间、安装包大小、核心页面的FPS、CPU使用率、内存占用、电量使用、卡顿情况等。
- 关注App线上质量,从技术维度来判断App是否健康。不健康的App表现为启动时间慢、页面卡顿、耗电量大等,这些App最终会失去用户;
- APM (Application Performance Manage)旨在建立APP的质量监控接入框架,方便App能快速集成,对性能监控项的异常数据进行采集和分析,输出相应问题的分析、定位与优化建议,从而帮助开发者开发出更高质量的应用。
2、APM工具
- 微信最近开源了微信的APM工具, 提供了针对iOS、Android和macOS系统的性能监控方案。这个方案很全面,可以直接接入App,当然也可以吸收其优秀的技术细节,优化自己的APM工具。
- 本文不是介绍如何定制一个APM工具,而是介绍在APM监控中,比较重要的几个监控维度:CPU使用率、内存使用、FPS和卡顿监控。
二、CPU使用率监控
1、Task和CPU
- 任务(Task)是一种容器(Container)对象;虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。
- 严格地说,Mach 的任务并不是其他操作系统中所谓的进程,因为 Mach 作为一个微内核的操作系统,并没有提供“进程”的逻辑,而只是提供了最基本的实现。不过在 BSD 的模型中,这两个概念有1:1的简单映射,每一个 BSD 进程(也就是 OS X 进程)都在底层关联了一个 Mach 任务对象。
- 而每App运行,会对应一个
Mach Task
,Task下可能有多条线程同时执行任务,每个线程都是利用CPU的基本单位。要计算CPU 占用率,就需要获得当前Mach Task
下,所有线程占用 CPU 的情况。
2、Mach Task和线程列表
- 一个
Mach Task
包含它的线程列表。内核提供了task_threads
API 调用获取指定 task 的线程列表,然后可以通过thread_info
API调用来查询指定线程的信息,
kern_return_t task_threads( task_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt);复制代码
说明:task_threads
将target_task
任务中的所有线程保存在act_list
数组中,act_listCnt
表示线程个数:
3、单个线程信息结构
- iOS 的线程技术与Mac OS X类似,也是基于 Mach 线程技术实现的,可以通过
thread_info
这个API调用来查询指定线程的信息,thread_info结构如下:
kern_return_t thread_info( thread_act_t target_act, thread_flavor_t flavor, // 传入不同的宏定义获取不同的线程信息 thread_info_t thread_info_out, // 查询到的线程信息 mach_msg_type_number_t *thread_info_outCnt // 信息的大小);复制代码
- 在 Mach 层中
thread_basic_info
结构体封装了单个线程的基本信息:
struct thread_basic_info { time_value_t user_time; // 用户运行时长 time_value_t system_time; // 系统运行时长 integer_t cpu_usage; // CPU 使用率 policy_t policy; // 调度策略 integer_t run_state; // 运行状态 integer_t flags; // 各种标记 integer_t suspend_count; // 暂停线程的计数 integer_t sleep_time; // 休眠的时间};复制代码
4、CPU 占用率计算
- 先获取当前task中的线程总数(threadCount)和所有线程数组(threadList)
- 遍历这个数组来获取单个线程的基本信息。线程基本信息的结构是thread_basic_info_t,这里面有CPU的使用率(cpu_usage)字段,累计所有线程的CPU使用率就能获得整个APP的CPU使用率(cpuUsage)。
- 需要注意的是:cpuUsage是一个整数,想要获得百分比形式,需要除以TH_USAGE_SCALE
/* * Scale factor for usage field. */#define TH_USAGE_SCALE 1000复制代码
- 可以定时,比如2s去计算一次CPU的使用率
+ (double)getCpuUsage { kern_return_t kr; thread_array_t threadList; // 保存当前Mach task的线程列表 mach_msg_type_number_t threadCount; // 保存当前Mach task的线程个数 thread_info_data_t threadInfo; // 保存单个线程的信息列表 mach_msg_type_number_t threadInfoCount; // 保存当前线程的信息列表大小 thread_basic_info_t threadBasicInfo; // 线程的基本信息 // 通过“task_threads”API调用获取指定 task 的线程列表 // mach_task_self_,表示获取当前的 Mach task kr = task_threads(mach_task_self(), &threadList, &threadCount); if (kr != KERN_SUCCESS) { return -1; } double cpuUsage = 0; // 遍历所有线程 for (int i = 0; i < threadCount; i++) { threadInfoCount = THREAD_INFO_MAX; // 通过“thread_info”API调用来查询指定线程的信息 // flavor参数传的是THREAD_BASIC_INFO,使用这个类型会返回线程的基本信息, // 定义在 thread_basic_info_t 结构体,包含了用户和系统的运行时间、运行状态和调度优先级等 kr = thread_info(threadList[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount); if (kr != KERN_SUCCESS) { return -1; } threadBasicInfo = (thread_basic_info_t)threadInfo; if (!(threadBasicInfo->flags & TH_FLAGS_IDLE)) { cpuUsage += threadBasicInfo->cpu_usage; } } // 回收内存,防止内存泄漏 vm_deallocate(mach_task_self(), (vm_offset_t)threadList, threadCount * sizeof(thread_t)); return cpuUsage / (double)TH_USAGE_SCALE * 100.0;}复制代码
4、为什么关注CPU使用率
- CPU的使用率是对APP使用CPU情况的评估,App频繁操作,CPU使用率一般在40%-50%;
- 假如CPU使用过高(>90%),可以认为CPU满负载,此种情况大概率发生卡顿,可以选择上报。
- 一段时间内CPU的使用率一直超过某个阈值(80%),此种情况大概率发生卡顿,可以选择上报。
三、内存使用监控
1、内存
- 内存是有限且系统共享的资源,一个App占用地多,系统和其他App所能用的就更少;减少内存占用能不仅仅让自己App,其他App,甚至是整个系统都表现得更好。
- 关注App的内存使用情况十分重要
2、内存信息结构
- Mach task 的内存使用信息存放在
mach_task_basic_info
结构体中 ,其中resident_size
为驻留内存大小,而phys_footprint表示实际使用的物理内存,iOS 9之后使用phys_footprint来统计App占用的内存大小(和Xcode和Instruments的值显示值接近)。
struct task_vm_info { mach_vm_size_t virtual_size; // 虚拟内存大小 integer_t region_count; // 内存区域的数量 integer_t page_size; mach_vm_size_t resident_size; // 驻留内存大小 mach_vm_size_t resident_size_peak; // 驻留内存峰值 ... /* added for rev1 */ mach_vm_size_t phys_footprint; // 实际使用的物理内存 ...复制代码
3、内存信息获取
uint64_t qs_getAppMemoryBytes() { task_vm_info_data_t vmInfo; mach_msg_type_number_t count = TASK_VM_INFO_COUNT; kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count); if (result != KERN_SUCCESS) return 0; return vmInfo.phys_footprint;}复制代码
4、为什么关注内存使用
- 内存问题影响最大是OOM,即Out of Memory,指的是 App 占用的内存达到iOS系统对单个App占用内存上限时,而被系统强杀的现象,这是一种由iOS的Jetsam机制导致的奔溃,无法通过信号捕获到。
- 对于监控OOM没有很好的办法,目前比较可行的办法是:定时监控内存使用,当接近内存使用上限时,dump 内存信息,获取对象名称、对象个数、各对象的内存值,并在合适的时机上报到服务器。
- App中会使用很多单例,这些单例常驻内存,需要关注大单例;大图片解码会造成内存使用飙升,这个也需要关注;还有些取巧的方案,比如预创建webview对象甚至预创建ViewController对象,采用此类做法,需要关注对内存造成的压力。
四、FPS监控
1、FPS和CADisplayLink
FPS
是Frames Per Second
,意思是每秒帧数,也就是我们常说的“刷新率(单位为Hz)。FPS低(小于50)表示App不流畅,App需要优化,iOS手机屏幕的正常刷新频率是每秒60次,即FPS
值为60。CADisplayLink
是和屏幕刷新频率保存一致,它是CoreAnimation
提供的另一个类似于NSTimer
的类,它总是在屏幕完成一次更新之前启动,CADisplayLink
有一个整型的frameInterval
属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。
2、FPS监控实现
- 注册CADisplayLink 得到屏幕的同步刷新率,记录1s(useTime,可能比1s大一丢丢)时间内刷新的帧数(total),计算total/useTime得到1s时间内的帧数,即FPS值。
- (void)start { //注意CADisplayLink的处理循环引用问题 self.displayLink = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(updateFPSCount:)]; [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];}// 执行帧率和屏幕刷新率保持一致- (void)updateFPSCount:(CADisplayLink *)displayLink { if (self.lastTimeStamp == 0) { self.lastTimeStamp = self.displayLink.timestamp; } else { self.total++; // 开始渲染时间与上次渲染时间差值 NSTimeInterval useTime = self.displayLink.timestamp - self.lastTimeStamp; //小于1s立即返回 if (useTime < 1){ return; } self.lastTimeStamp = self.displayLink.timestamp; // fps 计算 NSInteger fps = self.total / useTime; NSLog(@"self.total = %@,useTime = %@,fps = %@",@(self.total),@(useTime),@(fps)); self.total = 0; }}复制代码
说明:很多团队非常相信(甚至迷信)FPS值,认为FPS值(大于50)就代表不卡顿,这点我是不认可。下面我列举遇到的2个非常典型的Case。
3、错信FPS值Case1
- 同学A在做频繁绘制需求时, 重写UIView的
drawRect:
方法,在模拟器上频繁调用setNeedsDisplay来触发drawRect:
方法,FPS值还稳定在50以上,但是真机上去掉帧很厉害。我认为这里犯了两个错误。 - 错误1:
drawRect:
是利用CPU绘制的,性能并不如GPU绘制,对于频繁绘制的绘制需求,不应该考虑使用重写drawRect:
这种方式,推荐CAShapeLayer+UIBezierPath
。 - 错误2:不应该关注模拟器FPS来观察是否发生卡顿,模拟器使用的是Mac的处理器,比手机的ARM性能要强,所以造成在模拟器上FPS比较理想,真机上比较差。
4、错信FPS值Case2
- 同学B在列表滑动时候,观察iPhone 6 plus真机上FPS的值稳定在52左右,感觉不错,但是肉眼明显感觉到卡顿。
- 是FPS错了吗?我认为没错,是我们对FPS的理解错了;因为FPS代表的是每秒帧数,这是一个平均值,假如前0.5s播放了2帧,后面0.5s播放了58帧,从结果来看,FPS的值依旧是60。但是实际上,它的确发生了卡顿。
5、为什么关注FPS
- 虽然列举了两个错信FPS的Case,但是FPS依旧是一个很重要的指标,来关注页面的卡顿情况。
- 和使用监控RunLoop状态来发现卡顿问题不同,FPS关注的是滑动场景下,FPS偏低的场景。
- 而监控RunLoop状态来发现卡顿问题更加关注的是:在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题
五、卡顿监控
1、卡顿和RunLoop
- 卡顿监控的本质是,监控主线程做了哪些事;线程的消息事件依赖RunLoop,通过监听RunLoop的状态,从而判断是否发生卡顿。
- RunLoop在iOS中是由CFRunLoop实现的,它负责监听输入源,进行调度处理的,这里的输入源可以是输入设备、网络、周期性或者延迟时间、异步回调。RunLoop接收两种输入源:一种是来自另一个线程或者来自不同应用的异步消息;另一个事来自预定时间或重复间隔的同步事件。
- 当有事情处理,Runloop唤起线程去处理,没有事情处理,让线程进入休眠。基于此,我们可以把大量占用CPU的任务(图片加载、数据文件读写等) ,放在空闲的非主线程执行,就可以避免影响主线程滑动过程中的体验(主线程滑动时,RunLoop处在UITrackingRunLoopMode模式)
2、如何判断卡顿
- 已知的RunLoop的7个状态
//RunLoop的状态typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { kCFRunLoopEntry = (1UL << 0), // 即将进入Loop kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠 kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒 kCFRunLoopExit = (1UL << 7), // 即将退出Loop kCFRunLoopAllActivities = 0x0FFFFFFFU // loop 所有状态改变 };复制代码
-
由于
kCFRunLoopBeforeSources之后
需要处理Source0,kCFRunLoopAfterWaiting之后
需要处理timer、dispatch 到 main_queue 的 block和Source1,所以可以认为kCFRunLoopBeforeSources
和kCFRunLoopAfterWaiting
。因为kCFRunLoopBeforeSources
之后和kCFRunLoopAfterWaiting
之后是事情处理的主要时间段。 -
dispatch_semaphore_t信号量机制特性:信号量到达、或者 超时会继续向下进行,否则等待;如果超时则返回的结果必定不为0,否则信号量到达结果为0。
-
主线程卡顿发生是因为要处理大量的事情。这就意味着主线程在消耗时间在处理繁重的事件,导致信号超时了(dispatch_semaphore_signal不能及时执行),如果此时发现当前的RunLoop的状态是kCFRunLoopBeforeSources或kCFRunLoopAfterWaiting,就认为主线程长期停留在这两个状态上,此时就判定卡顿发生。
3、卡顿监控的实现
// QSMainThreadMonitor.h@interface QSMainThreadMonitor : NSObject+ (instancetype)sharedInstance;- (void)beginMonitor;- (void)stopMonitor;@end// QSMainThreadMonitor.m@interface QSMainThreadMonitor()@property (nonatomic,strong) dispatch_semaphore_t semaphore;@property (nonatomic,assign) CFRunLoopObserverRef observer;@property (nonatomic,assign) CFRunLoopActivity runloopActivity;@property (nonatomic,strong) dispatch_queue_t monitorQueue;@end@implementation QSMainThreadMonitor+ (instancetype)sharedInstance { static QSMainThreadMonitor *monitor = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ monitor = [[QSMainThreadMonitor alloc]init]; }); return monitor;}- (instancetype)init { self = [super init]; if (self) { self.monitorQueue = dispatch_queue_create("com.main.thread.monitor.queue", DISPATCH_QUEUE_CONCURRENT); } return self;}- (void)beginMonitor{ if (self.observer) { return; } __block int timeoutCount = 0; //创建观察者并添加到主线程 CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL,NULL}; self.observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context); //将self.observer添加到主线程RunLoop的Common模式下观察 CFRunLoopAddObserver(CFRunLoopGetMain(), self.observer, kCFRunLoopCommonModes); self.semaphore = dispatch_semaphore_create(0); dispatch_async(self.monitorQueue, ^{ while (YES) { long result = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC)); if (result != 0 && self.observer) { //超时判断 if (self.runloopActivity == kCFRunLoopBeforeSources || self.runloopActivity == kCFRunLoopAfterWaiting) { if (++timeoutCount < 1) { NSLog(@"--timeoutCount--%@",@(timeoutCount)); continue; } //出现卡顿、进一步处理 NSLog(@"--timeoutCount 卡顿发生--"); // todo,eg:获取堆栈信息并上报 } }else { timeoutCount = 0; } } }); }- (void)stopMonitor{ if (!self.observer) { return; } CFRunLoopRemoveObserver(CFRunLoopGetMain(), self.observer, kCFRunLoopCommonModes); CFRelease(self.observer); self.observer = NULL; }#pragma mark -Private Method/** * 观察者回调函数 */static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){ //每一次监测到Runloop状态变化调用 QSMainThreadMonitor *monitor = (__bridge QSMainThreadMonitor *)info; monitor.runloopActivity = activity; if (monitor.semaphore) { dispatch_semaphore_signal(monitor.semaphore); }}@end复制代码
4、卡顿时间阈值说明
- 这里卡顿时间阈值是2s,连续1次超时且RunLoop的状态处于
kCFRunLoopBeforeSources
或kCFRunLoopAfterWaiting
状态就认为卡顿。 - 利用的RunLoop实现的卡顿方案,主要是针对那些在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题。
- 卡顿时间阈值(timeoutThreshold)和超时时间次数(timeoutCount)可以通服务器下发控制,用来控制上报卡顿情况的场景。
六、电量监控
1、手动查看电量
- 我们可以通过手机的设置-电池查看过去一段时间(24小时或2天)查看Top耗电量的App;
- 对于用户来说,还有更直接的方式,使用某App时候,手机状态栏右上角电池使用量嗖嗖往下掉或手机发热,那么基本可以判断这个App耗电太快,赶紧卸了。
- 对于开发者来说,可以通过Xcode左边栏的Energy Impact查看电量使用,蓝色表示--合理,黄色--表示比较耗电,红色--表示仅仅轻度使用你的程序,就会很耗电。
- 还可以使用手机设置-开发者-Logging-Energy的startRecording和stopRecording来记录一段时间(3-5minutes)某App的耗电量情况。导入Instrument来分析具体耗电情况。
2、电量监控方案1
-
利用
UIDevice
提供了获取设备电池的相关信息,包括当前电池的状态以及电量。//开启电量监控[UIDevice currentDevice].batteryMonitoringEnabled = YES;//监听电量使用情况[[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryLevelDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) { // Level has changed NSLog(@""); //UIDevice返回的batteryLevel的范围在0到1之间。 NSUInteger batteryLevel = [UIDevice currentDevice].batteryLevel * 100; NSLog(@"[Battery Level]: %@", @(batteryLevel)); }];复制代码
说明:使用 UIDevice
可以非常方便获取到电量,但是经测试发现,在 iOS 8.0 之前,batteryLevel
只能精确到5%,而在 iOS 8.0
之后,精确度可以达到1%
3、电量监控方案2
- 利用iOS系统私有框架
IOKit
, 通过它可以获取设备电量信息,精确度达到1%。
#import "IOPSKeys.h"#import "IOPowerSources.h"-(double) getBatteryLevel{ // 返回电量信息 CFTypeRef blob = IOPSCopyPowerSourcesInfo(); // 返回电量句柄列表数据 CFArrayRef sources = IOPSCopyPowerSourcesList(blob); CFDictionaryRef pSource = NULL; const void *psValue; // 返回数组大小 int numOfSources = CFArrayGetCount(sources); // 计算大小出错处理 if (numOfSources == 0) { NSLog(@"Error in CFArrayGetCount"); return -1.0f; } // 计算所剩电量 for (int i=0; i
说明:
- 因为IOKit.framework是私有类库,使用的时候,需要通过动态引用的方式,没有具体实践,UIDevice获取的方案在iOS 8.0` 之后,精确度可以达到1%, 已经满足项目需要(我们项目最低支持iOS 9)。
4、耗电量大的操作
-
CPU使用率高的操作
线程过多 (控制合适的线程数)定位 (按需使用,降低频次)CPU任务繁重 (使用轻量级对象,缓存计算结果,对象复用等)频繁网络请求(避免无效冗余的网络请求)复制代码
-
I/O操作频繁的操作
直接读写磁盘文件 (合理利用内存缓存,碎片化的数据在内存中聚合,合适时机写入磁盘)复制代码
七、End
1、总结
-
对APP的质量指标的监控,是为了更早地发现问题;发现问题是为了更好地解决问题。所以监控不是终点,是起点。
-
在17年时候,在简书中写了和 两篇文章;时隔两年之后,书写此文,是为了纪念过去大半年时候在App质量监控上花的努力。
-
文章篇幅有限,没有介绍具体的优化办法。