1. 概述
iOS 客户端的应用性能数据监控一般包括如下指标
- 卡顿监测
- FPS 采集
- CPU 采集
- Memory 采集
- 冷启动测速
- 流量监控
而我们关注监控技术的目的,通常是为了开发一套相关的监控 SDK 或者功能,需要了解各个监控指标的监控手段和原理;因此这里将记录各个监控指标的基本原理和机制,不过多涉及具体的代码实现,大部分监控代码能玩的花样不多,延展出去的监控数据展示、持久化与上报机制又远远比监控本身复杂,此处就不赘述。
2. 卡顿检测
卡顿监控需要利用信号量,对主线程 Runloop 加入 observer 进行监听,通过信号量等待机制,检测出主线程 Runloop 卡顿情况,进行上报。
2.1 加入监听
CFRunLoopActivity observedActivities = kCFRunLoopBeforeSources | kCFRunLoopBeforeWaiting | kCFRunLoopAfterWaiting;
_runloopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, observedActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
__strong __typeof(weakSelf)strongSelf = weakSelf;
if (strongSelf.semaphore != NULL) {
dispatch_semaphore_signal(strongSelf.semaphore);
}
});
CFRunLoopAddObserver(CFRunLoopGetMain(), _runloopObserver, kCFRunLoopCommonModes);
CFRelease(_runloopObserver);
此处主要监听 Runloop 的三个 activity,beforeSources,beforeWaiting 和 afterWaiting,原因是根据 Runloop 内部执行顺序,具体见下图
Runloop 执行 Source0,Source1,MainQueue,Timer 和 Block 的阶段均在这三个时机之间,因此对三个时机插点,就可以监控出执行卡顿的问题。
2.2 信号量等待机制
while (!self.cancelled) {
long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, self.threshold * NSEC_PER_MSEC));
if (status != 0) {
if (self.callback) {
self.callback();
}
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
}
}
此处利用 dispatch_semaphore_wait
函数,在一段时间内(一般是3s-5s)等待信号量,假如 Runloop 运行正常则在上面三个时机点均会执行信号量释放操作,因此如果出现卡顿不能如期释放信号量,则调用 callback 进行卡顿处理和上报。
dispatch_semaphore_wait
返回为 0 代表信号量获取成功,否则未能获取到信号量,此时将永久等待信号量,以确保不再重复上报卡顿。
当然卡顿上报也可以加入次数限制,例如卡顿发生 3 次就不再上报等逻辑。
3. FPS 采集
3.1 基础原理及步骤
FPS 采集完全依赖于 iOS 提供的 CADisplayLink 类,它提供了屏幕刷新时机,并支持自定义回调,从而获知到屏幕刷新的时间戳,依据如下公式就可以得到应用的 FPS 信息。
FPS = FrameCount/Duration
因此对于 FPS 监控的基本步骤如下
- 初始化一个 CADisplayLink
[CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)]
- 回调中记录当前时间戳,记录与上一帧时间戳间隔,记录瞬时 FPS,甚至可以记录自某一时刻开始到当前,总的帧数和总时间间隔,从而计算出平均 FPS
- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
currentTimestamp = displayLink.timestamp;
instantDuration = currentTimestamp - lastTimestamp;
instantFPS = round(1.0/instantDuration);
totalFrameCount++;
totalDuration += instantDuration;
avgFPS = totalFrameCount/totalDuration;
}
但是更进一步,除了关注整体 FPS,我们还可以考虑关注特定 VC,特定 ScrollView,自定义时机的 FPS。
3.2 UIViewController 的 FPS
一个 VC 的 FPS 统计与基础 FPS 统计无异,唯一要关注的是如何确定统计时机,一般选取如下时机
- viewDidAppear 时开启当前 VC 的 FPS 统计,关闭其他 VC 的 FPS 统计
- applicationWillResignActive 退出后台时上报数据,关闭计时器
- applicationDidBecomeActive 进入前台后重启计时器,重置数据
当然可监控的 VC 的选取也存在一些规则,大致如下
- 排除 UIViewController 等系统 VC
- 排除 UINavigationController、UITabBarController、UIInputViewController、UIAlertController 等非页面级的 VC
- 排除一个 UIViewController 内的子 VC
- 排除无父 VC 且不是 present 出来的 VC
这样排除的考虑是监控 FPS 的实体一般只有一个,同一时刻只针对一个 VC 进行监控,子 VC 等不排除的话可能导致监控数据不合理。当然如果能针对每一个 VC 都加入 FPS 监控就可以解决这一问题,但是这样会引入额外的统计时机,比如 VC 的 view 需要添加到其他 VC 上以后才应该监控。
3.3 ScrollView 的 FPS
ScrollView 是常用的展示抽象程度较高、数目较大元素的视图组件,也是 FPS 重灾区,在数据处理、渲染、滑动手势等多处都可能引发掉帧现象,因此有必要对其进行 FPS 监控。
ScrollView 的具体监控依赖于 UIScrollView 的两个属性
- isDragging 用户开始滑动 ScrollView
- isDecelerating 用户停止滑动,但 ScrollView 仍在滚动中
通过 CADisplayLink 回调中检查当前监控的 UIScrollView 实例的两个状态,与其前一次状态对比,进行如下逻辑
- 由未滑动进入到滑动状态,初始化 FPS 数据
- 滑动中,更新统计帧数和统计总时间间隔
- 由滑动状态进入到未滑动状态,上报 FPS 数据
在这一过程中也可以加入当前 ScrollView 所属 VC 的信息方便后续排查。
3.4 自定义 FPS 时机
自定义时机更加灵活,只需要明确统计开始点和结束点,即可按照 FPS 基本原理进行统计。
- (void)startRecordWithIdentifier:(NSString *)identifier;
- (void)stopRecordWithIdentifier:(NSString *)identifier;
4. CPU 采集
iOS 是基于 Apple Darwin 内核,由 kernel、XNU 和 Runtime 组成,而 XNU 是 Darwin 的内核,它是“X is not UNIX”的缩写,是一个混合内核,由 Mach 微内核和 BSD 组成。Mach 内核是轻量级的平台,只能完成操作系统最基本的职责,比如:进程和线程、虚拟内存管理、任务调度、进程通信和消息传递机制。其他的工作,例如文件操作和设备访问,都由 BSD 层实现。
在 Mach 层中定义了一个 thread_basic_info
结构体,提供了线程的基本信息
struct thread_basic_info {
time_value_t user_time; /* user run time */
time_value_t system_time; /* system run time */
integer_t cpu_usage; /* scaled cpu usage percentage */
policy_t policy; /* scheduling policy in effect */
integer_t run_state; /* run state (see below) */
integer_t flags; /* various flags (see below) */
integer_t suspend_count; /* suspend count for thread */
integer_t sleep_time; /* number of seconds that thread
has been sleeping */
};
其中就有我们所需要的 cpu_usage
字段,因此如果获知了组成当前应用进程的所有线程的 thread_basic_info
,就可以统计出 CPU 使用情况了。
在 Mach 层,一个应用进程严格关联一个 Mach Task 对象,通过如下函数可以获知当前应用所在进程的全部线程信息
thread_array_t thread_list;
mach_msg_type_number_t thread_count;
thread_info_data_t thinfo;
mach_msg_type_number_t thread_info_count;
thread_basic_info_t basic_info_th;
kern_return_t kr = task_threads(mach_task_self(), &thread_list, &thread_count);
if (kr != KERN_SUCCESS) {
return -1;
}
接下来遍历整个 thread_list
,算出 CPU 总和
CGFloat total_cpu = 0;
for (int j = 0; j < thread_count; j++)
{
thread_info_count = THREAD_INFO_MAX;
kr = thread_info(thread_list[j], THREAD_BASIC_INFO,(thread_info_t)thinfo, &thread_info_count);
if (kr != KERN_SUCCESS) {
return -1;
}
basic_info_th = (thread_basic_info_t)thinfo;
if (!(basic_info_th->flags & TH_FLAGS_IDLE)) {
total_cpu = total_cpu + basic_info_th->cpu_usage / (CGFloat)TH_USAGE_SCALE * 100.0;
}
}
这里通过 thread_info
函数,将一个 thread 的基础信息(BASIC_INFO
)读入到 thinfo 中,最终获取到的 cpu_usage 还需要除以 TH_USAGE_SCALE
(CPU处理总频率),从而得到 CPU 占比。
此处由于我们创建了一个 thread_list
结构体,因此需要手动释放掉,以避免泄漏内存
kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
assert(kr == KERN_SUCCESS);
获得了瞬时 CPU 占比,可以启动一个定时器定期(1s)采集数据,最终汇总出最大占比和平均占比等数据。
5. Memory 采集
上一节提到一个应用进程对应于一个 Mach Task,而 thread_info
也可以获取到当前进程的所有数据,它们均定义在一个 mach_task_basic_info
结构体中
struct mach_task_basic_info {
mach_vm_size_t virtual_size; /* virtual memory size (bytes) */
mach_vm_size_t resident_size; /* resident memory size (bytes) */
mach_vm_size_t resident_size_max; /* maximum resident memory size (bytes) */
time_value_t user_time; /* total user run time for
terminated threads */
time_value_t system_time; /* total system run time for
terminated threads */
policy_t policy; /* default policy for new threads */
integer_t suspend_count; /* suspend count for task */
};
注释写的也很清楚,这里 resident_size
即代表了物理内存使用情况。
所以获取方式如下
struct mach_task_basic_info info;
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)& info, &count);
return (kr == KERN_SUCCESS) ? info.resident_size : 0;
这里我们返回的是 Byte 单位的内存占用,因而还需要进行一些数学运算以简化数字展示。
但是实际上通过此方法并不能够获取到与 Xcode 上的 Memory 一样的参数,就观察来看它比 Xcode 的统计数据要大很多。这里还有另一种 方法,它获取到的内存占用值更加贴合于 Xcode 的统计值
+ (double)getMemoryUsage {
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
if(task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count) == KERN_SUCCESS) {
return (double)vmInfo.phys_footprint;
} else {
return -1.0;
}
}
而 iOS 的内存杀手 Jetsam 也是通过 phys_footprint
这一参数来获知内存使用是否达到上界的。
6. 冷启动测速
冷启动测速很多时候都与打点密不可分,通常来说我们会在以下一系列地方进行打点获知启动流程
- main 函数
- AppDelegate 代理方法
- homePage 首页
但是在 main 函数执行前其实也有很大一部分耗时工作需要执行,例如
- 加载可执行文件
- 加载动态链接库
- 初始化 Runtime
- +load 函数
完整示意图如下
所以从 main 函数开始计时是与真实情况不够贴合的,更早的时间点获取方式有以下 3 种
- 以可执行文件中任意一个类的 +load 方法的执行时间作为起始点
- 分析 dylib 的依赖关系,找到叶子节点的 dylib,然后以其中某个类的 +load 方法的执行时间作为起始点
- 以 App 的进程创建时间(即 exec 函数执行时间)作为冷启动的起始时间,通过 sysctl 函数获取
这三者里,第三个方式的时间戳统计最早,而且目前未发现更早更准确且更有意义的起始点
#import <sys/sysctl.h>
#import <mach/mach.h>
+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(*procInfo);
return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
+ (NSTimeInterval)processStartTime
{
struct kinfo_proc kProcInfo;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
} else {
NSAssert(NO, @"无法取得进程的信息");
return 0;
}
}
有了起始点,其他打点就可以依次相减得到每一段的具体耗时了。
这里需要补充一点,假如应用执行了安装后启动的操作,例如模拟器上进行编译调试,sysctl 获取的时间戳会从安装起始点开始计算,当然这对于实际使用来说影响不大。
7. 流量监控
流量监控主要需要关注的点有以下四个
- URL,毋庸置疑,监控出问题后需要 URL 来排查
- requestSize,请求大小,具体包括 URL 长度、 header 长度和 body 长度,实际上严格意义上 Method 字段和 Version 字段也需要考虑,但是考虑到它们都是固定长度且占比较小所以不计
NSURL *URL = request.URL;
NSUInteger URLLength = URL.absoluteString.length;
NSUInteger requestHeaderLength = 0;
if (request && [NSJSONSerialization isValidJSONObject:[request allHTTPHeaderFields]]) {
requestHeaderLength = [NSJSONSerialization dataWithJSONObject:[request allHTTPHeaderFields] options:0 error:NULL].length;
}
NSUInteger requestBodyLength = request.HTTPBody.length;
NSUInteger requestSize = URLLength + requestHeaderLength + requestBodyLength;
- responseSize,响应大小,具体包括 header 长度和 body 长度
NSUInteger responseHeaderLength = 0;
if (response && [NSJSONSerialization isValidJSONObject:[response allHeaderFields]]) {
responseHeaderLength = [NSJSONSerialization dataWithJSONObject:[(NSHTTPURLResponse *)response allHeaderFields] options:0 error:NULL].length;
}
NSUInteger responseSize = responseHeaderLength + responseDataLength;
- type,请求类型,具体可以分为
- Web - H5页面,一般来说它的 MIMEType 会是这几种 "text/css","text/html","application/x-javascript","application/javascript"
- API - Native 侧进行 API 接口请求
- Resource - Native 侧进行多媒体资源等资源数据请求,与 API 的区分需要从 URLHost 上着手
- Other
当然流量数据的特点是频率高、次数多、体积不定,所以做好缓存和批次上报、压缩上报等工作也是必不可少的。