博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
APM基础小记
阅读量:5986 次
发布时间:2019-06-20

本文共 13359 字,大约阅读时间需要 44 分钟。

天之道,损有余而补不足

一、概述

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工具, 提供了针对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

说明

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

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

    直接读写磁盘文件 (合理利用内存缓存,碎片化的数据在内存中聚合,合适时机写入磁盘)复制代码

七、End

1、总结
  • 对APP的质量指标的监控,是为了更早地发现问题;发现问题是为了更好地解决问题。所以监控不是终点,是起点。

  • 在17年时候,在简书中写了和 两篇文章;时隔两年之后,书写此文,是为了纪念过去大半年时候在App质量监控上花的努力。

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

2、推荐的阅读资料

转载地址:http://hsflx.baihongyu.com/

你可能感兴趣的文章
Shell脚本
查看>>
【小游戏】C++手工制作贪吃蛇
查看>>
紫书 习题 10-14 UVa 10886(暴力+数据范围)
查看>>
紫书 例题 9-6 UVa 11400 (线性结构上的动态规划)
查看>>
PAT (Advanced Level) 1085. Perfect Sequence (25)
查看>>
CodeForces 708B Recover the String
查看>>
FZU 2193 So Hard
查看>>
detectron和mm-detection是干什么的?
查看>>
postman请求https接口
查看>>
MySQL主从切换
查看>>
[BZOJ] 2442: [Usaco2011 Open]修剪草坪
查看>>
设计模式之-适配器模式
查看>>
正式学习React (六) 项目篇
查看>>
treap
查看>>
刷数据结构记录
查看>>
雷林鹏分享:jQuery EasyUI 布局 - 创建标签页(Tabs)
查看>>
Spring Boot入门程序
查看>>
HTML光标样式
查看>>
读书笔记之《大话设计模式》——思维导图
查看>>
knockout js
查看>>