APM基础小记

天之道,损有余而补不足

一、概述

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工具Matrix, 提供了针对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调用来查询指定线程的信息,
1
2
3
4
5
6
kern_return_t task_threads
(
task_t target_task,
thread_act_array_t *act_list,
mach_msg_type_number_t *act_listCnt
);

说明task_threadstarget_task 任务中的所有线程保存在act_list数组中,act_listCnt表示线程个数:

3、单个线程信息结构
  • iOS 的线程技术与Mac OS X类似,也是基于 Mach 线程技术实现的,可以通过thread_info这个API调用来查询指定线程的信息,thread_info结构如下:
1
2
3
4
5
6
7
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 结构体封装了单个线程的基本信息:
1
2
3
4
5
6
7
8
9
10
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
1
2
3
4
/*
* Scale factor for usage field.
*/
#define TH_USAGE_SCALE 1000
  • 可以定时,比如2s去计算一次CPU的使用率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
+ (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的值显示值接近)。
1
2
3
4
5
6
7
8
9
10
11
12
13
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、内存信息获取
1
2
3
4
5
6
7
8
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监控

  • FPSFrames 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值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (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个状态
1
2
3
4
5
6
7
8
9
10
//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,所以可以认为kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting。因为kCFRunLoopBeforeSources之后和kCFRunLoopAfterWaiting之后是事情处理的主要时间段。
  • dispatch_semaphore_t信号量机制特性:信号量到达、或者 超时会继续向下进行,否则等待;如果超时则返回的结果必定不为0,否则信号量到达结果为0。

  • 主线程卡顿发生是因为要处理大量的事情。这就意味着主线程在消耗时间在处理繁重的事件,导致信号超时了(dispatch_semaphore_signal不能及时执行),如果此时发现当前的RunLoop的状态是kCFRunLoopBeforeSources或kCFRunLoopAfterWaiting,就认为主线程长期停留在这两个状态上,此时就判定卡顿发生。

3、卡顿监控的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
//  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的状态处于kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 状态就认为卡顿。
  • 利用的RunLoop实现的卡顿方案,主要是针对那些在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题。
  • 卡顿时间阈值(timeoutThreshold)和超时时间次数(timeoutCount)可以通服务器下发控制,用来控制上报卡顿情况的场景。

六、电量监控

1、手动查看电量
  • 我们可以通过手机的设置-电池查看过去一段时间(24小时或2天)查看Top耗电量的App;
  • 对于用户来说,还有更直接的方式,使用某App时候,手机状态栏右上角电池使用量嗖嗖往下掉或手机发热,那么基本可以判断这个App耗电太快,赶紧卸了。
  • 对于开发者来说,可以通过Xcode左边栏的Energy Impact查看电量使用,蓝色表示–合理,黄色–表示比较耗电,红色–表示仅仅轻度使用你的程序,就会很耗电。
  • 还可以使用手机设置-开发者-Logging-Energy的startRecording和stopRecording来记录一段时间(3-5minutes)某App的耗电量情况。导入Instrument来分析具体耗电情况。
2、电量监控方案1
  • 利用UIDevice 提供了获取设备电池的相关信息,包括当前电池的状态以及电量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //开启电量监控
    [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%。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#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<numOfSources; i++) {
// 返回电源可读信息的字典
pSource = IOPSGetPowerSourceDescription(blob, CFArrayGetValueAtIndex(sources, i));
if (!pSource) {
NSLog(@"Error in IOPSGetPowerSourceDescription");
return -1.0f;
}
psValue = (CFStringRef) CFDictionaryGetValue(pSource, CFSTR(kIOPSNameKey));

int curCapacity = 0;
int maxCapacity = 0;
double percentage;

psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSCurrentCapacityKey));
CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &curCapacity);

psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSMaxCapacityKey));
CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &maxCapacity);

percentage = ((double) curCapacity / (double) maxCapacity * 100.0f);
NSLog(@"curCapacity : %d / maxCapacity: %d , percentage: %.1f ", curCapacity, maxCapacity, percentage);
return percentage;
}
return -1.
}

说明

  • 因为IOKit.framework是私有类库,使用的时候,需要通过动态引用的方式,没有具体实践,UIDevice获取的方案在iOS 8.0` 之后,精确度可以达到1%, 已经满足项目需要(我们项目最低支持iOS 9)。
4、耗电量大的操作
  • CPU使用率高的操作

    1
    2
    3
    4
    线程过多 (控制合适的线程数)
    定位 (按需使用,降低频次)
    CPU任务繁重 (使用轻量级对象,缓存计算结果,对象复用等)
    频繁网络请求(避免无效冗余的网络请求)
  • I/O操作频繁的操作

    1
    直接读写磁盘文件 (合理利用内存缓存,碎片化的数据在内存中聚合,合适时机写入磁盘)

七、End

1、总结
  • 对APP的质量指标的监控,是为了更早地发现问题;发现问题是为了更好地解决问题。所以监控不是终点,是起点。

  • 在17年时候,在简书中写了iOS实录14:浅谈iOS Crash(一)iOS实录15:浅谈iOS Crash(二)两篇文章;时隔两年之后,书写此文,是为了纪念过去大半年时候在App质量监控上花的努力。

  • 文章篇幅有限,没有介绍具体的优化办法。
2、推荐的阅读资料

iOS 性能监控方案 Wedjat(上篇)

教你开发省电的 iOS app

文章作者: 南华coder
文章链接: http://buaa0300/nanhuacoder.com/2019/04/16/iOS-AppMonitor/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 南华coder的空间