APM基础小记

天之道,损有余而补不足

原文链接

一、概述
1、APM是什么
  • 我们平时关注更多的是:需求是否delay,线上bug有多少?每个周期(比如2-3周) 关注下App的DAU、DNU、这些产品指标;但是团队中需要有人去关注App的技术质量指标:如Crash率、启动时间、安装包大小、核心页面的FPS、CPU使用率、内存占用、电量使用、卡顿情况等。
  • 关注App线上质量,从技术维度来判断App是否健康。不健康的App表现为启动时间慢、页面卡顿、耗电量大等,这些App最终会失去用户;
  • APM (Application Performance Manage)旨在建立APP的质量监控接入框架,方便App能快速集成,对性能监控项的异常数据进行采集和分析,输出相应问题的分析、定位与优化建议,从而帮助开发者开发出更高质量的应用。
2、APM工具
  • 微信最近开源了微信的APM工具Matrix, 提供了针对iOS、Android和macOS系统的性能监控方案。这个方案很全面,可以直接接入App,当然也可以吸收其优秀的技术细节,优化自己的APM工具。
  • 本文不是介绍如何定制一个APM工具,而是介绍在APM监控中,比较重要的几个监控维度:CPU使用率、内存使用、FPS和卡顿监控
二、CPU使用率监控
1、Task和CPU
  • 任务(Task)是一种容器(Container)对象;虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。
  • 严格地说,Mach 的任务并不是其他操作系统中所谓的进程,因为 Mach 作为一个微内核的操作系统,并没有提供“进程”的逻辑,而只是提供了最基本的实现。不过在 BSD 的模型中,这两个概念有1:1的简单映射,每一个 BSD 进程(也就是 OS X 进程)都在底层关联了一个 Mach 任务对象。
  • 而每App运行,会对应一个Mach Task,Task下可能有多条线程同时执行任务,每个线程都是利用CPU的基本单位。要计算CPU 占用率,就需要获得当前Mach Task下,所有线程占用 CPU 的情况
2、Mach Task和线程列表
  • 一个Mach Task包含它的线程列表。内核提供了task_threads API 调用获取指定 task 的线程列表,然后可以通过thread_info API调用来查询指定线程的信息,
kern_return_t task_threads
(
    task_t target_task,
    thread_act_array_t *act_list,
    mach_msg_type_number_t *act_listCnt
);
复制代码

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

3、单个线程信息结构
  • iOS 的线程技术与Mac OS X类似,也是基于 Mach 线程技术实现的,可以通过thread_info这个API调用来查询指定线程的信息,thread_info结构如下:
kern_return_t thread_info
(
    thread_act_t target_act,
    thread_flavor_t flavor,  // 传入不同的宏定义获取不同的线程信息
    thread_info_t thread_info_out,  // 查询到的线程信息
    mach_msg_type_number_t *thread_info_outCnt  // 信息的大小
);
复制代码
  • 在 Mach 层中thread_basic_info 结构体封装了单个线程的基本信息:
struct thread_basic_info {
  time_value_t    user_time;     // 用户运行时长
  time_value_t    system_time;   // 系统运行时长
  integer_t       cpu_usage;     // CPU 使用率
  policy_t        policy;        // 调度策略
  integer_t       run_state;     // 运行状态
  integer_t       flags;         // 各种标记
  integer_t       suspend_count; // 暂停线程的计数
  integer_t       sleep_time;    // 休眠的时间
};
复制代码
4、CPU 占用率计算
  • 先获取当前task中的线程总数(threadCount)和所有线程数组(threadList)
  • 遍历这个数组来获取单个线程的基本信息。线程基本信息的结构是thread_basic_info_t,这里面有CPU的使用率(cpu_usage)字段,累计所有线程的CPU使用率就能获得整个APP的CPU使用率(cpuUsage)。
  • 需要注意的是:cpuUsage是一个整数,想要获得百分比形式,需要除以TH_USAGE_SCALE
/*
 *	Scale factor for usage field.
 */
#define TH_USAGE_SCALE	1000
复制代码
  • 可以定时,比如2s去计算一次CPU的使用率
+ (double)getCpuUsage {
    
    kern_return_t           kr;
    thread_array_t          threadList;         // 保存当前Mach task的线程列表
    mach_msg_type_number_t  threadCount;        // 保存当前Mach task的线程个数
    thread_info_data_t      threadInfo;         // 保存单个线程的信息列表
    mach_msg_type_number_t  threadInfoCount;    // 保存当前线程的信息列表大小
    thread_basic_info_t     threadBasicInfo;    // 线程的基本信息
    
    // 通过“task_threads”API调用获取指定 task 的线程列表
    //  mach_task_self_,表示获取当前的 Mach task
    kr = task_threads(mach_task_self(), &threadList, &threadCount);
    if (kr != KERN_SUCCESS) {
        return -1;
    }
    double cpuUsage = 0;
     // 遍历所有线程
    for (int i = 0; i < threadCount; i++) {
        threadInfoCount = THREAD_INFO_MAX;
        // 通过“thread_info”API调用来查询指定线程的信息
        //  flavor参数传的是THREAD_BASIC_INFO,使用这个类型会返回线程的基本信息,
        //  定义在 thread_basic_info_t 结构体,包含了用户和系统的运行时间、运行状态和调度优先级等
        kr = thread_info(threadList[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
        if (kr != KERN_SUCCESS) {
            return -1;
        }
        
        threadBasicInfo = (thread_basic_info_t)threadInfo;
        if (!(threadBasicInfo->flags & TH_FLAGS_IDLE)) {
            cpuUsage += threadBasicInfo->cpu_usage;
        }
    }
    
    // 回收内存,防止内存泄漏
    vm_deallocate(mach_task_self(), (vm_offset_t)threadList, threadCount * sizeof(thread_t));
    
    return cpuUsage / (double)TH_USAGE_SCALE * 100.0;
}
复制代码
4、为什么关注CPU使用率
  • CPU的使用率是对APP使用CPU情况的评估,App频繁操作,CPU使用率一般在40%-50%;
  • 假如CPU使用过高(>90%),可以认为CPU满负载,此种情况大概率发生卡顿,可以选择上报。
  • 一段时间内CPU的使用率一直超过某个阈值(80%),此种情况大概率发生卡顿,可以选择上报。
三、内存使用监控
1、内存
  • 内存是有限且系统共享的资源,一个App占用地多,系统和其他App所能用的就更少;减少内存占用能不仅仅让自己App,其他App,甚至是整个系统都表现得更好。
  • 关注App的内存使用情况十分重要
2、内存信息结构
  • Mach task 的内存使用信息存放在mach_task_basic_info结构体中 ,其中resident_size 为驻留内存大小,而phys_footprint表示实际使用的物理内存,iOS 9之后使用phys_footprint来统计App占用的内存大小(和Xcode和Instruments的值显示值接近)。
struct task_vm_info {
  mach_vm_size_t  virtual_size;       // 虚拟内存大小
  integer_t region_count;             // 内存区域的数量
  integer_t page_size;
  mach_vm_size_t  resident_size;      // 驻留内存大小
  mach_vm_size_t  resident_size_peak; // 驻留内存峰值

  ...

  /* added for rev1 */
  mach_vm_size_t  phys_footprint;     // 实际使用的物理内存

  ...
复制代码
3、内存信息获取
uint64_t qs_getAppMemoryBytes() {
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if (result != KERN_SUCCESS)
        return 0;
    return vmInfo.phys_footprint;
}
复制代码
4、为什么关注内存使用
  • 内存问题影响最大是OOM,即Out of Memory,指的是 App 占用的内存达到iOS系统对单个App占用内存上限时,而被系统强杀的现象,这是一种由iOS的Jetsam机制导致的奔溃,无法通过信号捕获到。
  • 对于监控OOM没有很好的办法,目前比较可行的办法是:定时监控内存使用,当接近内存使用上限时,dump 内存信息,获取对象名称、对象个数、各对象的内存值,并在合适的时机上报到服务器
  • App中会使用很多单例,这些单例常驻内存,需要关注大单例;大图片解码会造成内存使用飙升,这个也需要关注;还有些取巧的方案,比如预创建webview对象甚至预创建ViewController对象,采用此类做法,需要关注对内存造成的压力。
四、FPS监控
1、FPS和CADisplayLink
  • FPSFrames Per Second ,意思是每秒帧数,也就是我们常说的“刷新率(单位为Hz)。FPS低(小于50)表示App不流畅,App需要优化,iOS手机屏幕的正常刷新频率是每秒60次,即FPS值为60。
  • CADisplayLink是和屏幕刷新频率保存一致,它是CoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。
2、FPS监控实现
  • 注册CADisplayLink 得到屏幕的同步刷新率,记录1s(useTime,可能比1s大一丢丢)时间内刷新的帧数(total),计算total/useTime得到1s时间内的帧数,即FPS值。
- (void)start {
    //注意CADisplayLink的处理循环引用问题
    self.displayLink = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(updateFPSCount:)];
    [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

// 执行帧率和屏幕刷新率保持一致
- (void)updateFPSCount:(CADisplayLink *)displayLink {
    
    if (self.lastTimeStamp == 0) {
        self.lastTimeStamp = self.displayLink.timestamp;
    } else {
        self.total++;
        // 开始渲染时间与上次渲染时间差值
        NSTimeInterval useTime = self.displayLink.timestamp - self.lastTimeStamp;
        //小于1s立即返回
        if (useTime < 1){
            return;
        }
        self.lastTimeStamp = self.displayLink.timestamp;
        // fps 计算
        NSInteger fps = self.total / useTime;
        NSLog(@"self.total = %@,useTime = %@,fps = %@",@(self.total),@(useTime),@(fps));
        self.total = 0;
    }
}
复制代码

说明:很多团队非常相信(甚至迷信)FPS值,认为FPS值(大于50)就代表不卡顿,这点我是不认可。下面我列举遇到的2个非常典型的Case。

3、错信FPS值Case1
  • 同学A在做频繁绘制需求时, 重写UIView的drawRect:方法,在模拟器上频繁调用setNeedsDisplay来触发drawRect:方法,FPS值还稳定在50以上,但是真机上去掉帧很厉害。我认为这里犯了两个错误。
  • 错误1:drawRect:是利用CPU绘制的,性能并不如GPU绘制,对于频繁绘制的绘制需求,不应该考虑使用重写drawRect:这种方式,推荐CAShapeLayer+UIBezierPath
  • 错误2:不应该关注模拟器FPS来观察是否发生卡顿,模拟器使用的是Mac的处理器,比手机的ARM性能要强,所以造成在模拟器上FPS比较理想,真机上比较差。
4、错信FPS值Case2
  • 同学B在列表滑动时候,观察iPhone 6 plus真机上FPS的值稳定在52左右,感觉不错,但是肉眼明显感觉到卡顿。
  • 是FPS错了吗?我认为没错,是我们对FPS的理解错了;因为FPS代表的是每秒帧数,这是一个平均值,假如前0.5s播放了2帧,后面0.5s播放了58帧,从结果来看,FPS的值依旧是60。但是实际上,它的确发生了卡顿。
5、为什么关注FPS
  • 虽然列举了两个错信FPS的Case,但是FPS依旧是一个很重要的指标,来关注页面的卡顿情况。
  • 和使用监控RunLoop状态来发现卡顿问题不同,FPS关注的是滑动场景下,FPS偏低的场景。
  • 而监控RunLoop状态来发现卡顿问题更加关注的是:在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题
五、卡顿监控
1、卡顿和RunLoop
  • 卡顿监控的本质是,监控主线程做了哪些事;线程的消息事件依赖RunLoop,通过监听RunLoop的状态,从而判断是否发生卡顿。
  • RunLoop在iOS中是由CFRunLoop实现的,它负责监听输入源,进行调度处理的,这里的输入源可以是输入设备、网络、周期性或者延迟时间、异步回调。RunLoop接收两种输入源:一种是来自另一个线程或者来自不同应用的异步消息;另一个事来自预定时间或重复间隔的同步事件
  • 当有事情处理,Runloop唤起线程去处理,没有事情处理,让线程进入休眠。基于此,我们可以把大量占用CPU的任务(图片加载、数据文件读写等) ,放在空闲的非主线程执行,就可以避免影响主线程滑动过程中的体验(主线程滑动时,RunLoop处在UITrackingRunLoopMode模式)
2、如何判断卡顿
  • 已知的RunLoop的7个状态
//RunLoop的状态
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
     kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
     kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
     kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
     kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
     kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
     kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
     kCFRunLoopAllActivities = 0x0FFFFFFFU // loop 所有状态改变
 };
复制代码
  • 由于kCFRunLoopBeforeSources之后需要处理Source0,kCFRunLoopAfterWaiting之后需要处理timer、dispatch 到 main_queue 的 block和Source1,所以可以认为kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting。因为kCFRunLoopBeforeSources之后和kCFRunLoopAfterWaiting之后是事情处理的主要时间段。

  • dispatch_semaphore_t信号量机制特性:信号量到达、或者 超时会继续向下进行,否则等待;如果超时则返回的结果必定不为0,否则信号量到达结果为0。

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

3、卡顿监控的实现
//  QSMainThreadMonitor.h
@interface QSMainThreadMonitor : NSObject

+ (instancetype)sharedInstance;

- (void)beginMonitor;

- (void)stopMonitor;


@end

//  QSMainThreadMonitor.m
@interface QSMainThreadMonitor()

@property (nonatomic,strong) dispatch_semaphore_t semaphore;
@property (nonatomic,assign) CFRunLoopObserverRef observer;
@property (nonatomic,assign) CFRunLoopActivity runloopActivity;
@property (nonatomic,strong) dispatch_queue_t monitorQueue;

@end

@implementation QSMainThreadMonitor

+ (instancetype)sharedInstance {
    static QSMainThreadMonitor *monitor = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        monitor = [[QSMainThreadMonitor alloc]init];
    });
    return monitor;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        self.monitorQueue = dispatch_queue_create("com.main.thread.monitor.queue", DISPATCH_QUEUE_CONCURRENT);
    }
    return self;
}

- (void)beginMonitor{
    
    if (self.observer) {
        return;
    }
     __block int timeoutCount = 0;
    
    //创建观察者并添加到主线程
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL,NULL};
    self.observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context);
    //将self.observer添加到主线程RunLoop的Common模式下观察
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.observer, kCFRunLoopCommonModes);

    self.semaphore = dispatch_semaphore_create(0);
    dispatch_async(self.monitorQueue, ^{
        while (YES) {
            long result = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC));
            if (result != 0 && self.observer) {
                //超时判断
                if (self.runloopActivity == kCFRunLoopBeforeSources || self.runloopActivity == kCFRunLoopAfterWaiting) {
                    if (++timeoutCount < 1) {
                        NSLog(@"--timeoutCount--%@",@(timeoutCount));
                        continue;
                    }
                    //出现卡顿、进一步处理
                    NSLog(@"--timeoutCount 卡顿发生--");
                    // todo,eg:获取堆栈信息并上报
                }
            }else {
                timeoutCount = 0;
            }
        }
    });
    
}

- (void)stopMonitor{
    
    if (!self.observer) {
        return;
    }
    
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), self.observer, kCFRunLoopCommonModes);
    CFRelease(self.observer);
    self.observer = NULL;
    
}

#pragma mark -Private Method

/**
 * 观察者回调函数
 */
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    //每一次监测到Runloop状态变化调用
    QSMainThreadMonitor *monitor = (__bridge QSMainThreadMonitor *)info;
    monitor.runloopActivity = activity;
    if (monitor.semaphore) {
        dispatch_semaphore_signal(monitor.semaphore);
    }
}

@end
复制代码
4、卡顿时间阈值说明
  • 这里卡顿时间阈值是2s,连续1次超时且RunLoop的状态处于kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 状态就认为卡顿。
  • 利用的RunLoop实现的卡顿方案,主要是针对那些在一段时间内无法进行用户操作的场景,这类卡顿对用户的伤害非常大,是通过日志很难发现,需要优先解决的问题。
  • 卡顿时间阈值(timeoutThreshold)和超时时间次数(timeoutCount)可以通服务器下发控制,用来控制上报卡顿情况的场景。
六、电量监控
1、手动查看电量
  • 我们可以通过手机的设置-电池查看过去一段时间(24小时或2天)查看Top耗电量的App;
  • 对于用户来说,还有更直接的方式,使用某App时候,手机状态栏右上角电池使用量嗖嗖往下掉或手机发热,那么基本可以判断这个App耗电太快,赶紧卸了。
  • 对于开发者来说,可以通过Xcode左边栏的Energy Impact查看电量使用,蓝色表示--合理,黄色--表示比较耗电,红色--表示仅仅轻度使用你的程序,就会很耗电。
  • 还可以使用手机设置-开发者-Logging-Energy的startRecording和stopRecording来记录一段时间(3-5minutes)某App的耗电量情况。导入Instrument来分析具体耗电情况。
2、电量监控方案1
  • 利用UIDevice 提供了获取设备电池的相关信息,包括当前电池的状态以及电量。

    //开启电量监控
    [UIDevice currentDevice].batteryMonitoringEnabled = YES;
    //监听电量使用情况
    [[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryLevelDidChangeNotification object:nil queue:[NSOperationQueue mainQueue]
             usingBlock:^(NSNotification *notification) {
                 // Level has changed
                 NSLog(@"");
                 //UIDevice返回的batteryLevel的范围在0到1之间。
                 NSUInteger batteryLevel = [UIDevice currentDevice].batteryLevel * 100;
                 NSLog(@"[Battery Level]: %@", @(batteryLevel));
             }];
    复制代码

说明:使用 UIDevice 可以非常方便获取到电量,但是经测试发现,在 iOS 8.0 之前,batteryLevel 只能精确到5%,而在 iOS 8.0 之后,精确度可以达到1%

3、电量监控方案2
  • 利用iOS系统私有框架IOKit, 通过它可以获取设备电量信息,精确度达到1%。
#import "IOPSKeys.h"
#import "IOPowerSources.h"

-(double) getBatteryLevel{
    // 返回电量信息
    CFTypeRef blob = IOPSCopyPowerSourcesInfo();
    // 返回电量句柄列表数据
    CFArrayRef sources = IOPSCopyPowerSourcesList(blob);
    CFDictionaryRef pSource = NULL;
    const void *psValue;
    // 返回数组大小
    int numOfSources = CFArrayGetCount(sources);
    // 计算大小出错处理
    if (numOfSources == 0) {
        NSLog(@"Error in CFArrayGetCount");
        return -1.0f;
    }

    // 计算所剩电量
    for (int i=0; i<numOfSources; i++) {
        // 返回电源可读信息的字典
        pSource = IOPSGetPowerSourceDescription(blob, CFArrayGetValueAtIndex(sources, i));
        if (!pSource) {
            NSLog(@"Error in IOPSGetPowerSourceDescription");
            return -1.0f;
        }
        psValue = (CFStringRef) CFDictionaryGetValue(pSource, CFSTR(kIOPSNameKey));

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

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

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

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

说明

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

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

    直接读写磁盘文件 (合理利用内存缓存,碎片化的数据在内存中聚合,合适时机写入磁盘)
    复制代码
七、End
1、总结
  • 对APP的质量指标的监控,是为了更早地发现问题;发现问题是为了更好地解决问题。所以监控不是终点,是起点。

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

  • 文章篇幅有限,没有介绍具体的优化办法。

2、推荐的阅读资料

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

教你开发省电的 iOS app

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值