📚 iOS 性能监控 SDK —— Wedjat(华狄特)开发过程的调研和整理
Switch branches/tags
Nothing to show
Clone or download

README.md

Monitor

为了让这篇文章能够在公众号发表,所以将文章拆解成上下两篇:基础性能篇和网络篇

目录

为什么写这篇文章?

随着移动互联网向纵深发展,用户变得越来越关心应用的体验,开发者必须关注应用性能所带来的用户流失问题。据统计,有十种应用性能问题危害最大,分别为:连接超时、闪退、卡顿、崩溃、黑白屏、网络劫持、交互性能差、CPU 使用率问题、内存泄露、不良接口。开发者难以兼顾所有的性能问题,而在传统的开发流程中,我们解决性能问题的方式通常是在得到线上用户的反馈后,再由开发人员去分析引发问题的根源;显然,凭借用户的反馈来得知应用的性能问题这种方式很原始,也很不高效,它使得开发团队在应对应用性能问题上很被动;所以寻找一种更专业和高效的手段来保障应用的性能就变得势在必行。性能监控 SDK 的定位就是帮助开发团队快速精确地定位性能问题,进而推动应用的性能和用户体验的提升。

这篇文章是我在开发 iOS 性能监控平台 SDK 过程前期的调研和沉淀。主要会探讨在 iOS 平台下如何采集性能指标,如 CPU 占用率、内存使用情况、FPS、冷启动、热启动时间,网络,耗电量等,剖析每一项性能指标的具体实现方式,SDK 的实现会有一定的技术难度,这也是我为什么写这篇文章的原因,我希望能够将开发过程中的一些心得和体会记录下来,同时后续我会将实现 SDK 的详细细节开源出来,希望能对读者有所帮助。

项目名称的来源

我们团队将这个项目命名为 Wedjat(华狄特),取自古埃及神话中鹰头神荷鲁斯的眼睛,荷鲁斯是古埃及神话中法老的守护神,他通常被描绘成“隼头人身”的形象,最常见的代表符号是一只眼睛,该眼也被称之为“荷鲁斯之眼”,象征着“正义之眼”,严厉、公正、铁面无私,一切公开或私人的行为,都逃不过他的法眼。他不但是光明和天堂的象征,最早还是一位生育万物的大神,每天在尼罗河上巡视他的子民。Wedjat 的寓意恰好与我们性能监控 SDK 的愿景相契合。

荷鲁斯之眼又称真知之眼、埃及乌加眼,是一个自古埃及时代便流传至今的符号,也是古埃及文化中最令外人印象深刻的符号之一。荷鲁斯之眼顾名思义,它是鹰头神荷鲁斯的眼睛。荷鲁斯的右眼象征完整无缺的太阳,依据传说,因荷鲁斯战胜赛特,右眼有着远离痛苦,战胜邪恶的力量,荷鲁斯的左眼象征有缺损的月亮,依据传说,荷鲁斯后来将左眼献给欧西里斯,因而左眼亦有分辨善恶、捍卫健康与幸福的作用,亦使古埃及人也相信荷鲁斯的左眼具有复活死者的力量。

CPU

A CPU chip is designed for portable computers, it is typically housed in a smaller chip package, but more importantly, in order to run cooler, it uses lower voltages than its desktop counterpart and has more "sleep mode" capability. A mobile processor can be throttled down to different power levels or sections of the chip can be turned off entirely when not in use. Further, the clock frequency may be stepped down under low processor loads. This stepping down conserves power and prolongs battery life.

CPU 是移动设备最重要的计算资源,设计糟糕的应用可能会造成 CPU 持续以高负载运行,一方面会导致用户使用过程遭遇卡顿;另一方面也会导致手机发热发烫,电量被快速消耗完,严重影响用户体验。

APP 的 CPU 占用率

如果想避免出现上述情况,可以通过监控应用的 CPU 占用率,那么在 iOS 中如何实现 CPU 占用率的监控呢?事实上,学习过操作系统课程的读者都了解线程是调度和分配的基本单位,而应用作为进程运行时,包含了多个不同的线程,显然如果我们能获取应用的所有线程占用 CPU 的情况,也就能知道应用的 CPU 占用率。

iOS 是基于 Apple Darwin 内核,由 kernel、XNU 和 Runtime 组成,而 XNU 是 Darwin 的内核,它是“X is not UNIX”的缩写,是一个混合内核,由 Mach 微内核和 BSD 组成。Mach 内核是轻量级的平台,只能完成操作系统最基本的职责,比如:进程和线程、虚拟内存管理、任务调度、进程通信和消息传递机制。其他的工作,例如文件操作和设备访问,都由 BSD 层实现。

上图是权威著作《OS X Internal: A System Approach》给出的 Mac OS X 中进程子系统组成的概念图,与 Mac OS X 类似,iOS 的线程技术也是基于 Mach 线程技术实现的,在 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 */
};

任务(task)是一种容器(container)对象,虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。严格地说,Mach 的任务并不是其他操作系统中所谓的进程,因为 Mach 作为一个微内核的操作系统,并没有提供“进程”的逻辑,而只是提供了最基本的实现。不过在 BSD 的模型中,这两个概念有1:1的简单映射,每一个 BSD 进程(也就是 OS X 进程)都在底层关联了一个 Mach 任务对象。

上面引用的是《OS X and iOS Kernel Programming》对 Mach task 的描述,Mach task 可以看作一个机器无关的 thread 执行环境的抽象 一个 task 包含它的线程列表。内核提供了 task_threads API 调用获取指定 task 的线程列表,然后可以通过 thread_info API 调用来查询指定线程的信息,thread_info API 在 thread_act.h 中定义。

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 个条目。

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
);

thread_info 查询 flavor 指定的 thread 信息,将信息返回到长度为 thread_info_outCnt 字节的 thread_info_out 缓存区中,

有了上面的铺垫后,得到获取当前应用的 CPU 占用率的实现如下:

#import <mach/mach.h>
#import <assert.h>

+ (CGFloat)appCpuUsage {
    kern_return_t kr;
    task_info_data_t tinfo;
    mach_msg_type_number_t task_info_count;
    
    task_info_count = TASK_INFO_MAX;
    kr = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)tinfo, &task_info_count);
    if (kr != KERN_SUCCESS) {
        return -1;
    }
    
    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;
    
    // get threads in the task
    kr = task_threads(mach_task_self(), &thread_list, &thread_count);
    if (kr != KERN_SUCCESS) {
        return -1;
    }
    
    long total_time     = 0;
    long total_userTime = 0;
    CGFloat total_cpu   = 0;
    int j;
    
    // for each thread
    for (j = 0; j < (int)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_time     = total_time + basic_info_th->user_time.seconds + basic_info_th->system_time.seconds;
            total_userTime = total_userTime + basic_info_th->user_time.microseconds + basic_info_th->system_time.microseconds;
            total_cpu      = total_cpu + basic_info_th->cpu_usage / (float)TH_USAGE_SCALE * kMaxPercent;
        }
    }
    
    kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
    assert(kr == KERN_SUCCESS);
    
    return total_cpu;
}

在调用 task_threads API 时,target_task 参数传入的是 mach_task_self(),表示获取当前的 Mach task。而在调用 thread_info API 时,flavor 参数传的是 THREAD_BASIC_INFO ,使用这个类型会返回线程的基本信息,定义在 thread_basic_info_t 结构体,包含了用户和系统的运行时间,运行状态和调度优先级。

注意方法最后要调用 vm_deallocate,防止出现内存泄漏。据测试,该方法采集的 CPU 数据和腾讯的 GTInstruments 数据接近。

由于监控 CPU 的线程也会占用 CPU 资源,所以为了让结果更客观,可以考虑在计算的时候将监控线程排除。

下面是 GT 中获得 App 的 CPU 占用率的方法

- (float)getCpuUsage
{
    kern_return_t           kr;
    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;
    
    kr = task_threads(mach_task_self(), &thread_list, &thread_count);
    if (kr != KERN_SUCCESS) {
        return -1;
    }
    cpu_usage = 0;
    
    for (int i = 0; i < thread_count; i++)
    {
        thread_info_count = THREAD_INFO_MAX;
        kr = thread_info(thread_list[i], 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))
        {
            cpu_usage += basic_info_th->cpu_usage;
        }
    }
    
    cpu_usage = cpu_usage / (float)TH_USAGE_SCALE * 100.0;
    
    vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
    
    return cpu_usage;
}

总的 CPU 占用率

而获取整个设备的 CPU 占用率如下:

static NSUInteger const kMaxPercent = 100;

+ (CGFloat)cpuUsage {
    CGFloat cpuUsage = 0;
    processor_info_array_t _cpuInfo, _prevCPUInfo = nil;
    mach_msg_type_number_t _numCPUInfo, _numPrevCPUInfo = 0;
    unsigned _numCPUs;
    NSLock *_cpuUsageLock;
    
    int _mib[2U] = {CTL_HW, HW_NCPU};
    size_t _sizeOfNumCPUs = sizeof(_numCPUs);
    int _status = sysctl(_mib, 2U, &_numCPUs, &_sizeOfNumCPUs, NULL, 0U);
    if (_status)
        _numCPUs = 1;
    
    _cpuUsageLock = [[NSLock alloc] init];
    
    natural_t _numCPUsU = 0U;
    kern_return_t err = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &_numCPUsU, &_cpuInfo, &_numCPUInfo);
    if (err == KERN_SUCCESS) {
        [_cpuUsageLock lock];
        
        for (unsigned i = 0U; i < _numCPUs; ++i) {
            CGFloat _inUse, _total = 0;
            if (_prevCPUInfo) {
                _inUse = (
                          (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER]   - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER])
                          + (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM] - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM])
                          + (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE]   - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE])
                          );
                _total = _inUse + (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE] - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE]);
            } else {
                _inUse = _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER] + _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM] + _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE];
                _total = _inUse + _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE];
            }
            
            if (_total != 0) {
                cpuUsage += _inUse / _total;
            }
        }
        
        [_cpuUsageLock unlock];
        if (_prevCPUInfo) {
            size_t prevCpuInfoSize = sizeof(integer_t) * _numPrevCPUInfo;
            vm_deallocate(mach_task_self(), (vm_address_t)_prevCPUInfo, prevCpuInfoSize);
        }
        return cpuUsage * kMaxPercent ;
    } else {
        return -1;
    }
}

上述方法大致思路是先计算出每个 CPU 核心的占用率,然后将所有 CPU 核心的占用率相加得到设备总的 CPU 占用率,这主要参考 top 命令,它在计算多核 CPU 的占用率时,是把每个核的 CPU 占用率求和。

网上有很多文章都是通过上述方式去获取设备的 CPU 占用率,包括 YYCategoriesUIDeviceYYAdd category 也是采用这种方式,但是其实计算出来的 CPU 占用率会维持一个值基本没有改变,要归功于 ySssssssss 发现这个细节。上面这段代码其实存在问题,代码中的 _prevCPUInfo_numPrevCPUInfo 等使用的是局部变量,这会造成对 _prevCPUInfo 非空的判断总是为假,最终计算 _cpuInfo_prevCPUInfo 差值的那段代码根本不会执行。可以通过将这几个变量改为成员变量,或者使用静态变量。成员变量的写法如下:

@implementation WDTDevice {
    processor_info_array_t _cpuInfo, _prevCPUInfo;
    mach_msg_type_number_t _numCPUInfo, _numPrevCPUInfo;
    NSLock *_cpuUsageLock;
}

- (CGFloat)cpuUsage {
    CGFloat cpuUsage = 0;
    unsigned _numCPUs;
    
    int _mib[2U] = {CTL_HW, HW_NCPU};
    size_t _sizeOfNumCPUs = sizeof(_numCPUs);
    int _status = sysctl(_mib, 2U, &_numCPUs, &_sizeOfNumCPUs, NULL, 0U);
    if (_status)
        _numCPUs = 1;
    
    _cpuUsageLock = [[NSLock alloc] init];
    
    natural_t _numCPUsU = 0U;
    kern_return_t err = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &_numCPUsU, &_cpuInfo, &_numCPUInfo);
    if (err == KERN_SUCCESS) {
        [_cpuUsageLock lock];
        
        for (unsigned i = 0U; i < _numCPUs; ++i) {
            CGFloat _inUse, _total = 0;
            if (_prevCPUInfo) {
                _inUse = (
                          (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER]   - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER])
                          + (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM] - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM])
                          + (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE]   - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE])
                          );
                _total = _inUse + (_cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE] - _prevCPUInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE]);
            } else {
                _inUse = _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER] + _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM] + _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE];
                _total = _inUse + _cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE];
            }
            
            if (_total != 0) {
                cpuUsage += _inUse / _total;
            }
        }
        
        [_cpuUsageLock unlock];
        if (_prevCPUInfo) {
            size_t prevCpuInfoSize = sizeof(integer_t) * _numPrevCPUInfo;
            vm_deallocate(mach_task_self(), (vm_address_t)_prevCPUInfo, prevCpuInfoSize);
        }
        
        _prevCPUInfo = _cpuInfo;
        _numPrevCPUInfo = _numCPUInfo;
        
        _cpuInfo = NULL;
        _numCPUInfo = 0U;

        return cpuUsage * kMaxPercent ;
    } else {
        return -1;
    }
}

改为这种写法之后发现结果几乎都在 100% 以上,所以这种写法依然存在问题。

于是寻找到另外一种 host_statistics 函数拿到 host_cpu_load_info 的值,这个结构体的成员变量 cpu_ticks 包含了 CPU 运行的时钟脉冲的数量,cpu_ticks 是一个数组,里面分别包含了 CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLECPU_STATE_NICE 模式下的时钟脉冲。

+ (CGFloat)cpuUsage {
    kern_return_t kr;
    mach_msg_type_number_t count;
    static host_cpu_load_info_data_t previous_info = {0, 0, 0, 0};
    host_cpu_load_info_data_t info;
    
    count = HOST_CPU_LOAD_INFO_COUNT;
    
    kr = host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, (host_info_t)&info, &count);
    if (kr != KERN_SUCCESS) {
        return -1;
    }
    
    natural_t user   = info.cpu_ticks[CPU_STATE_USER] - previous_info.cpu_ticks[CPU_STATE_USER];
    natural_t nice   = info.cpu_ticks[CPU_STATE_NICE] - previous_info.cpu_ticks[CPU_STATE_NICE];
    natural_t system = info.cpu_ticks[CPU_STATE_SYSTEM] - previous_info.cpu_ticks[CPU_STATE_SYSTEM];
    natural_t idle   = info.cpu_ticks[CPU_STATE_IDLE] - previous_info.cpu_ticks[CPU_STATE_IDLE];
    natural_t total  = user + nice + system + idle;
    previous_info    = info;
    
    return (user + nice + system) * 100.0 / total;
}

上面代码通过计算 infoprevious_info 的差值,分别得到在这几个模式下的 cpu_ticks,除 idle 以外都属于 CPU 被占用的情况,最后就能求出 CPU 的占用率。

经测试发现这种计算总的 CPU 占用率的方式与 iOS 系统的 top 命令的值吻合(测试环境:iPhone 5s 的越狱机器),并且 App Store 中的几个性能工具的应用都是采用这种方式去计算设备的 CPU 占用率的,比如简易系统状态Battery Memory System Status Monitor 这两款应用。

CPU 核数

+ (NSUInteger)cpuNumber {
    return [NSProcessInfo processInfo].activeProcessorCount;
}

CPU 频率

CPU 频率,就是 CPU 的时钟频率, 是 CPU 运算时的工作的频率(1秒内发生的同步脉冲数)的简称。单位是 Hz,它决定移动设备的运行速度。

在 iOS 中与 CPU 频率相关的性能指标有三个:CPU 频率,CPU 最大频率 和 CPU 最小频率。

下面代码给出了获取 CPU 频率的实现,笔者通过反编译发现手淘,腾讯视频等应用也是通过这种方式获取 CPU 频率,反编译的截图如下。

上面反编译代码的实现效果和下面这段代码基本一致。

+ (NSUInteger)getSysInfo:(uint)typeSpecifier {
    size_t size = sizeof(int);
    int results;
    int mib[2] = {CTL_HW, typeSpecifier};
    sysctl(mib, 2, &results, &size, NULL, 0);
    return (NSUInteger)results;
}

+ (NSUInteger)getCpuFrequency {
    return [self getSysInfo:HW_CPU_FREQ];
}

反编译代码中的 [self getSysInfo:0Xf] 的参数 0Xf 就是 HW_CPU_FREQHW_CPU_FREQ 的宏定义的就是 15.

但是在真机测试会发现上述方式并不能正确获取到设备的 CPU 频率,如果你在网上搜索会发现有很多代码都是使用这种方式,猜测应该是早期版本还是能够获取到的,只不过出于安全性的考虑,主频这个内核变量也被禁止访问了。手淘等应用中代码估计应该是遗留代码。 既然上述方式已经被 Apple 堵死了,我们还有其他的方法可以获取到 CPU 主频吗?当然,其实我们还是可以通过一些变通的方式获取到的,主要有以下两种方式。 第一种方式是比较容易实现,我们通过硬编码的方式,建立一张机型和 CPU 主频的映射表,然后根据机型找到对应的 CPU 主频即可。

static const NSUInteger CPUFrequencyTable[] = {
    [iPhone_1G]         = 412,
    [iPhone_3G]         = 620,
    [iPhone_3GS]        = 600,
    [iPhone_4]          = 800,
    [iPhone_4_Verizon]  = 800,
    [iPhone_4S]         = 800,
    [iPhone_5_GSM]      = 1300,
    [iPhone_5_CDMA]     = 1300,
    [iPhone_5C]         = 1000,
    [iPhone_5S]         = 1300,
    [iPhone_6]          = 1400,
    [iPhone_6_Plus]     = 1400,
    [iPhone_6S]         = 1850,
    [iPhone_6S_Plus]    = 1850,
    [iPod_Touch_1G]     = 400,
    [iPod_Touch_2G]     = 533,
    [iPod_Touch_3G]     = 600,
    [iPod_Touch_4G]     = 800,
    [iPod_Touch_5]      = 1000,
    [iPad_1]            = 1000,
    [iPad_2_CDMA]       = 1000,
    [iPad_2_GSM]        = 1000,
    [iPad_2_WiFi]       = 1000,
    [iPad_3_WiFi]       = 1000,
    [iPad_3_GSM]        = 1000,
    [iPad_3_CDMA]       = 1000,
    [iPad_4_WiFi]       = 1400,
    [iPad_4_GSM]        = 1400,
    [iPad_4_CDMA]       = 1400,
    [iPad_Air]          = 1400,
    [iPad_Air_Cellular] = 1400,
    [iPad_Air_2]        = 1500,
    [iPad_Air_2_Cellular] = 1500,
    [iPad_Pro]          = 2260,
    [iPad_Mini_WiFi]    = 1000,
    [iPad_Mini_GSM]     = 1000,
    [iPad_Mini_CDMA]    = 1000,
    [iPad_Mini_2]       = 1300,
    [iPad_Mini_2_Cellular] = 1300,
    [iPad_Mini_3]       = 1300,
    [iPad_Mini_3_Cellular] = 1300,
    [iUnknown]          = 0
};

上面主频值的单位为 MHZ,SystemMonitor 就是使用这种方式。

第二种方式实现起来较上一种方式更为复杂,可以通过计算来得出 CPU 频率,具体的代码如下

extern int freqTest(int cycles);

static double GetCPUFrequency(void)
{
    volatile NSTimeInterval times[500];
    
    int sum = 0;
    
    for(int i = 0; i < 500; i++)
    {
        times[i] = [[NSProcessInfo processInfo] systemUptime];
        sum += freqTest(10000);
        times[i] = [[NSProcessInfo processInfo] systemUptime] - times[i];
    }
    
    NSTimeInterval time = times[0];
    for(int i = 1; i < 500; i++)
    {
        if(time > times[i])
            time = times[i];
    }
    
    double freq = 1300000.0 / time;
    return freq;
}

出于效率的考虑,代码中 freqTest 这个函数是用汇编写的,在工程加入一个文件 cpuFreq.s,后缀 s 代表这个文件是一个汇编文件,文件的代码如下:

.text
.align 4
.globl _freqTest    

_freqTest:

    push    {r4-r11, lr}

freqTest_LOOP:

    // loop 1
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 2
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 3
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 4
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 5
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 6
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 7
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 8
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 9
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    // loop 10
    add     r2, r2, r1
    add     r3, r3, r2
    add     r4, r4, r3
    add     r5, r5, r4
    add     r6, r6, r5
    add     r7, r7, r6
    add     r8, r8, r7
    add     r9, r9, r8
    add     r10, r10, r9
    add     r11, r11, r10
    add     r12, r12, r11
    add     r14, r14, r12
    add     r1, r1, r14

    subs    r0, r0, #1
    bne     freqTest_LOOP
    pop     {r4-r11, pc}

当然这个文件的汇编指令只支持 armv7 和 armv7s ,也就是 32 位 Arch,64 位汇编指令有机会再补上,如果你使用的是 64 位机器调试,记得将 build archive architecture only 设置为 NO 如下图,否则会编译不通过,

我用一台 iPhone 6 测试了这种方法获得 CPU 频率,结果为 1391614727.725209 HZ,大约也就是 1400 MHZ,和上面那张表中主频一致。

这种实现方式的代码实际是参考了 AppStore 上的一款应用 CPU Dasher,代码参考CPU-Dasher-for-iOS

要获取 CPU 最大频率 和 CPU 最小频率这两个性能指标也需要用到 sysctlsysctl 是用以查询内核状态的接口,具体实现如下

static inline Boolean WDTCanGetSysCtlBySpecifier(char* specifier, size_t *size) {
    if (!specifier || strlen(specifier) == 0 ||
        sysctlbyname(specifier, NULL, size, NULL, 0) == -1 || size == -1) {
        return false;
    }
    return true;
}

static inline uint64_t WDTGetSysCtl64BySpecifier(char* specifier) {
    size_t size = -1;
    uint64_t val = 0;
    
    if (!WDTCanGetSysCtlBySpecifier(specifier, &size)) {
        return -1;
    }
    
    if (sysctlbyname(specifier, &val, &size, NULL, 0) == -1)
    {
        return -1;
    }

    return val;
}

+ (NSUInteger)cpuMaxFrequency {
    return (NSUInteger)WDTGetSysCtl64BySpecifier("hw.cpufrequency_max");
}

+ (NSUInteger)cpuMinFrequency {
    return (NSUInteger)WDTGetSysCtl64BySpecifier("hw.cpufrequency_min");
}

但是实际在真机测试会发现,当 specifierhw.cpufrequency_maxhw.cpufrequency_min 时,sysctlbyname(specifier, NULL, size, NULL, 0)函数的返回值为-1,导致无法获取这两个指标,模拟器上则正常,然而模拟器上获取的两个指标的值都是2700000000HZ,我的 MBP 的主频就是2.7GHZ。应该是 iOS 禁用了这两个内核变量的获取,暂时也没找到有什么更好的方法能在真机上获取这两个指标。

CPU Type

我们知道 iPhone 使用的处理器架构都是 ARM 的,而 ARM 又分为 ARMV7、ARMV7S 和 ARM64等。而想要获取设备具体的处理器架构则需要使用 NXGetLocalArchInfo() 函数。这个函数的返回值是 NXArchInfo 结构体类型,如下:

typedef struct {
    const char *name;
    cpu_type_t cputype;
    cpu_subtype_t cpusubtype;
    enum NXByteOrder byteorder;
    const char *description;
} NXArchInfo;

NXArchInfo 结构体成员变量中就包含我们需要的信息:cputypecpusubtype,这两个变量类型的定义在 mach/machine.h 头文件中给出,本质上都是 int 类型 typedef 得到的。

根据 mach/machine.h 头文件给出的 CPU 架构类型的定义,可以很容易建立起各 CPU 架构到其对应描述的映射关系,代码实现如下:

+ (NSInteger)cpuType {
    return (NSInteger)NXGetLocalArchInfo()->cputype;
}
+ (NSInteger)cpuSubtype {
    return (NSInteger)NXGetLocalArchInfo()->cpusubtype;
}
- (NSString *)p_stringFromCpuType:(NSInteger)cpuType {
    switch (cpuType) {
        case CPU_TYPE_VAX:          return @"VAX";          
        case CPU_TYPE_MC680x0:      return @"MC680x0";      
        case CPU_TYPE_X86:          return @"X86";          
        case CPU_TYPE_X86_64:       return @"X86_64";       
        case CPU_TYPE_MC98000:      return @"MC98000";      
        case CPU_TYPE_HPPA:         return @"HPPA";         
        case CPU_TYPE_ARM:          return @"ARM";          
        case CPU_TYPE_ARM64:        return @"ARM64";        
        case CPU_TYPE_MC88000:      return @"MC88000";      
        case CPU_TYPE_SPARC:        return @"SPARC";        
        case CPU_TYPE_I860:         return @"I860";         
        case CPU_TYPE_POWERPC:      return @"POWERPC";      
        case CPU_TYPE_POWERPC64:    return @"POWERPC64";    
        default:                    return @"Unknown";      
    }
}
- (NSString *)cpuTypeString {
    if (!_cpuTypeString) {
        _cpuTypeString = [self p_stringFromCpuType:[[self class] cpuType]];
    }
    
    return _cpuTypeString;
}

- (NSString *)cpuSubtypeString {
    if (!_cpuSubtypeString) {
        _cpuSubtypeString = [NSString stringWithUTF8String:NXGetLocalArchInfo()->description];
    }
    
    return _cpuSubtypeString;
}

经测试发现 NXArchInfo 结构体成员变量 description 包含的就是 CPU 架构的详尽信息,所以可以用它作为 cpuSubtypeString,当然也可以自己建立 cpuSubtype 的映射关系。

Memory

物理内存(RAM)与 CPU 一样都是系统中最稀少的资源,也是最有可能产生竞争的资源,应用内存与性能直接相关 - 通常是以牺牲别的应用为代价。 不像 PC 端,iOS 没有交换空间作为备选资源,这就使得内存资源尤为重要。事实上,在 iOS 中就有 Jetsam 机制负责处理系统低 RAM 事件,Jetsam 是一种类似 Linux 的 Out-Of-Memory(Killer) 的机制。

App 使用的内存

mach_task_basic_info 结构体存储了 Mach task 的内存使用信息,其中 resident_size 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。

#define MACH_TASK_BASIC_INFO     20         /* always 64-bit 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 */
};

这里需要提到的是有些文章使用的 task_basic_info 结构体,而不是上文的 mach_task_basic_info,值得注意的是 Apple 已经不建议再使用 task_basic_info 结构体了。

/* localized structure - cannot be safely passed between tasks of differing sizes */
/* Don't use this, use MACH_TASK_BASIC_INFO instead */
struct task_basic_info {
        integer_t       suspend_count;  /* suspend count for task */
        vm_size_t       virtual_size;   /* virtual memory size (bytes) */
        vm_size_t       resident_size;  /* 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 */
};

task_info API 根据指定的 flavor 类型返回 target_task 的信息。

kern_return_t task_info
(
	task_name_t target_task,
	task_flavor_t flavor,
	task_info_t task_info_out,
	mach_msg_type_number_t *task_info_outCnt
);

于是得到获取当前 App Memory 的使用情况

- (NSUInteger)getResidentMemory
{
    struct mach_task_basic_info info;
    mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
	
	int r = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)& info, & count);
	if (r == KERN_SUCCESS)
	{
		return info.resident_size;
	}
	else
	{
		return -1;
	}
}

细心的读者会发现,将上述代码采集到的 App RAM 的使用值与 Xcode 的 Debug Gauges 的 memory 对比,会发现代码会与 Debug Gauges 显示的值存在差异,有时甚至会差几百 MB,那么究竟怎样才能获取到应用使用的真实内存值呢?

我们先来看看 WebKit 源码中是怎样使用的,在 MemoryFootprintCocoa.cpp 文件中,代码如下:

size_t memoryFootprint()
{
    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 static_cast<size_t>(vmInfo.phys_footprint);
}

可以看到代码使用的不是 resident_size,而是 phys_footprintphys_footprint 同样是 task_info 的成员变量。

另外我们知道在 iOS 中如果应用使用内存高于水位线时,会被 JetSam 杀死,那么我们也来探索下 JetSam 是怎么获取应用内存吧。具体代码实现在 kern_memorystatus.c 文件中,代码如下:

static boolean_t
memorystatus_kill_hiwat_proc(uint32_t *errors)
{
.....
		/* skip if no limit set */
		if (p->p_memstat_memlimit <= 0) {
			continue;
		}

		footprint_in_bytes = get_task_phys_footprint(p->task);
		memlimit_in_bytes  = (((uint64_t)p->p_memstat_memlimit) * 1024ULL * 1024ULL);	/* convert MB to bytes */
		skip = (footprint_in_bytes <= memlimit_in_bytes);
.....
	return killed;
}

当我们将获取内存的实现从 resident_size 换成 phys_footprint 时,于是代码获取的内存值就和 Xcode Debug Gauges 一致了。

最后,我们得到获取应用使用真实内存值的代码如下:

- (NSUInteger)getApplicationUsedMemory
{
    struct mach_task_basic_info info;
    mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
	
	int r = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)& info, & count);
	if (r == KERN_SUCCESS)
	{
		return info.phys_footprint;
	}
	else
	{
		return -1;
	}
}

与获取 CPU 占用率类似,在调用 task_info API 时,target_task 参数传入的是 mach_task_self(),表示获取当前的 Mach task,另外 flavor 参数传的是 MACH_TASK_BASIC_INFO,使用这个类型会返回 mach_task_basic_info 结构体,表示返回 target_task 的基本信息,比如 task 的挂起次数和驻留页面数量。

如果想获取设备所有物理内存大小可以通过 NSProcessInfo

[NSProcessInfo processInfo].physicalMemory

设备使用的内存

获取当前设备的 Memory 使用情况

int64_t getUsedMemory()
{
    size_t length = 0;
    int mib[6] = {0};
    
    int pagesize = 0;
    mib[0] = CTL_HW;
    mib[1] = HW_PAGESIZE;
    length = sizeof(pagesize);
    if (sysctl(mib, 2, &pagesize, &length, NULL, 0) < 0)
    {
        return 0;
    }
    
    mach_msg_type_number_t count = HOST_VM_INFO_COUNT;
    
    vm_statistics_data_t vmstat;
    
    if (host_statistics(mach_host_self(), HOST_VM_INFO, (host_info_t)&vmstat, &count) != KERN_SUCCESS)
    {
		return 0;
    }
    
    int wireMem = vmstat.wire_count * pagesize;
    int activeMem = vmstat.active_count * pagesize;
    return wireMem + activeMem;
}

设备可用的内存

获取当前设备可用的 Memory

+ (uint64_t)availableMemory {
    vm_statistics64_data_t vmStats;
    mach_msg_type_number_t infoCount = HOST_VM_INFO_COUNT;
    kern_return_t kernReturn = host_statistics(mach_host_self(),
                                               HOST_VM_INFO,
                                               (host_info_t)&vmStats,
                                               &infoCount);
    
    if (kernReturn != KERN_SUCCESS) {
        return NSNotFound;
    }
        
    return vm_page_size * (vmStats.free_count + vmStats.inactive_count);
}

读者可能会看到有些代码会使用 vm_statistics_data_t 结构体,但是这个结构体是32位机器的,随着 Apple 逐渐放弃对32位应用的支持,所以建议读者还是使用 vm_statistics64_data_t 64位的结构体。

Startup Time

毫无疑问移动应用的启动时间是影响用户体验的一个重要方面,那么我们究竟该如何通过启动时间来衡量一个应用性能的好坏呢?启动时间可以从冷启动和热启动两个角度去测量

  • 冷启动:指的是应用尚未运行,必须加载并构建整个应用,完成初始化的工作,冷启动往往比热启动耗时长,而且每个应用的冷启动耗时差别也很大,所以冷启动存在很大的优化空间,冷启动时间从applicationDidFinishLaunching:withOptions:方法开始计算,很多应用会在该方法对其使用的第三方库初始化。
  • 热启动:应用已经在后台运行(常见的场景是用户按了 Home 按钮),由于某个事件将应用唤醒到前台,应用会在 applicationWillEnterForeground: 方法接收应用进入前台的事件

先来研究下冷启动,因为在它里面存在很多资源密集型的操作,下面先看看苹果官方文档给的应用的启动时序图

t(App 总启动时间) = t1(main()之前的加载时间) + t2(main()之后的加载时间)。

t1 = 系统的 dylib (动态链接库)和 App 可执行文件的加载时间

t2 = main函数执行之后到 AppDelegate 类中的applicationDidFinishLaunching:withOptions:方法执行结束前这段时间

先来看看如何通过打点的方式统计main函数之后的时间,下面代码是有些文章给出的一种实现方式

CFAbsoluteTime StartTime;

int main(int argc, char * argv[]) {
    @autoreleasepool {
        StartTime = CFAbsoluteTimeGetCurrent();
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

extern CFAbsoluteTime StartTime;
 ...
 
// 在 applicationDidFinishLaunching:withOptions: 方法的最后统计
dispatch_async(dispatch_get_main_queue(), ^{
    NSLog(@"Launched in %f sec", CFAbsoluteTimeGetCurrent() - StartTime);
});

上述代码使用CFAbsoluteTimeGetCurrent()方法来计算时间,CFAbsoluteTimeGetCurrent()的概念和NSDate非常相似,只不过参考点是以 GMT 为标准的,2001年一月一日00:00:00这一刻的时间绝对值。CFAbsoluteTimeGetCurrent()也会跟着当前设备的系统时间一起变化,也可能会被用户修改。他的精确度可能是微秒(μs)

其实还可以通过mach_absolute_time()来计算时间,这个一般很少用,他表示 CPU 的时钟周期数(ticks),精确度可以达到纳秒(ns),mach_absolute_time()不受系统时间影响,只受设备重启和休眠行为影响。示例代码如下

static uint64_t loadTime;
static uint64_t applicationRespondedTime = -1;
static mach_timebase_info_data_t timebaseInfo;

static inline NSTimeInterval MachTimeToSeconds(uint64_t machTime) {
    return ((machTime / 1e9) * timebaseInfo.numer) / timebaseInfo.denom;
}

@implementation XXStartupMeasurer

+ (void)load {
    loadTime = mach_absolute_time();
    mach_timebase_info(&timebaseInfo);
    
    @autoreleasepool {
        __block id<NSObject> obs;
        obs = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification
                                                                object:nil queue:nil
                                                            usingBlock:^(NSNotification *note) {
            dispatch_async(dispatch_get_main_queue(), ^{
                applicationRespondedTime = mach_absolute_time();
                NSLog(@"StartupMeasurer: it took %f seconds until the app could respond to user interaction.", MachTimeToSeconds(applicationRespondedTime - loadTime));
            });
            [[NSNotificationCenter defaultCenter] removeObserver:obs];
        }];
    }
}

因为类的+ load方法在main函数执行之前调用,所以我们可以在+ load方法记录开始时间,同时监听UIApplicationDidFinishLaunchingNotification通知,收到通知时将时间相减作为应用启动时间,这样做有一个好处,不需要侵入到业务方的main函数去记录开始时间点。

FPS

首先来看 wikipedia 上是怎么定义 FPS(Frames Per Second)。

Frame rate (expressed in frames per second or FPS) is the frequency (rate) at which consecutive images called frames are displayed in an animated display. The term applies equally to film and video cameras, computer graphics, and motion capture systems. Frame rate may also be called the frame frequency, and be expressed in hertz.

通过定义可以看出 FPS 是测量用于保存、显示动态视频的信息数量,每秒钟帧数愈多,所显示的动作就会愈流畅,一般应用只要保持 FPS 在 50-60,应用就会给用户流畅的感觉,反之,用户则会感觉到卡顿。

接下来我们看下网络上流传的最多的关于测量 FPS 的方法,GitHub 上有关计算 FPS 的仓库基本都是通过以下方式实现的:

@implementation YYFPSLabel {
    CADisplayLink *_link;
    NSUInteger _count;
    NSTimeInterval _lastTime;    
}

- (id)init {
    self = [super init];
    if( self ){        
    _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
        
    }
    return self;
}

- (void)dealloc {
    [_link invalidate];
}

- (void)tick:(CADisplayLink *)link {
    if (_lastTime == 0) {
        _lastTime = link.timestamp;
        return;
    }
    
    _count++;
    NSTimeInterval delta = link.timestamp - _lastTime;
    if (delta < 1) return;
    _lastTime = link.timestamp;
    float fps = _count / delta;
    _count = 0;    
}

上面是 YYText 中 Demo 的 YYFPSLabel,主要是基于CADisplayLink以屏幕刷新频率同步绘图的特性,尝试根据这点去实现一个可以观察屏幕当前帧数的指示器。YYWeakProxy的使用是为了避免循环引用。

值得注意的是基于CADisplayLink实现的 FPS 在生产场景中只有指导意义,不能代表真实的 FPS,因为基于CADisplayLink实现的 FPS 无法完全检测出当前 Core Animation 的性能情况,它只能检测出当前 RunLoop 的帧率。

Freezing/Lag

为什么会出现卡顿

从一个像素到最后真正显示在屏幕上,iPhone 究竟在这个过程中做了些什么?想要了解背后的运作流程,首先需要了解屏幕显示的原理。iOS 上完成图形的显示实际上是 CPU、GPU 和显示器协同工作的结果,具体来说,CPU 负责计算显示内容,包括视图的创建、布局计算、图片解码、文本绘制等,CPU 完成计算后会将计算内容提交给 GPU,GPU 进行变换、合成、渲染后将渲染结果提交到帧缓冲区,当下一次垂直同步信号(简称 V-Sync)到来时,最后显示到屏幕上。下面是显示流程的示意图:

上文中提到 V-Sync 是什么,以及为什么要在 iPhone 的显示流程引入它呢?在 iPhone 中使用的是双缓冲机制,即上图中的 FrameBuffer 有两个缓冲区,双缓冲区的引入是为了提升显示效率,但是与此同时,他引入了一个新的问题,当视频控制器还未读取完成时,比如屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象,V-Sync 就是为了解决画面撕裂问题,开启 V-Sync 后,GPU 会在显示器发出 V-Sync 信号后,去进行新帧的渲染和缓冲区的更新。

搞清楚了 iPhone 的屏幕显示原理后,下面来看看在 iPhone 上为什么会出现卡顿现象,上文已经提及在图像真正在屏幕显示之前,CPU 和 GPU 需要完成自身的任务,而如果他们完成的时间错过了下一次 V-Sync 的到来(通常是1000/60=16.67ms),这样就会出现显示屏还是之前帧的内容,这就是界面卡顿的原因。不难发现,无论是 CPU 还是 GPU 引起错过 V-Sync 信号,都会造成界面卡顿。

如何监控卡顿

那怎么监控应用的卡顿情况?通常有以下两种方案

  • FPS 监控:这是最容易想到的一种方案,如果帧率越高意味着界面越流畅,上文也给出了计算 FPS 的实现方式,通过一段连续的 FPS 计算丢帧率来衡量当前页面绘制的质量。
  • 主线程卡顿监控:这是业内常用的一种检测卡顿的方法,通过开辟一个子线程来监控主线程的 RunLoop,当两个状态区域之间的耗时大于阈值时,就记为发生一次卡顿。美团的移动端性能监控方案 Hertz 采用的就是这种方式

FPS 的刷新频率非常快,并且容易发生抖动,因此直接通过比较 FPS 来侦测卡顿是比较困难的;此外,主线程卡顿监控也会发生抖动,所以微信读书团队给出一种综合方案,结合主线程监控、FPS 监控,以及 CPU 使用率等指标,作为判断卡顿的标准。Bugly 的卡顿检测也是基于这套标准。

当监控到应用出现卡顿,如何定位造成卡顿的原因呢?试想如果我们能够在发生卡顿的时候,保存应用的上下文,即卡顿发生时程序的堆栈调用和运行日志,那么就能凭借这些信息更加高效地定位到造成卡顿问题的来源。下图是 Hertz 监控卡顿的流程图

主线程卡顿监控的实现思路:开辟一个子线程,然后实时计算 kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 两个状态区域之间的耗时是否超过某个阀值,来断定主线程的卡顿情况,可以将这个过程想象成操场上跑圈的运动员,我们会每隔一段时间间隔去判断是否跑了一圈,如果发现在指定时间间隔没有跑完一圈,则认为在消息处理的过程中耗时太多,视为主线程卡顿。

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    MyClass *object = (__bridge MyClass*)info;
    
    // 记录状态值
    object->activity = activity;
    
    // 发送信号
    dispatch_semaphore_t semaphore = moniotr->semaphore;
    dispatch_semaphore_signal(semaphore);
}

- (void)registerObserver
{
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            0,
                                                            &runLoopObserverCallBack,
                                                            &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
    
    // 创建信号
    semaphore = dispatch_semaphore_create(0);
    
    // 在子线程监控时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        while (YES)
        {
            // 假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms)
            long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
            if (st != 0)
            {
                if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
                {
                    if (++timeoutCount < 5)
                        continue;
                    // 检测到卡顿,进行卡顿上报
                }
            }
            timeoutCount = 0;
        }
    });
}                                                 

代码中使用 timeoutCount 变量来覆盖多次连续的小卡顿,当累计次数超过5次,也会进入到卡顿逻辑。

当检测到了卡顿,下一步需要做的就是记录卡顿的现场,即此时程序的堆栈调用,可以借助开源库 PLCrashReporter 来实现,示例代码:

PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD
                                                                   symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config];
NSData *data = [crashReporter generateLiveReport];
PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL];
NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter
                                                          withTextFormat:PLCrashReportTextFormatiOS];                                                

Network

国内移动网络环境非常复杂,WIFI、4G、3G、2.5G(Edge)、2G 等多种移动网络并存,用户的网络可能会在 WIFI/4G/3G/2.5G/2G 类型之间切换,这是移动网络和传统网络一个很大的区别,被称作是 Connection Migration 问题。此外,还存在国内运营商网络的 DNS 解析慢、失败率高、DNS 被劫持的问题;还有国内运营商互联和海外访问国内带宽低传输慢等问题。这些网络问题令人非常头疼。移动网络的现状造成了用户在使用过程中经常会遇到各种网络问题,网络问题将直接导致用户无法在 App 进行操作,当一些关键的业务接口出现错误时,甚至会直接导致用户的大量流失。网络问题不仅给移动开发带来了巨大的挑战,同时也给网络监控带来了全新的机遇。以往要解决这些问题,只能靠经验和猜想,而如果能站在 App 的视角对网络进行监控,就能更有针对性地了解产生问题的根源。

网络监控一般通过 NSURLProtocol 和代码注入(Hook)这两种方式来实现,由于 NSURLProtocol 作为上层接口,使用起来更为方便,因此很自然选择它作为网络监控的方案,但是 NSURLProtocol 属于 URL Loading System 体系中,应用层的协议支持有限,只支持 FTPHTTPHTTPS 等几个应用层协议,对于使用其他协议的流量则束手无策,所以存在一定的局限性。监控底层网络库 CFNetwork 则没有这个限制。

下面是网络采集的关键性能指标:

  • TCP 建立连接时间
  • DNS 时间
  • SSL 时间
  • 首包时间
  • 响应时间
  • HTTP 错误率
  • 网络错误率
  • 流量

NSURLProtocol

//为了避免 canInitWithRequest 和 canonicalRequestForRequest 出现死循环
static NSString * const HJHTTPHandledIdentifier = @"hujiang_http_handled";

@interface HJURLProtocol () <NSURLSessionTaskDelegate, NSURLSessionDataDelegate>

@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, strong) NSOperationQueue     *sessionDelegateQueue;
@property (nonatomic, strong) NSURLResponse        *response;
@property (nonatomic, strong) NSMutableData        *data;
@property (nonatomic, strong) NSDate               *startDate;
@property (nonatomic, strong) HJHTTPModel          *httpModel;

@end

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    if (![request.URL.scheme isEqualToString:@"http"] &&
        ![request.URL.scheme isEqualToString:@"https"]) {
        return NO;
    }
    
    if ([NSURLProtocol propertyForKey:HJHTTPHandledIdentifier inRequest:request] ) {
        return NO;
    }
    return YES;
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    
    NSMutableURLRequest *mutableReqeust = [request mutableCopy];
    [NSURLProtocol setProperty:@YES
                        forKey:HJHTTPHandledIdentifier
                     inRequest:mutableReqeust];
    return [mutableReqeust copy];
}

- (void)startLoading {
    self.startDate                                        = [NSDate date];
    self.data                                             = [NSMutableData data];
    NSURLSessionConfiguration *configuration              = [NSURLSessionConfiguration defaultSessionConfiguration];
    self.sessionDelegateQueue                             = [[NSOperationQueue alloc] init];
    self.sessionDelegateQueue.maxConcurrentOperationCount = 1;
    self.sessionDelegateQueue.name                        = @"com.hujiang.wedjat.session.queue";
    NSURLSession *session                                 = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:self.sessionDelegateQueue];
    self.dataTask                                         = [session dataTaskWithRequest:self.request];
    [self.dataTask resume];

    httpModel                                             = [[NEHTTPModel alloc] init];
    httpModel.request                                     = self.request;
    httpModel.startDateString                             = [self stringWithDate:[NSDate date]];

    NSTimeInterval myID                                   = [[NSDate date] timeIntervalSince1970];
    double randomNum                                      = ((double)(arc4random() % 100))/10000;
    httpModel.myID                                        = myID+randomNum;
}

- (void)stopLoading {
    [self.dataTask cancel];
    self.dataTask           = nil;
    httpModel.response      = (NSHTTPURLResponse *)self.response;
    httpModel.endDateString = [self stringWithDate:[NSDate date]];
    NSString *mimeType      = self.response.MIMEType;
    
    // 解析 response,流量统计等
}

#pragma mark - NSURLSessionTaskDelegate

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    if (!error) {
        [self.client URLProtocolDidFinishLoading:self];
    } else if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) {
    } else {
        [self.client URLProtocol:self didFailWithError:error];
    }
    self.dataTask = nil;
}

#pragma mark - NSURLSessionDataDelegate

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveData:(NSData *)data {
    [self.client URLProtocol:self didLoadData:data];
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
    completionHandler(NSURLSessionResponseAllow);
    self.response = response;
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler {
    if (response != nil){
        self.response = response;
        [[self client] URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
    }
}

Hertz 使用的是 NSURLProtocol 这种方式,通过继承 NSURLProtocol,实现 NSURLConnectionDelegate 来实现截取行为。

Hook

如果我们使用手工埋点的方式来监控网络,会侵入到业务代码,维护成本会非常高。通过 Hook 将网络性能监控的代码自动注入就可以避免上面的问题,做到真实用户体验监控(RUM: Real User Monitoring),监控应用在真实网络环境中的性能。

AOP(Aspect Oriented Programming,面向切面编程),是通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态添加功能的一种技术。其核心思想是将业务逻辑(核心关注点,系统的主要功能)与公共功能(横切关注点,如日志、事物等)进行分离,降低复杂性,提高软件系统模块化、可维护性和可重用性。其中核心关注点采用 OOP 方式进行代码的编写,横切关注点采用 AOP 方式进行编码,最后将这两种代码进行组合形成系统。AOP 被广泛应用在日志记录,性能统计,安全控制,事务处理,异常处理等领域。

在 iOS 中 AOP 的实现是基于 Objective-CRuntime 机制,实现 Hook 的三种方式分别为:Method SwizzlingNSProxyFishhook。前两者适用于 Objective-C 实现的库,如 NSURLConnectionNSURLSessionFishhook 则适用于 C 语言实现的库,如 CFNetwork

下图是阿里百川码力监控给出的三类网络接口需要 hook 的方法

接下来分别来讨论这三种实现方式:

Method Swizzling

Method swizzling 是利用 Objective-C Runtime 特性把一个方法的实现与另一个方法的实现进行替换的技术。每个 Class 结构体中都有一个 Dispatch Table 的成员变量,Dispatch Table 中建立了每个 SEL(方法名)和对应的 IMP(方法实现,指向 C 函数的指针)的映射关系,Method Swizzling 就是将原有的 SELIMP映射关系打破,并建立新的关联来达到方法替换的目的。

因此利用 Method swizzling 可以替换原始实现,在替换的实现中加入网络性能埋点行为,然后调用原始实现。

NSProxy

NSProxy is an abstract superclass defining an API for objects that act as stand-ins for other objects or for objects that don’t exist yet. Typically, a message to a proxy is forwarded to the real object or causes the proxy to load (or transform itself into) the real object. Subclasses of NSProxy can be used to implement transparent distributed messaging (for example, NSDistantObject) or for lazy instantiation of objects that are expensive to create.

这是 Apple 官方文档给 NSProxy 的定义,NSProxyNSObject 一样都是根类,它是一个抽象类,你可以通过继承它,并重写 -forwardInvocation:-methodSignatureForSelector: 方法以实现消息转发到另一个实例。综上,NSProxy 的目的就是负责将消息转发到真正的 target 的代理类。

Method swizzling 替换方法需要指定类名,但是 NSURLConnectionDelegateNSURLSessionDelegate 是由业务方指定,通常来说是不确定,所以这种场景不适合使用 Method swizzling。使用 NSProxy 可以解决上面的问题,具体实现:proxy delegate 替换 NSURLConnectionNSURLSession 原来的 delegate,当 proxy delegate 收到回调时,如果是要 hook 的方法,则调用 proxy 的实现,proxy 的实现最后会调用原来的 delegate;如果不是要 hook 的方法,则通过消息转发机制将消息转发给原来的 delegate。下图示意了整个操作流程。

Fishhook

fishhook 是一个由 Facebook 开源的第三方框架,其主要作用就是动态修改 C 语言的函数实现,我们可以使用 fishhook 来替换动态链接库中的 C 函数实现,具体来说就是去替换 CFNetworkCoreFoundation 中的相关函数。后面会在讲监控 CFNetwork 详细说明,这里不再赘述。

讲解完 iOS 上 hook 的实现技术,接下来讨论在 NSURLConnectionNSURLSessionCFNetwork 中,如何将上面的三种技术应用到实践中。

NSURLConnection

NSURLSession

CFNetwork

概述

NeteaseAPM 作为案例来讲解如何通过 CFNetwork 实现网络监控,它是通过使用代理模式来实现的,具体来说,是在 CoreFoundation Framework 的 CFStream 实现一个 Proxy Stream 从而达到拦截的目的,记录通过 CFStream 读取的网络数据长度,然后再转发给 Original Stream,流程图如下:

详细描述

由于 CFNetwork 都是 C 函数实现,想要对 C 函数 进行 Hook 需要使用 Dynamic Loader Hook 库函数 - fishhook

Dynamic Loader(dyld)通过更新 Mach-O 文件中保存的指针的方法来绑定符号。借用它可以在 Runtime 修改 C 函数调用的函数指针。fishhook 的实现原理:遍历 __DATA segment 里面 __nl_symbol_ptr__la_symbol_ptr 两个 section 里面的符号,通过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到自己要替换的函数,达到 hook 的目的。

CFNetwork 使用 CFReadStreamRef 做数据传递,使用回调函数来接收服务器响应。当回调函数收到流中有数据的通知后,将数据保存到客户端的内存中。显然对流的读取不适合使用修改字符串表的方式,如果这样做的话也会 hook 系统也在使用的 read 函数,而系统的 read 函数不仅仅被网络请求的 stream 调用,还有所有的文件处理,而且 hook 频繁调用的函数也是不可取的。

使用上述方式的缺点就是无法做到选择性的监控和 HTTP 相关的 CFReadStream,而不涉及来自文件和内存的 CFReadStreamNeteaseAPM 的解决方案是在系统构造 HTTP Stream 时,将一个 NSInputStream 的子类 ProxyStream 桥接为 CFReadStream 返回给用户,来达到单独监控 HTTP Stream 的目的。

具体的实现思路就是:首先设计一个继承自 NSObject 并持有 NSInputStream 对象的 Proxy 类,持有的 NSInputStream 记为 OriginalStream。将所有发向 Proxy 的消息转发给 OriginalStream 处理,然后再重写 NSInputStreamread:maxLength: 方法,如此一来,我们就可以获取到 stream 的大小了。 XXInputStreamProxy 类的代码如下:

- (instancetype)initWithStream:(id)stream {
    if (self = [super init]) {
        _stream = stream;
    }
    return self;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    return [_stream methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    [anInvocation invokeWithTarget:_stream];
}
                                                        

继承 NSInputStream 并重写 read:maxLength: 方法:

- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len {
    NSInteger readSize = [_stream read:buffer maxLength:len];
    // 记录 readSize
    return readSize;
}                                                   

XX_CFReadStreamCreateForHTTPRequest 会被用来替换系统的 CFReadStreamCreateForHTTPRequest 方法

static CFReadStreamRef (*original_CFReadStreamCreateForHTTPRequest)(CFAllocatorRef __nullable alloc,
                                                                    CFHTTPMessageRef request);
                         
/**
 XXInputStreamProxy 持有 original CFReadStreamRef,转发消息到 original CFReadStreamRef,
 在 read 方法中记录获取数据的大小
 */
static CFReadStreamRef XX_CFReadStreamCreateForHTTPRequest(CFAllocatorRef alloc,
                                                           CFHTTPMessageRef request) {
    // 使用系统方法的函数指针完成系统的实现
    CFReadStreamRef originalCFStream = original_CFReadStreamCreateForHTTPRequest(alloc, request);
    // 将 CFReadStreamRef 转换成 NSInputStream,并保存在 XXInputStreamProxy,最后返回的时候再转回 CFReadStreamRef
    NSInputStream *stream = (__bridge NSInputStream *)originalCFStream;
    XXInputStreamProxy *outStream = [[XXInputStreamProxy alloc] initWithClient:stream];
    CFRelease(originalCFStream);
    CFReadStreamRef result = (__bridge_retained CFReadStreamRef)outStream;
    return result;
}                                                             
                                                        

使用 fishhook 替换函数地址

void save_original_symbols() {
    original_CFReadStreamCreateForHTTPRequest = dlsym(RTLD_DEFAULT, "CFReadStreamCreateForHTTPRequest");
}                                                      
rebind_symbols((struct rebinding[1]){{"CFReadStreamCreateForHTTPRequest", XX_CFReadStreamCreateForHTTPRequest, (void *)& original_CFReadStreamCreateForHTTPRequest}}, 1);                                                    

根据 CFNetwork API 的调用方式,使用 fishhook 和 Proxy Stream 获取 C 函数的设计模型如下:

NSURLSessionTaskMetrics/NSURLSessionTaskTransactionMetrics

Apple 在 iOS 10 的 NSURLSessionTaskDelegate 代理中新增了 -URLSession: task:didFinishCollectingMetrics: 方法,如果实现这个代理方法,就可以通过该回调的 NSURLSessionTaskMetrics 类型参数获取到采集的网络指标,实现对网络请求中 DNS 查询/TCP 建立连接/TLS 握手/请求响应等各环节时间的统计。

/*
 * Sent when complete statistics information has been collected for the task.
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

NSURLSessionTaskMetrics

NSURLSessionTaskMetrics 对象封装了 session task 的指标,每个 NSURLSessionTaskMetrics 对象有 taskIntervalredirectCount 属性,还有在执行任务时产生的每个请求/响应事务中收集的指标。

  • transactionMetrics:transactionMetrics 数组包含了在执行任务时产生的每个请求/响应事务中收集的指标。

     /*
      * transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution.
      */
     @property (copy, readonly) NSArray<NSURLSessionTaskTransactionMetrics *> *transactionMetrics;
  • taskInterval:任务从创建到完成花费的总时间,任务的创建时间是任务被实例化时的时间;任务完成时间是任务的内部状态将要变为完成的时间。

     /*
      * Interval from the task creation time to the task completion time.
      * Task creation time is the time when the task was instantiated.
      * Task completion time is the time when the task is about to change its internal state to completed.
      */
     @property (copy, readonly) NSDateInterval *taskInterval;
  • redirectCount:记录了被重定向的次数。

     /*
      * redirectCount is the number of redirects that were recorded.
      */
     @property (assign, readonly) NSUInteger redirectCount;

NSURLSessionTaskTransactionMetrics

NSURLSessionTaskTransactionMetrics 对象封装了任务执行时收集的性能指标,包括了 requestresponse 属性,对应 HTTP 的请求和响应,还包括了从 fetchStartDate 开始,到 responseEndDate 结束之间的指标,当然还有 networkProtocolNameresourceFetchType 属性。

  • request:表示了网络请求对象。

     /*
      * Represents the transaction request.
      */
     @property (copy, readonly) NSURLRequest *request;
  • response:表示了网络响应对象,如果网络出错或没有响应时,responsenil

     /*
      * Represents the transaction response. Can be nil if error occurred and no response was generated.
      */
     @property (nullable, copy, readonly) NSURLResponse *response;
  • networkProtocolName:获取资源时使用的网络协议,由 ALPN 协商后标识的协议,比如 h2, http/1.1, spdy/3.1。

     @property (nullable, copy, readonly) NSString *networkProtocolName;
  • isProxyConnection:是否使用代理进行网络连接。

     /*
      * This property is set to YES if a proxy connection was used to fetch the resource.
      */
     @property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection;
  • isReusedConnection:是否复用已有连接。

     /*
      * This property is set to YES if a persistent connection was used to fetch the resource.
      */
     @property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection;
  • resourceFetchType:NSURLSessionTaskMetricsResourceFetchType 枚举类型,标识资源是通过网络加载,服务器推送还是本地缓存获取的。

     /*
      * Indicates whether the resource was loaded, pushed or retrieved from the local cache.
      */
     @property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType;

对于下面所有 NSDate 类型指标,如果任务没有完成,所有相应的 EndDate 指标都将为 nil。例如,如果 DNS 解析超时、失败或者客户端在解析成功之前取消,domainLookupStartDate 会有对应的数据,然而 domainLookupEndDate 以及在它之后的所有指标都为 nil

这幅图示意了一次 HTTP 请求在各环节分别做了哪些工作

如果是复用已有的连接或者从本地缓存中获取资源,下面的指标都会被赋值为 nil

  • domainLookupStartDate

  • domainLookupEndDate

  • connectStartDate

  • connectEndDate

  • secureConnectionStartDate

  • secureConnectionEndDate

  • fetchStartDate:客户端开始请求的时间,无论资源是从服务器还是本地缓存中获取。

     @property (nullable, copy, readonly) NSDate *fetchStartDate;
  • domainLookupStartDate:DNS 解析开始时间,Domain -> IP 地址。

     /*
      * domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource.
      */
     @property (nullable, copy, readonly) NSDate *domainLookupStartDate;
  • domainLookupEndDate:DNS 解析完成时间,客户端已经获取到域名对应的 IP 地址。

     /*
      * domainLookupEndDate returns the time after the name lookup was completed.
      */
     @property (nullable, copy, readonly) NSDate *domainLookupEndDate;
  • connectStartDate:客户端与服务器开始建立 TCP 连接的时间。

     /*
      * connectStartDate is the time immediately before the user agent started establishing the connection to the server.
      *
      * For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection.
      */
     @property (nullable, copy, readonly) NSDate *connectStartDate;
    • secureConnectionStartDate :HTTPS 的 TLS 握手开始时间。

       /*
        * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection.
        *
        * For example, this would correspond to the time immediately before the user agent started the TLS handshake.
        *
        * If an encrypted connection was not used, this attribute is set to nil.
        */
       @property (nullable, copy, readonly) NSDate *secureConnectionStartDate;
    • secureConnectionEndDate:HTTPS 的 TLS 握手结束时间。

       /*
        * If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed.
        *
        * If an encrypted connection was not used, this attribute is set to nil.
        */
       @property (nullable, copy, readonly) NSDate *secureConnectionEndDate;
  • connectEndDate:客户端与服务器建立 TCP 连接完成时间,包括 TLS 握手时间。

     /*
      * connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes.
      */
     @property (nullable, copy, readonly) NSDate *connectEndDate;
  • requestStartDate :开始传输 HTTP 请求的 header 第一个字节的时间。

     /*
      * requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources.
      *
      * For example, this would correspond to the time immediately before the user agent sent an HTTP GET request.
      */
     @property (nullable, copy, readonly) NSDate *requestStartDate;
  • requestEndDate :HTTP 请求最后一个字节传输完成的时间。

     /*
      * requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources.
      *
      * For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request.
      */
     @property (nullable, copy, readonly) NSDate *requestEndDate;
  • responseStartDate:客户端从服务器接收到响应的第一个字节的时间。

     /*
      * responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources.
      *
      * For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response.
      */
     @property (nullable, copy, readonly) NSDate *responseStartDate;
  • responseEndDate:客户端从服务器接收到最后一个字节的时间。

     /*
      * responseEndDate is the time immediately after the user agent received the last byte of the resource.
      */
     @property (nullable, copy, readonly) NSDate *responseEndDate;

Traffic

在网络 APM 中的流量指标往往也是用户比较关心的,而通过以上技术手段我们也很容易获取流量数据,主要分上行流量和下行流量这两个维度来聊聊是如何实现的。

上行流量

上行流量主要可以从 HTTP 协议的请求报文入手,我们知道 HTTP 报文是由多行(用 CR + LF 做换行符)数据构成的字符串文本。具体来说,一个请求报文是由报文首部和报文主体组成,报文首部和报文主体之间会有一个空行,报文主体是可选的,其中报文首部又可以分为请求行和首部字段。

POST /q HTTP/1.1
Host: get.sogou.com
Content-Type: application/octet-stream
Connection: keep-alive
Accept: */*
User-Agent: SogouServices (unknown version) CFNetwork/811.5.4 Darwin/16.7.0 (x86_64)
Content-Length: 854
Accept-Language: zh-cn
Accept-Encoding: gzip, deflate

u=pJcIsbkG1m3U+6MUE4njAru+inspyRuhE6TjK4eHiygLzcF9T84jjnLwJt4ZYhqdvoYRTmeMUgo4yiaDnuEn/w==&g=HqdloJ9a3HetZyhU87uuHeYXnFxb9z0PynKAvmO/s0iG3NiGKanztmA8ZLv82ILg1aZUFJwwvmfouA+DT2cYtg==&p=lf+gqqMorInY9pCBwEd+Ecy6akrYsRRLUaoToCFhmqlO5lsE9Am672UbGU9HanDWj44qFi+AMx+hnuWpMnZhe4xLTyItF8WH15TXYZ2+53t7VfzG/ORosrHkcU2vyMm2Z1lWiGnMZL9pDXqYaHDniE7fiDq0F3qfNvbOTPPNStroqv2UZPJWcX3ZCK5axd1/yBYq5Dhj8JREkO8MeO/qZNV/YY6mA7paq6nTnKKsJJQnSs7wpOCXosQKHOYtidziDmzf5vs+2e8vAGGOZmzlDlwyiaRFocYjPZ0Nxks9VQVCK67UKDNrZeU7xrebocq6&k=I1D5ztcMZZq/x3oJV9X43CxNhLfXXkln/ytlXa5LxKo1iFMjZtiuqbTDNA+jP4rCvlRdxdRwgvqpi6PvvZywfLS+KqGsik9csxxLgatJHkPhFXmGRQZlrl9vm8e2foTH7BmzUb/vhH/Y4s3FgBQylGj9l/A3/8VOuPFArCC2wzA=&v=ua/DxZwfrnDzy5Oo79+dblQ66uuUP3NmBKo7HbJwQIrvnlqw/lUcE54w310UB0VXpa6A8qZFJCeAa4vmAJRJaqkJfUkhX6z/kEE/kybSlqZaYl+KETwymNgVDvbf6On7tsGWU1HcxGZo/UN2aEnV3tWAAfhKGC3RliQfiwsTSFo=

在 iOS 中 NSURLRequestallHTTPHeaderFields 属性就对应上文提到的首部字段,HTTPBody 则对应报文主体,所以很自然想到将两者大小相加即可得到请求报文的大小。但是细心的读者会发现 allHTTPHeaderFields 并不包含在实际请求的全部首部字段,比如 Cookie,但 Cookie 是造成首部膨胀的罪魁祸首,如果我们不把计算在内,很显然最后得到的结果不会很精确,当然事实上,我们还没有计算请求行。 因为上面的原因,我曾异想天开的想获取到完整和原始的请求报文格式,试想如果我能拿到原始的报文,再计算其大小,那这个值应该是最精确的,于是我意图从 CFNetwork 这个 framework 发现一些蛛丝马迹,但是最终发现这条路太艰难了,不过在研究的过程中,还是有一些收获。在 CFNetwork 中 HTTP 的报文是用 HTTPMessage 这个 C++ 类来表示的,在构建请求报文的时候会调用下列函数。

int HTTPMessage::copySerializedMessage()() {
    edi = arg_0;
    esi = HTTPMessage::copySerializedHeaders(edi);
    ebx = 0x0;
    if (esi != 0x0) {
            eax = *(edi + 0x18);
            if (eax != 0x0) {
                    ebx = HTTPBodyData::getLength();
                    var_14 = ebx;
                    ebx = CFDataCreateMutableCopy(CFGetAllocator(edi + 0xfffffff8), CFDataGetLength(esi) + ebx, esi);
                    CFRelease(esi);
                    eax = HTTPBodyData::getBytePtr();
                    CFDataAppendBytes(ebx, eax, var_14);
            }
            else {
                    ebx = esi;
            }
    }
    eax = ebx;
    return eax;
}

可以观察到函数会先调用 HTTPMessage::copySerializedHeaders,这个函数就是去构建首部,包括将请求行和首部字段拼接,首部字段的序列化通过 HTTPHeaderDict::serializeHeaders 函数实现,之后调用 HTTPBodyData 去构建请求体。但是这些都没有对上层暴露接口,所以最终放弃了这个念头。

于是只能从上层接口来计算请求报文的大小,思路还是与之前一样,只不过我们拿到 allHTTPHeaderFields 之后,会去调用 -[NSHTTPCookieStorage cookiesForURL:] 方法获得对应 URL 的 Cookie 信息,然后调用 -[NSHTTPCookie requestHeaderFieldsWithCookies:cookies] 以首部字段形式返回,最后将 Cookie 的首部字段加入 allHTTPHeaderFields 中,具体代码如下:

- (NSUInteger)p_getRequestLength {
    NSDictionary<NSString *, NSString *> *headerFields = _request.allHTTPHeaderFields;
    NSDictionary<NSString *, NSString *> *cookiesHeader = [self p_getCookies];
    if (cookiesHeader.count) {
        NSMutableDictionary *headerFieldsWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields];
        [headerFieldsWithCookies addEntriesFromDictionary:cookiesHeader];
        headerFields = [headerFieldsWithCookies copy];
    }
    
    NSUInteger headersLength = [self p_getHeadersLength:headerFields];
    NSUInteger bodyLength = [_request.HTTPBody length];
    return headersLength + bodyLength;
}

- (NSUInteger)p_getHeadersLength:(NSDictionary *)headers {
    NSUInteger headersLength = 0;
    if (headers) {
        NSData *data = [NSJSONSerialization dataWithJSONObject:headers
                                                       options:NSJSONWritingPrettyPrinted
                                                         error:nil];
        headersLength = data.length;
    }
    
    return headersLength;
}

- (NSDictionary<NSString *, NSString *> *)p_getCookies {
    NSDictionary<NSString *, NSString *> *cookiesHeader;
    NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    NSArray<NSHTTPCookie *> *cookies = [cookieStorage cookiesForURL:_request.URL];
    if (cookies.count) {
         cookiesHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
    }
    return cookiesHeader;
}

下行流量

下行流量的思路也类似,主要计算每个响应报文的大小,包含报文首部和报文主体,两者之间有个空行,报文首部包含状态行和首部字段。实现上会用到 NSHTTPURLResponseallHeaderFieldsexpectedContentLength 属性。但这里需要注意的是 expectedContentLength 属性可能会为 NSURLResponseUnknownLength(-1),主要是在有些请求的响应的首部字段中没有 Content-Length 字段,或者没有告知具体响应大小时出现。那么这个时候需要通过其他的机制去计算。

- (int64_t)p_getResponseLength {
    int64_t responseLength = 0;
    if (_response && [_response isKindOfClass:[NSHTTPURLResponse class]]) {
        NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)_response;
        NSDictionary<NSString *, NSString *> *headerFields = httpResponse.allHeaderFields;
        NSUInteger headersLength = [self p_getHeadersLength:headerFields];
        int64_t contentLength = (httpResponse.expectedContentLength != NSURLResponseUnknownLength) ?
        httpResponse.expectedContentLength :
        _dataLength;
        responseLength = headersLength + contentLength;
    }
    return responseLength;
}

在上面代码中会去判断 expectedContentLength 是否为 NSURLResponseUnknownLength,如果不是响应报文主体的大小就是 expectedContentLength,否则将其赋值为 _dataLength_dataLength 的计算可以在响应的回调中去计算,比如下面代码罗列的这几个地方。

- (void)wtn_URLSession:(NSURLSession *)session
              dataTask:(NSURLSessionDataTask *)dataTask
        didReceiveData:(NSData *)data {
    WTNHTTPTransactionMetrics *httpTransaction = dataTask.httpTransaction;
    httpTransaction.dataLength += data.length;
    
    if ([self.originalDelegate respondsToSelector:@selector(URLSession:dataTask:didReceiveData:)]) {
        [(id)self.originalDelegate URLSession:session dataTask:dataTask didReceiveData:data];
    }
}


- (NSURLSessionDataTask *)wtn_dataTaskWithRequest:(NSURLRequest *)request
                                completionHandler:(void (^)(NSData * _Nullable data,
                                                            NSURLResponse * _Nullable response,
                                                            NSError * _Nullable error))completionHandler {
    WTNHTTPTransactionMetrics *httpTransaction = [WTNHTTPTransactionMetrics new];
    
    ······

    if (completionHandler) {
        wrappedCompletionHandler = ^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
            httpTransaction.dataLength = data.length;
        };
    }
    
    ······

    return dataTask;
}

wtn_URLSession:dataTask:didReceiveData 是 hook 之后的回调函数,因为在大文件中这个回调会执行多次,所以这里使用 +=wtn_dataTaskWithRequest:completionHandler: 也是 hook 函数,类似的还有 wtn_uploadTaskWithRequest:fromData:completionHandler:wtn_uploadTaskWithRequest:fromFile:completionHandler 等。

Power consumption

iOS 设备的电量一直是用户非常关心的问题。如果你的应用由于某些缺陷不幸成为电量杀手,用户会毫不犹豫的卸载你的应用,所以耗电也是 App 性能的重要衡量标准之一。然而事实上业内对耗电量的监控的方案都做的不太好,下面会介绍和对比业内已有的耗电量的监控方案。

电量获取三种方案对比如下:

方案 优点 缺点
UIDevice 属性 API 简单,易于使用 粗粒度,不符合需求
IOKit 可以设备当前的电流和电压 粒度较粗,无法到应用级别
越狱 可以获取应用每小时耗电量 时间间隔太长,不符合需求

UIDevice

UIDevice 提供了获取设备电池的相关信息,包括当前电池的状态以及电量。获取电池信息之前需要先将 batteryMonitoringEnabled 属性设置为 YES,然后就可以通过 batteryStatebatteryLevel 获取电池信息。

  • 是否开启电池监控,默认为 NO

     // default is NO             
     @property(nonatomic,getter=isBatteryMonitoringEnabled) BOOL batteryMonitoringEnabled NS_AVAILABLE_IOS(3_0) __TVOS_PROHIBITED;                
  • 电池电量,取值 0-1.0,如果 batteryStateUIDeviceBatteryStateUnknown,则电量是 -1.0

     // 0 .. 1.0. -1.0 if UIDeviceBatteryStateUnknown
     @property(nonatomic,readonly) float batteryLevel NS_AVAILABLE_IOS(3_0) __TVOS_PROHIBITED; 
  • 电池状态,为 UIDeviceBatteryState 枚举类型,总共有四种状态

     // UIDeviceBatteryStateUnknown if monitoring disabled
     @property(nonatomic,readonly) UIDeviceBatteryState batteryState NS_AVAILABLE_IOS(3_0) __TVOS_PROHIBITED;  
     
     typedef NS_ENUM(NSInteger, UIDeviceBatteryState) {
         UIDeviceBatteryStateUnknown,
         UIDeviceBatteryStateUnplugged,   // on battery, discharging
         UIDeviceBatteryStateCharging,    // plugged in, less than 100%
         UIDeviceBatteryStateFull,        // plugged in, at 100%
     } __TVOS_PROHIBITED;              // available in iPhone 3.0

获取电量代码

  [UIDevice currentDevice].batteryMonitoringEnabled = YES;
  [[NSNotificationCenter defaultCenter]
 addObserverForName:UIDeviceBatteryLevelDidChangeNotification
 object:nil queue:[NSOperationQueue mainQueue]
 usingBlock:^(NSNotification *notification) {
     // Level has changed
     NSLog(@"Battery Level Change");
     NSLog(@"电池电量:%.2f", [UIDevice currentDevice].batteryLevel);
 }];                         

使用 UIDevice 可以非常方便获取到电量,经测试发现,在 iOS 8.0 之前,batteryLevel 只能精确到5%,而在 iOS 8.0 之后,精确度可以达到1%,但这种方案获取到的数据不是很精确,没办法应用到生产环境。

IOKit

IOKit 是 iOS 系统的一个私有框架,它可以被用来获取硬件和设备的详细信息,也是与硬件和内核服务通信的底层框架。通过它可以获取设备电量信息,精确度达到1%。

- (double)getBatteryLevel {
    // returns a blob of power source information in an opaque CFTypeRef
    CFTypeRef blob = IOPSCopyPowerSourcesInfo();
    // returns a CFArray of power source handles, each of type CFTypeRef
    CFArrayRef sources = IOPSCopyPowerSourcesList(blob);
    CFDictionaryRef pSource = NULL;
    const void *psValue;
    // returns the number of values currently in an array
    int numOfSources = CFArrayGetCount(sources);
    // error in CFArrayGetCount
    if (numOfSources == 0) {
        NSLog(@"Error in CFArrayGetCount");
        return -1.0f;
    }

    // calculating the remaining energy
    for (int i=0; i<numOfSources; i++) {
        // returns a CFDictionary with readable information about the specific power source
        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.0f;
}                                        

越狱方案

这种方案需要链接 iOSDiagnosticsSupport 私有库,然后通过 Runtime 拿到 MBSDevice 实例,调用 copyPowerLogsToDir: 方法将电量日志信息表(PLBLMAccountingService_Aggregate_BLMAppEnergyBreakdown)拷贝到硬盘的指定路径,日志信息表中包含了 iOS 系统采集的小时级别的耗电量。具体实现方案可以参考 iOS-Diagnostics

从电量日志表中查询的 SQL 语句如下:

SELECT datetime(timestamp, 'unixepoch') AS TIME, BLMAppName FROM PLBLMAccountingService_Aggregate_BLMAppEnergyBreakdown WHERE BLMEnergy_BackgroundLocation > 0  ORDER BY TIME

发现 iOSDiagnosticsSupport Framework 在 iOS 10 之后名字已经被改成 DiagnosticsSupport,而且 MBSDevice 类也被隐藏了。

Author

Twitter: @aozhimin

Email: aozhimin0811@gmail.com

参考资料