iOS性能數據采集機制匯總

1. 概述

iOS 客戶端的應用性能數據監控一般包括如下指標

  • 卡頓監測
  • FPS 采集
  • CPU 采集
  • Memory 采集
  • 冷啟動測速
  • 流量監控

而我們關注監控技術的目的,通常是為了開發一套相關的監控 SDK 或者功能,需要了解各個監控指標的監控手段和原理;因此這里將記錄各個監控指標的基本原理和機制,不過多涉及具體的代碼實現,大部分監控代碼能玩的花樣不多,延展出去的監控數據展示、持久化與上報機制又遠遠比監控本身復雜,此處就不贅述。

2. 卡頓檢測

卡頓監控需要利用信號量,對主線程 Runloop 加入 observer 進行監聽,通過信號量等待機制,檢測出主線程 Runloop 卡頓情況,進行上報。

2.1 加入監聽

    CFRunLoopActivity observedActivities = kCFRunLoopBeforeSources | kCFRunLoopBeforeWaiting | kCFRunLoopAfterWaiting;
    _runloopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, observedActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        __strong __typeof(weakSelf)strongSelf = weakSelf;
        if (strongSelf.semaphore != NULL) {
            dispatch_semaphore_signal(strongSelf.semaphore);
        }
    });
    CFRunLoopAddObserver(CFRunLoopGetMain(), _runloopObserver, kCFRunLoopCommonModes);
    CFRelease(_runloopObserver);

此處主要監聽 Runloop 的三個 activity,beforeSources,beforeWaiting 和 afterWaiting,原因是根據 Runloop 內部執行順序,具體見下圖

Runloop執行原理

Runloop 執行 Source0,Source1,MainQueue,Timer 和 Block 的階段均在這三個時機之間,因此對三個時機插點,就可以監控出執行卡頓的問題。

2.2 信號量等待機制

    while (!self.cancelled) {
        long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, self.threshold * NSEC_PER_MSEC));
        if (status != 0) {
            if (self.callback) {
                self.callback();
            }
            dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
        }
    }

此處利用 dispatch_semaphore_wait 函數,在一段時間內(一般是3s-5s)等待信號量,假如 Runloop 運行正常則在上面三個時機點均會執行信號量釋放操作,因此如果出現卡頓不能如期釋放信號量,則調用 callback 進行卡頓處理和上報。

dispatch_semaphore_wait 返回為 0 代表信號量獲取成功,否則未能獲取到信號量,此時將永久等待信號量,以確保不再重復上報卡頓。

當然卡頓上報也可以加入次數限制,例如卡頓發生 3 次就不再上報等邏輯。

3. FPS 采集

3.1 基礎原理及步驟

FPS 采集完全依賴于 iOS 提供的 CADisplayLink 類,它提供了屏幕刷新時機,并支持自定義回調,從而獲知到屏幕刷新的時間戳,依據如下公式就可以得到應用的 FPS 信息。

FPS = FrameCount/Duration

因此對于 FPS 監控的基本步驟如下

  • 初始化一個 CADisplayLink
[CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)]
  • 回調中記錄當前時間戳,記錄與上一幀時間戳間隔,記錄瞬時 FPS,甚至可以記錄自某一時刻開始到當前,總的幀數和總時間間隔,從而計算出平均 FPS
- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
    currentTimestamp = displayLink.timestamp;
    instantDuration = currentTimestamp - lastTimestamp;
    instantFPS = round(1.0/instantDuration);
    totalFrameCount++;
    totalDuration += instantDuration;
    avgFPS = totalFrameCount/totalDuration;
}

但是更進一步,除了關注整體 FPS,我們還可以考慮關注特定 VC,特定 ScrollView,自定義時機的 FPS。

3.2 UIViewController 的 FPS

一個 VC 的 FPS 統計與基礎 FPS 統計無異,唯一要關注的是如何確定統計時機,一般選取如下時機

  • viewDidAppear 時開啟當前 VC 的 FPS 統計,關閉其他 VC 的 FPS 統計
  • applicationWillResignActive 退出后臺時上報數據,關閉計時器
  • applicationDidBecomeActive 進入前臺后重啟計時器,重置數據

當然可監控的 VC 的選取也存在一些規則,大致如下

  • 排除 UIViewController 等系統 VC
  • 排除 UINavigationController、UITabBarController、UIInputViewController、UIAlertController 等非頁面級的 VC
  • 排除一個 UIViewController 內的子 VC
  • 排除無父 VC 且不是 present 出來的 VC

這樣排除的考慮是監控 FPS 的實體一般只有一個,同一時刻只針對一個 VC 進行監控,子 VC 等不排除的話可能導致監控數據不合理。當然如果能針對每一個 VC 都加入 FPS 監控就可以解決這一問題,但是這樣會引入額外的統計時機,比如 VC 的 view 需要添加到其他 VC 上以后才應該監控。

3.3 ScrollView 的 FPS

ScrollView 是常用的展示抽象程度較高、數目較大元素的視圖組件,也是 FPS 重災區,在數據處理、渲染、滑動手勢等多處都可能引發掉幀現象,因此有必要對其進行 FPS 監控。

ScrollView 的具體監控依賴于 UIScrollView 的兩個屬性

  • isDragging 用戶開始滑動 ScrollView
  • isDecelerating 用戶停止滑動,但 ScrollView 仍在滾動中

通過 CADisplayLink 回調中檢查當前監控的 UIScrollView 實例的兩個狀態,與其前一次狀態對比,進行如下邏輯

  • 由未滑動進入到滑動狀態,初始化 FPS 數據
  • 滑動中,更新統計幀數和統計總時間間隔
  • 由滑動狀態進入到未滑動狀態,上報 FPS 數據

在這一過程中也可以加入當前 ScrollView 所屬 VC 的信息方便后續排查。

3.4 自定義 FPS 時機

自定義時機更加靈活,只需要明確統計開始點和結束點,即可按照 FPS 基本原理進行統計。

- (void)startRecordWithIdentifier:(NSString *)identifier;
- (void)stopRecordWithIdentifier:(NSString *)identifier;

4. CPU 采集

iOS 是基于 Apple Darwin 內核,由 kernel、XNU 和 Runtime 組成,而 XNU 是 Darwin 的內核,它是“X is not UNIX”的縮寫,是一個混合內核,由 Mach 微內核和 BSD 組成。Mach 內核是輕量級的平臺,只能完成操作系統最基本的職責,比如:進程和線程、虛擬內存管理、任務調度、進程通信和消息傳遞機制。其他的工作,例如文件操作和設備訪問,都由 BSD 層實現。

在 Mach 層中定義了一個 thread_basic_info 結構體,提供了線程的基本信息

struct thread_basic_info {
        time_value_t    user_time;      /* user run time */
        time_value_t    system_time;    /* system run time */
        integer_t       cpu_usage;      /* scaled cpu usage percentage */
        policy_t        policy;         /* scheduling policy in effect */
        integer_t       run_state;      /* run state (see below) */
        integer_t       flags;          /* various flags (see below) */
        integer_t       suspend_count;  /* suspend count for thread */
        integer_t       sleep_time;     /* number of seconds that thread
                                           has been sleeping */
};

其中就有我們所需要的 cpu_usage 字段,因此如果獲知了組成當前應用進程的所有線程的 thread_basic_info,就可以統計出 CPU 使用情況了。

在 Mach 層,一個應用進程嚴格關聯一個 Mach Task 對象,通過如下函數可以獲知當前應用所在進程的全部線程信息

    thread_array_t         thread_list;
    mach_msg_type_number_t thread_count;
    thread_info_data_t     thinfo;
    mach_msg_type_number_t thread_info_count;
    thread_basic_info_t basic_info_th;
    kern_return_t kr = task_threads(mach_task_self(), &thread_list, &thread_count);
    if (kr != KERN_SUCCESS) {
        return -1;
    }

接下來遍歷整個 thread_list,算出 CPU 總和

    CGFloat total_cpu = 0;
    for (int j = 0; j < thread_count; j++)
    {
        thread_info_count = THREAD_INFO_MAX;
        kr = thread_info(thread_list[j], THREAD_BASIC_INFO,(thread_info_t)thinfo, &thread_info_count);
        if (kr != KERN_SUCCESS) {
            return -1;
        }
        
        basic_info_th = (thread_basic_info_t)thinfo;
        
        if (!(basic_info_th->flags & TH_FLAGS_IDLE)) {
            total_cpu = total_cpu + basic_info_th->cpu_usage / (CGFloat)TH_USAGE_SCALE * 100.0;
        }
    }

這里通過 thread_info 函數,將一個 thread 的基礎信息(BASIC_INFO)讀入到 thinfo 中,最終獲取到的 cpu_usage 還需要除以 TH_USAGE_SCALE (CPU處理總頻率),從而得到 CPU 占比。

此處由于我們創建了一個 thread_list 結構體,因此需要手動釋放掉,以避免泄漏內存

    kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
    assert(kr == KERN_SUCCESS);

獲得了瞬時 CPU 占比,可以啟動一個定時器定期(1s)采集數據,最終匯總出最大占比和平均占比等數據。

5. Memory 采集

上一節提到一個應用進程對應于一個 Mach Task,而 thread_info 也可以獲取到當前進程的所有數據,它們均定義在一個 mach_task_basic_info 結構體中

struct mach_task_basic_info {
        mach_vm_size_t  virtual_size;       /* virtual memory size (bytes) */
        mach_vm_size_t  resident_size;      /* resident memory size (bytes) */
        mach_vm_size_t  resident_size_max;  /* maximum resident memory size (bytes) */
        time_value_t    user_time;          /* total user run time for
                                               terminated threads */
        time_value_t    system_time;        /* total system run time for
                                               terminated threads */
        policy_t        policy;             /* default policy for new threads */
        integer_t       suspend_count;      /* suspend count for task */
};

注釋寫的也很清楚,這里 resident_size 即代表了物理內存使用情況。

所以獲取方式如下

    struct mach_task_basic_info info;
    mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
    kern_return_t kr = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)& info, &count);
    return (kr == KERN_SUCCESS) ? info.resident_size : 0;

這里我們返回的是 Byte 單位的內存占用,因而還需要進行一些數學運算以簡化數字展示。

但是實際上通過此方法并不能夠獲取到與 Xcode 上的 Memory 一樣的參數,就觀察來看它比 Xcode 的統計數據要大很多。這里還有另一種 方法,它獲取到的內存占用值更加貼合于 Xcode 的統計值

+ (double)getMemoryUsage {
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    if(task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count) == KERN_SUCCESS) {
        return (double)vmInfo.phys_footprint;
    } else {
        return -1.0;
    }
}

而 iOS 的內存殺手 Jetsam 也是通過 phys_footprint 這一參數來獲知內存使用是否達到上界的。

6. 冷啟動測速

冷啟動測速很多時候都與打點密不可分,通常來說我們會在以下一系列地方進行打點獲知啟動流程

  • main 函數
  • AppDelegate 代理方法
  • homePage 首頁

但是在 main 函數執行前其實也有很大一部分耗時工作需要執行,例如

  • 加載可執行文件
  • 加載動態鏈接庫
  • 初始化 Runtime
  • +load 函數

完整示意圖如下

冷啟動過程

所以從 main 函數開始計時是與真實情況不夠貼合的,更早的時間點獲取方式有以下 3 種

  • 以可執行文件中任意一個類的 +load 方法的執行時間作為起始點
  • 分析 dylib 的依賴關系,找到葉子節點的 dylib,然后以其中某個類的 +load 方法的執行時間作為起始點
  • 以 App 的進程創建時間(即 exec 函數執行時間)作為冷啟動的起始時間,通過 sysctl 函數獲取

這三者里,第三個方式的時間戳統計最早,而且目前未發現更早更準確且更有意義的起始點

#import <sys/sysctl.h>
#import <mach/mach.h>

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime
{
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"無法取得進程的信息");
        return 0;
    }
}

有了起始點,其他打點就可以依次相減得到每一段的具體耗時了。

這里需要補充一點,假如應用執行了安裝后啟動的操作,例如模擬器上進行編譯調試,sysctl 獲取的時間戳會從安裝起始點開始計算,當然這對于實際使用來說影響不大。

7. 流量監控

流量監控主要需要關注的點有以下四個

  • URL,毋庸置疑,監控出問題后需要 URL 來排查
  • requestSize,請求大小,具體包括 URL 長度、 header 長度和 body 長度,實際上嚴格意義上 Method 字段和 Version 字段也需要考慮,但是考慮到它們都是固定長度且占比較小所以不計
    NSURL *URL = request.URL;
    NSUInteger URLLength = URL.absoluteString.length;
    NSUInteger requestHeaderLength = 0;
    if (request && [NSJSONSerialization isValidJSONObject:[request allHTTPHeaderFields]]) {
        requestHeaderLength = [NSJSONSerialization dataWithJSONObject:[request allHTTPHeaderFields] options:0 error:NULL].length;
    }
    NSUInteger requestBodyLength = request.HTTPBody.length;
    NSUInteger requestSize = URLLength + requestHeaderLength + requestBodyLength;
  • responseSize,響應大小,具體包括 header 長度和 body 長度
    NSUInteger responseHeaderLength = 0;
    if (response && [NSJSONSerialization isValidJSONObject:[response allHeaderFields]]) {
        responseHeaderLength = [NSJSONSerialization dataWithJSONObject:[(NSHTTPURLResponse *)response allHeaderFields] options:0 error:NULL].length;
    }
    NSUInteger responseSize = responseHeaderLength + responseDataLength;
  • type,請求類型,具體可以分為
    • Web - H5頁面,一般來說它的 MIMEType 會是這幾種 "text/css","text/html","application/x-javascript","application/javascript"
    • API - Native 側進行 API 接口請求
    • Resource - Native 側進行多媒體資源等資源數據請求,與 API 的區分需要從 URLHost 上著手
    • Other

當然流量數據的特點是頻率高、次數多、體積不定,所以做好緩存和批次上報、壓縮上報等工作也是必不可少的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。