iOS性能数据采集机制汇总

栏目: IOS · 发布时间: 5年前

内容简介:iOS 客户端的应用性能数据监控一般包括如下指标而我们关注监控技术的目的,通常是为了开发一套相关的监控 SDK 或者功能,需要了解各个监控指标的监控手段和原理;因此这里将记录各个监控指标的基本原理和机制,不过多涉及具体的代码实现,大部分监控代码能玩的花样不多,延展出去的监控数据展示、持久化与上报机制又远远比监控本身复杂,此处就不赘述。卡顿监控需要利用信号量,对主线程 Runloop 加入 observer 进行监听,通过信号量等待机制,检测出主线程 Runloop 卡顿情况,进行上报。

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 内部执行顺序,具体见下图

iOS性能数据采集机制汇总

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 函数

完整示意图如下

iOS性能数据采集机制汇总

所以从 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

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


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Clean Code

Clean Code

Robert C. Martin / Prentice Hall / 2008-8-11 / USD 49.99

Even bad code can function. But if code isn’t clean, it can bring a development organization to its knees. Every year, countless hours and significant resources are lost because of poorly written code......一起来看看 《Clean Code》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

MD5 加密
MD5 加密

MD5 加密工具

SHA 加密
SHA 加密

SHA 加密工具