1. 程式人生 > 其它 >iOS 效能監控 SDK —— Wedjat(華狄特)開發過程的調研和整理

iOS 效能監控 SDK —— Wedjat(華狄特)開發過程的調研和整理

為了讓這篇文章能夠在公眾號發表,所以將文章拆解成上下兩篇:基礎效能篇和網路篇

目錄

為什麼寫這篇文章?

隨著移動網際網路向縱深發展,使用者變得越來越關心應用的體驗,開發者必須關注應用效能所帶來的使用者流失問題。據統計,有十種應用效能問題危害最大,分別為:連線超時、閃退、卡頓、崩潰、黑白屏、網路劫持、互動效能差、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_threadsAPI 呼叫獲取指定 task 的執行緒列表,然後可以通過thread_infoAPI 呼叫來查詢指定執行緒的資訊,thread_infoAPI 在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_threadsAPI 時,target_task引數傳入的是mach_task_self(),表示獲取當前的 Mach task。而在呼叫thread_infoAPI 時,flavor引數傳的是THREAD_BASIC_INFO,使用這個型別會返回執行緒的基本資訊,定義在thread_basic_info_t結構體,包含了使用者和系統的執行時間,執行狀態和排程優先順序。

注意方法最後要呼叫vm_deallocate,防止出現記憶體洩漏。據測試,該方法採集的CPU資料和騰訊的GT、Instruments資料接近。

由於監控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 佔用率,包括YYCategories中UIDeviceYYAddcategory 也是採用這種方式,但是其實計算出來的 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_infoAPI 根據指定的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_infoAPI 時,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_t64位的結構體。

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體系中,應用層的協議支援有限,只支援FTP,HTTP,HTTPS等幾個應用層協議,對於使用其他協議的流量則束手無策,所以存在一定的侷限性。監控底層網路庫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-C的Runtime機制,實現 Hook 的三種方式分別為:Method Swizzling、NSProxy和Fishhook。前兩者適用於Objective-C實現的庫,如NSURLConnectionNSURLSession,Fishhook則適用於C語言實現的庫,如CFNetwork

下圖是阿里百川碼力監控給出的三類網路介面需要 hook 的方法

接下來分別來討論這三種實現方式:

Method Swizzling

Method swizzling是利用Objective-CRuntime特性把一個方法的實現與另一個方法的實現進行替換的技術。每個 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實現網路監控,它是通過使用代理模式來實現的,具體來說,是在CoreFoundationFramework 的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,而不涉及來自檔案和記憶體的CFReadStream,NeteaseAPM的解決方案是在系統構造 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);                                                    

根據CFNetworkAPI 的呼叫方式,使用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%,而在iOS8.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

發現iOSDiagnosticsSupportFramework 在 iOS 10 之後名字已經被改成DiagnosticsSupport,而且MBSDevice類也被隱藏了。

Author

Twitter:@aozhimin

Email:[email protected]

參考資料

------------------越是喧囂的世界,越需要寧靜的思考------------------ 合抱之木,生於毫末;九層之臺,起於壘土;千里之行,始於足下。 積土成山,風雨興焉;積水成淵,蛟龍生焉;積善成德,而神明自得,聖心備焉。故不積跬步,無以至千里;不積小流,無以成江海。騏驥一躍,不能十步;駑馬十駕,功在不捨。鍥而舍之,朽木不折;鍥而不捨,金石可鏤。蚓無爪牙之利,筋骨之強,上食埃土,下飲黃泉,用心一也。蟹六跪而二螯,非蛇鱔之穴無可寄託者,用心躁也。