iOS性能数据采集机制汇总

3,285 阅读9分钟

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执行原理

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

当然流量数据的特点是频率高、次数多、体积不定,所以做好缓存和批次上报、压缩上报等工作也是必不可少的。