APM基礎小記

天之道,損有餘而補不足git

原文連接github

1、概述

一、APM是什麼
  • 咱們平時關注更多的是:需求是否delay,線上bug有多少?每一個週期(好比2-3周) 關注下App的DAU、DNU、這些產品指標;可是團隊中須要有人去關注App的技術質量指標:如Crash率、啓動時間、安裝包大小、核心頁面的FPS、CPU使用率、內存佔用、電量使用、卡頓狀況等。
  • 關注App線上質量,從技術維度來判斷App是否健康。不健康的App表現爲啓動時間慢、頁面卡頓、耗電量大等,這些App最終會失去用戶;
  • APM (Application Performance Manage)旨在創建APP的質量監控接入框架,方便App能快速集成,對性能監控項的異常數據進行採集和分析,輸出相應問題的分析、定位與優化建議,從而幫助開發者開發出更高質量的應用。
二、APM工具
  • 微信最近開源了微信的APM工具Matrix, 提供了針對iOS、Android和macOS系統的性能監控方案。這個方案很全面,能夠直接接入App,固然也能夠吸取其優秀的技術細節,優化本身的APM工具。
  • 本文不是介紹如何定製一個APM工具,而是介紹在APM監控中,比較重要的幾個監控維度:CPU使用率、內存使用、FPS和卡頓監控

2、CPU使用率監控

一、Task和CPU
  • 任務(Task)是一種容器(Container)對象;虛擬內存空間和其餘資源都是經過這個容器對象管理的,這些資源包括設備和其餘句柄。
  • 嚴格地說,Mach 的任務並非其餘操做系統中所謂的進程,由於 Mach 做爲一個微內核的操做系統,並無提供「進程」的邏輯,而只是提供了最基本的實現。不過在 BSD 的模型中,這兩個概念有1:1的簡單映射,每個 BSD 進程(也就是 OS X 進程)都在底層關聯了一個 Mach 任務對象。
  • 而每App運行,會對應一個Mach Task,Task下可能有多條線程同時執行任務,每一個線程都是利用CPU的基本單位。要計算CPU 佔用率,就須要得到當前Mach Task下,全部線程佔用 CPU 的狀況
二、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表示線程個數:web

三、單個線程信息結構
  • 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;    // 休眠的時間
};
複製代碼
四、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;
}
複製代碼
四、爲何關注CPU使用率
  • CPU的使用率是對APP使用CPU狀況的評估,App頻繁操做,CPU使用率通常在40%-50%;
  • 假如CPU使用太高(>90%),能夠認爲CPU滿負載,此種狀況大機率發生卡頓,能夠選擇上報。
  • 一段時間內CPU的使用率一直超過某個閾值(80%),此種狀況大機率發生卡頓,能夠選擇上報。

3、內存使用監控

一、內存
  • 內存是有限且系統共享的資源,一個App佔用地多,系統和其餘App所能用的就更少;減小內存佔用能不只僅讓本身App,其餘App,甚至是整個系統都表現得更好。
  • 關注App的內存使用狀況十分重要
二、內存信息結構
  • 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;     // 實際使用的物理內存

  ...
複製代碼
三、內存信息獲取
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;
}
複製代碼
四、爲何關注內存使用
  • 內存問題影響最大是OOM,即Out of Memory,指的是 App 佔用的內存達到iOS系統對單個App佔用內存上限時,而被系統強殺的現象,這是一種由iOS的Jetsam機制致使的奔潰,沒法經過信號捕獲到。
  • 對於監控OOM沒有很好的辦法,目前比較可行的辦法是:定時監控內存使用,當接近內存使用上限時,dump 內存信息,獲取對象名稱、對象個數、各對象的內存值,並在合適的時機上報到服務器
  • App中會使用不少單例,這些單例常駐內存,須要關注大單例;大圖片解碼會形成內存使用飆升,這個也須要關注;還有些取巧的方案,好比預建立webview對象甚至預建立ViewController對象,採用此類作法,須要關注對內存形成的壓力。

4、FPS監控

一、FPS和CADisplayLink
  • FPSFrames Per Second ,意思是每秒幀數,也就是咱們常說的「刷新率(單位爲Hz)。FPS低(小於50)表示App不流暢,App須要優化,iOS手機屏幕的正常刷新頻率是每秒60次,即FPS值爲60。
  • CADisplayLink是和屏幕刷新頻率保存一致,它是CoreAnimation提供的另外一個相似於NSTimer的類,它老是在屏幕完成一次更新以前啓動,CADisplayLink有一個整型的frameInterval屬性,指定了間隔多少幀以後才執行。默認值是1,意味着每次屏幕更新以前都會執行一次。
二、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。objective-c

三、錯信FPS值Case1
  • 同窗A在作頻繁繪製需求時, 重寫UIView的drawRect:方法,在模擬器上頻繁調用setNeedsDisplay來觸發drawRect:方法,FPS值還穩定在50以上,可是真機上去掉幀很厲害。我認爲這裏犯了兩個錯誤。
  • 錯誤1:drawRect:是利用CPU繪製的,性能並不如GPU繪製,對於頻繁繪製的繪製需求,不該該考慮使用重寫drawRect:這種方式,推薦CAShapeLayer+UIBezierPath
  • 錯誤2:不該該關注模擬器FPS來觀察是否發生卡頓,模擬器使用的是Mac的處理器,比手機的ARM性能要強,因此形成在模擬器上FPS比較理想,真機上比較差。
四、錯信FPS值Case2
  • 同窗B在列表滑動時候,觀察iPhone 6 plus真機上FPS的值穩定在52左右,感受不錯,可是肉眼明顯感受到卡頓。
  • 是FPS錯了嗎?我認爲沒錯,是咱們對FPS的理解錯了;由於FPS表明的是每秒幀數,這是一個平均值,假如前0.5s播放了2幀,後面0.5s播放了58幀,從結果來看,FPS的值依舊是60。可是實際上,它的確發生了卡頓。
五、爲何關注FPS
  • 雖然列舉了兩個錯信FPS的Case,可是FPS依舊是一個很重要的指標,來關注頁面的卡頓狀況。
  • 和使用監控RunLoop狀態來發現卡頓問題不一樣,FPS關注的是滑動場景下,FPS偏低的場景。
  • 而監控RunLoop狀態來發現卡頓問題更加關注的是:在一段時間內沒法進行用戶操做的場景,這類卡頓對用戶的傷害很是大,是經過日誌很難發現,須要優先解決的問題

5、卡頓監控

一、卡頓和RunLoop
  • 卡頓監控的本質是,監控主線程作了哪些事;線程的消息事件依賴RunLoop,經過監聽RunLoop的狀態,從而判斷是否發生卡頓。
  • RunLoop在iOS中是由CFRunLoop實現的,它負責監聽輸入源,進行調度處理的,這裏的輸入源能夠是輸入設備、網絡、週期性或者延遲時間、異步回調。RunLoop接收兩種輸入源:一種是來自另外一個線程或者來自不一樣應用的異步消息;另外一個事來自預約時間或重複間隔的同步事件
  • 當有事情處理,Runloop喚起線程去處理,沒有事情處理,讓線程進入休眠。基於此,咱們能夠把大量佔用CPU的任務(圖片加載、數據文件讀寫等) ,放在空閒的非主線程執行,就能夠避免影響主線程滑動過程當中的體驗(主線程滑動時,RunLoop處在UITrackingRunLoopMode模式)
二、如何判斷卡頓
  • 已知的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,就認爲主線程長期停留在這兩個狀態上,此時就斷定卡頓發生。bash

三、卡頓監控的實現
//  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
複製代碼
四、卡頓時間閾值說明
  • 這裏卡頓時間閾值是2s,連續1次超時且RunLoop的狀態處於kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 狀態就認爲卡頓。
  • 利用的RunLoop實現的卡頓方案,主要是針對那些在一段時間內沒法進行用戶操做的場景,這類卡頓對用戶的傷害很是大,是經過日誌很難發現,須要優先解決的問題。
  • 卡頓時間閾值(timeoutThreshold)和超時時間次數(timeoutCount)能夠通服務器下發控制,用來控制上報卡頓狀況的場景。

6、電量監控

一、手動查看電量
  • 咱們能夠經過手機的設置-電池查看過去一段時間(24小時或2天)查看Top耗電量的App;
  • 對於用戶來講,還有更直接的方式,使用某App時候,手機狀態欄右上角電池使用量嗖嗖往下掉或手機發熱,那麼基本能夠判斷這個App耗電太快,趕忙卸了。
  • 對於開發者來講,能夠經過Xcode左邊欄的Energy Impact查看電量使用,藍色表示--合理,黃色--表示比較耗電,紅色--表示僅僅輕度使用你的程序,就會很耗電。
  • 還可使用手機設置-開發者-Logging-Energy的startRecording和stopRecording來記錄一段時間(3-5minutes)某App的耗電量狀況。導入Instrument來分析具體耗電狀況。
二、電量監控方案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%微信

三、電量監控方案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)。
四、耗電量大的操做
  • CPU使用率高的操做

    線程過多 (控制合適的線程數)
    定位   (按需使用,下降頻次)
    CPU任務繁重  (使用輕量級對象,緩存計算結果,對象複用等)
    頻繁網絡請求(避免無效冗餘的網絡請求)
    複製代碼
  • I/O操做頻繁的操做

    直接讀寫磁盤文件 (合理利用內存緩存,碎片化的數據在內存中聚合,合適時機寫入磁盤)
    複製代碼

7、End

一、總結
  • 對APP的質量指標的監控,是爲了更早地發現問題;發現問題是爲了更好地解決問題。因此監控不是終點,是起點。

  • 在17年時候,在簡書中寫了iOS實錄14:淺談iOS Crash(一)iOS實錄15:淺談iOS Crash(二)兩篇文章;時隔兩年以後,書寫此文,是爲了記念過去大半年時候在App質量監控上花的努力。

  • 文章篇幅有限,沒有介紹具體的優化辦法。

二、推薦的閱讀資料

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

教你開發省電的 iOS app

相關文章
相關標籤/搜索