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_threads API 調用獲取指定 task 的線程列表,然后可以通過 thread_info API 調用來查詢指定線程的信息,thread_info API 在 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_threads 將 target_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_threads API 時,target_task 參數傳入的是 mach_task_self(),表示獲取當前的 Mach task。而在調用 thread_info API 時,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 中 UIDevice 的 YYAddcategory 也是采用這種方式,但是其實計算出來的 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_USERCPU_STATE_SYSTEMCPU_STATE_IDLE 和 CPU_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; }

上面代碼通過計算 info 和 previous_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_FREQ, HW_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"); }

但是實際在真機測試會發現,當 specifier 為 hw.cpufrequency_max 和 hw.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 結構體成員變量中就包含我們需要的信息:cputype 和 cpusubtype,這兩個變量類型的定義在 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_info API 根據指定的 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_info API 時,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_t 64位的結構體。

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 監控卡頓的流程圖

主線程卡頓監控的實現思路:開辟一個子線程,然后實時計算 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 兩個狀態區域之間的耗時是否超過某個閥值,來斷定主線程的卡頓情況,可以將這個過程想象成操場上跑圈的運動員,我們會每隔一段時間間隔去判斷是否跑了一圈,如果發現在指定時間間隔沒有跑完一圈,則認為在消息處理的過程中耗時太多,視為主線程卡頓。

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 實現的庫,如 NSURLConnection 和 NSURLSession ,Fishhook 則適用於 C 語言實現的庫,如 CFNetwork

下圖是阿里百川碼力監控給出的三類網絡接口需要 hook 的方法

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

Method Swizzling

Method swizzling 是利用 Objective-C Runtime 特性把一個方法的實現與另一個方法的實現進行替換的技術。每個 Class 結構體中都有一個 Dispatch Table 的成員變量,Dispatch Table 中建立了每個 SEL(方法名)和對應的 IMP(方法實現,指向 C 函數的指針)的映射關系,Method Swizzling 就是將原有的 SEL 和 IMP映射關系打破,並建立新的關聯來達到方法替換的目的。

因此利用 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 的定義,NSProxy 和 NSObject 一樣都是根類,它是一個抽象類,你可以通過繼承它,並重寫 -forwardInvocation: 和 -methodSignatureForSelector: 方法以實現消息轉發到另一個實例。綜上,NSProxy 的目的就是負責將消息轉發到真正的 target 的代理類。

Method swizzling 替換方法需要指定類名,但是 NSURLConnectionDelegate 和 NSURLSessionDelegate 是由業務方指定,通常來說是不確定,所以這種場景不適合使用 Method swizzling。使用 NSProxy 可以解決上面的問題,具體實現:proxy delegate 替換 NSURLConnection 和 NSURLSession 原來的 delegate,當 proxy delegate 收到回調時,如果是要 hook 的方法,則調用 proxy 的實現,proxy 的實現最后會調用原來的 delegate;如果不是要 hook 的方法,則通過消息轉發機制將消息轉發給原來的 delegate。下圖示意了整個操作流程。

Fishhook

fishhook 是一個由 Facebook 開源的第三方框架,其主要作用就是動態修改 C 語言的函數實現,我們可以使用 fishhook 來替換動態鏈接庫中的 C 函數實現,具體來說就是去替換 CFNetwork 和 CoreFoundation 中的相關函數。后面會在講監控 CFNetwork 詳細說明,這里不再贅述。

講解完 iOS 上 hook 的實現技術,接下來討論在 NSURLConnectionNSURLSession 和 CFNetwork 中,如何將上面的三種技術應用到實踐中。

NSURLConnection

NSURLSession

CFNetwork

概述

以 NeteaseAPM 作為案例來講解如何通過 CFNetwork 實現網絡監控,它是通過使用代理模式來實現的,具體來說,是在 CoreFoundation Framework 的 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 處理,然后再重寫 NSInputStream 的 read: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); 

根據 CFNetwork API 的調用方式,使用 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 對象有 taskInterval 和 redirectCount 屬性,還有在執行任務時產生的每個請求/響應事務中收集的指標。

  • 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 對象封裝了任務執行時收集的性能指標,包括了 request 和 response屬性,對應 HTTP 的請求和響應,還包括了從 fetchStartDate 開始,到 responseEndDate 結束之間的指標,當然還有 networkProtocolName 和 resourceFetchType 屬性。

  • request:表示了網絡請求對象。

     /*
     * Represents the transaction request.  */ @property (copy, readonly) NSURLRequest *request;
  • response:表示了網絡響應對象,如果網絡出錯或沒有響應時,response 為 nil

     /*
     * 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 中 NSURLRequest 的 allHTTPHeaderFields 屬性就對應上文提到的首部字段,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;
}

下行流量

下行流量的思路也類似,主要計算每個響應報文的大小,包含報文首部和報文主體,兩者之間有個空行,報文首部包含狀態行和首部字段。實現上會用到 NSHTTPURLResponse 的 allHeaderFields 和 expectedContentLength 屬性。但這里需要注意的是 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,然后就可以通過 batteryState 和 batteryLevel 獲取電池信息。

  • 是否開啟電池監控,默認為 NO

     // default is NO @property(nonatomic,getter=isBatteryMonitoringEnabled) BOOL batteryMonitoringEnabled NS_AVAILABLE_IOS(3_0) __TVOS_PROHIBITED; 
  • 電池電量,取值 0-1.0,如果 batteryState 是 UIDeviceBatteryStateUnknown,則電量是 -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%,而在 iOS 8.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

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

Author

Twitter: @aozhimin

Email: aozhimin0811@gmail.com

參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM