基於 iOS 平臺的效能檢測方案
導語
在開發過程中,功能不僅要滿足業務需求,也要關注功能對 App 效能帶來的一些問題。開發人員在開發階段檢測效能比較容易, iOS 端可以直接通過 instruments 工具進行檢測。但是在測試階段,測試人員要檢測效能需要下載開發工具成本比較高。如果客戶端能夠將效能資料上傳到服務端並且通過一些介面進行展示,對測試人員來說是一種可以檢測效能的比較好的方法。
本文章主要介紹 iOS 端 如何通過程式碼採集效能資料,其中包括電池資料, CPU 資料,記憶體資料,卡頓資料,流量資料以及冷啟動時間等。
電池資料
首先來看一下電池資料,iOS 電池資料採集方案主要有以下三種方案: UIDevice , IOKit ,越獄。
1、UIDevice:提供了獲取裝置電池的相關資訊,包括當前電池的狀態以及電量。獲取電池資訊之前需要先將 batteryMonitoringEnabled 屬性設定為 YES,然後就可以通過 batteryState 和 batteryLevel 獲取電池資訊。
優點:api簡單,易於使用。
缺點:粗粒度,能夠採集到的資料較少,不符合需求。
2、 IOKit: 是一個iOS 系統的私有框架,它可以被用來獲取硬體和裝置的詳細資訊,也是與硬體和核心服務通訊的底層框架。通過它可以獲取裝置電量資訊,精確度達到1%。
優點:可以獲取較多的電池相關的資料。
缺點:因為要訪問私有api,不能通過蘋果稽核,只能在線下取值。 獲取到的值是裝置的電池資料,無法達到應用級別的資料獲取。
3、 越獄方案:通過iOSDiagnosticsSupport 私有庫,Runtime 拿到 MBSDevice 例項,獲取電量日誌資訊表,日誌資訊表中包含了 iOS 系統採集的小時級別的耗電量。
優點:可以獲取到應用的耗電量。
缺點:獲取到的耗電量是以小時為單位的,時間間隔太長,不符合需求。
最後,為了能夠採集更多的電池資料,我們選擇的方案是通過訪問IOKit的私有api獲取資料,並且在提交到app store時將這部門程式碼從包裡移除掉,以免影響app的稽核結果。
核心程式碼如下:
void *handle = dlopen("/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit",RTLD_LAZY);
s_IORegistryEntryCreateCFProperties = dlsym(handle,"IORegistryEntryCreateCFProperties");
s_kIOMasterPortDefault = dlsym(handle, "kIOMasterPortDefault");
s_IOServiceMatching= dlsym(handle, "IOServiceMatching");
s_IOServiceGetMatchingService = dlsym(handle,"IOServiceGetMatchingService");
if(s_IORegistryEntryCreateCFProperties && s_IOServiceMatching &&s_IOServiceGetMatchingService) {
g_powerSourceService = s_IOServiceMatching("IOPMPowerSource");
g_platformExpertDevice =s_IOServiceGetMatchingService(*s_kIOMasterPortDefault,g_powerSourceService);
foundSymbols = (g_powerSourceService && g_platformExpertDevice);
}
CFMutableDictionaryRef prop = NULL;
s_IORegistryEntryCreateCFProperties(g_platformExpertDevice,∝,0,0);
通過以上方式可以獲取到的資料包括但不限於:
當前充電狀態
電量
是否連線USB(支援iOS10以下系統)
是否有電池
最大值
電壓
溫度(支援iOS10以下系統)
CPU資料
iOS的執行緒技術是基於Mach 執行緒技術實現的,在 Mach 層中thread_basic_info 結構體提供了執行緒的基本資訊,並且每個執行緒中包含執行緒的cpu_usage。獲取當前App的佔用率就是所有執行緒的cpu_usage之和。
struct thread_basic_info {
time_value_tuser_time;/* user run time */
time_value_tsystem_time;/* system run time */
integer_tcpu_usage;/* scaled cpu usage percentage */
policy_tpolicy;/* scheduling policy in effect */
integer_trun_state;/* run state (see below) */
integer_tflags;/* various flags (see below) */
integer_tsuspend_count;/* suspend count for thread */
integer_tsleep_time;/* number of seconds that thread has been sleeping */
};
通常一個 task 包含多個執行緒,在核心提供了 task_threads API 呼叫獲取指定 task 的執行緒列表以及執行緒個數,也就是target_task 任務中的所有執行緒儲存在 act_list 陣列中,陣列中包含 act_listCnt 個條目。然後可以通過 thread_info API 呼叫來查詢指定執行緒的資訊。
{
task_ttarget_task,
thread_act_array_t*act_list,
mach_msg_type_number_t*act_listCnt
};
因此,獲取當前APP的CPU佔用率需要遍歷所有執行緒,將cpu_usage求和。
接下來就是獲取當前裝置的CPU總佔用率。 iOS中CPU狀態一般包括CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLE 和 CPU_STATE_NICE等四種。
1、CPU_STATE_USER:執行在使用者態空間或者說是使用者程序。
2、CPU_STATE_SYSTEM:在核心空間執行的分配記憶體、IO操作、建立子程序……等。
3、CPU_STATE_IDLE:空閒狀態。
4、CPU_STATE_NICE:使用者空間程序的CPU的排程優先順序。
因此,除了空閒狀態都屬於CPU佔用狀態,因此當前CPU的總使用率為(使用者+系統+排程)/(使用者+系統+排程+空閒)。通過host_statistics獲取host_cpu_load_info結構體資料,該結構體中 cpu_ticks 包含了 CPU 執行時四種不同該狀態的時鐘脈衝的數量,並且根據這四個不同狀態的時間脈衝,計算出CPU的總佔用率。
inttotalUsage= (int)(user + nice + system) * 100.0 / total;
記憶體資料
獲取記憶體資料同樣也可以通過mach_task_basic_info結構獲取resident_size值作為當前App已佔用的記憶體大小。
struct mach_task_basic_info {
mach_vm_size_tvirtual_size;/* virtual memory size (bytes) */
mach_vm_size_tresident_size;/* resident memory size (bytes) */
mach_vm_size_tresident_size_max;/* maximum resident memory size (bytes) */
time_value_tuser_time;/* total user run time for terminated threads */
time_value_tsystem_time;/* total system run time for terminated threads */
policy_tpolicy;/* default policy for new threads */
integer_tsuspend_count;/* suspend count for task */
}
但是在測試中發現,通過該結構體獲取的值與Xcode中的記憶體資料對不上,往往差好幾兆甚至好幾十兆。因此通過查詢資料,有一篇文章介紹通過逆向Xcode來獲取Xcode計算記憶體方法以及結構體。該方法獲取到的已佔用記憶體大小與Xcode的值幾乎一致,可以作為一個判斷標準。具體參考程式碼如下:
-(uint64_t)getCurMemory{
mach_msg_type_number_tinfo_count;
task_vm_info_data_t vm_info;
uint64_t curMem;
info_count = TASK_VM_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO_PURGEABLE, (task_info_t)&vm_info,&info_count);
if (kr ==KERN_SUCCESS) {
curMem; = vm_info.internal + vm_info.compressed -vm_info.purgeable_volatile_pmap;
}
return curMem;
}
接下來,如果要獲取當前裝置可以使用的空閒記憶體,首先要了解iOS系統的記憶體分配。
Free Memory:未使用的 RAM 容量,隨時可以被應用分配使用。
Wired Memory:用來存放核心程式碼和資料結構,它主要為核心服務,如負責網路、檔案系統之類的;對於應用、framework、一些使用者級別的軟體是沒辦法分配此記憶體的。但是應用程式也會對 Wired Memory 的分配有所影響。
Active Memory:活躍的記憶體,正在被使用或很短時間內被使用過。
Inactive Memory:最近被使用過,但是目前處於不活躍狀態。
Purgeable Memory:可以理解為可釋放的記憶體,主要是大物件或大記憶體塊才可以使用的記憶體,此記憶體會在記憶體緊張的時候自動釋放掉。
因此,空閒記憶體看成總記憶體大小減去 Wired Memory大小,Active Memory大小以及Inactive Memory大小。在32位系統通過這種方式獲取空閒記憶體與Xcode資料作比較誤差範圍較小,而在64位系統上的資料與Xcode資料一比較誤差較大,同樣找到一個逆向Xcode獲取Xcode的計算記憶體方法。64位系統獲取空閒記憶體的具體程式碼如下:
vm_statistics64_data_t vminfo;
mach_msg_type_number_t count = HOST_VM_INFO64_COUNT;
host_statistics64(mach_host_self(), HOST_VM_INFO64, (host_info64_t)&vminfo,&count);
uint64_t total_used_count = (physical_memory /pagesize) - (vminfo.free_count - vminfo.speculative_count) - vminfo.external_page_count - vminfo.purgeable_count;
uint64_t free_size = ((physical_memory / pagesize) -total_used_count) * pagesize;
通過以上方式獲取到的記憶體值與Xcode上的資料幾乎一致。
卡頓資料
檢測卡頓資料的方式通常有兩種:一種是FPS卡頓檢測,另一種是主執行緒卡頓檢測。
FPS卡頓檢測:檢測當前頁面的幀率,幀率越高意味著介面越流暢,通過計算丟幀率來檢測當前頁面的卡頓情況。
主執行緒卡頓檢測:通過開闢一個子執行緒來監控主執行緒的RunLoop,當兩個狀態區域之間的耗時大於閾值時,就記為發生一次卡頓。
一、FPS卡頓檢測
目前我們要採集的方主要是基於CADisplayLink以螢幕重新整理頻率同步繪圖的特性,觀察螢幕當前幀數的指示器,若幀率少於指定的幀率看成一個FPS卡頓。具體程式碼如下:
- (void)setupDisplayLink{
if (!_displayLink){
//建立CADisplayLink,並新增到當前run loop的NSRunLoopCommonModes
_displayLink = [CADisplayLink displayLinkWithTarget:selfselector:@selector(linkTicks:)];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop]forMode:NSRunLoopCommonModes];
}
}
-(void)linkTicks:(CADisplayLink *)link{
_scheduleTimes++; //執行次數
if(_timestamp== 0){//當前時間戳
_timestamp = link.timestamp;
}
CFTimeIntervaltimePassed = link.timestamp - _timestamp;
if(timePassed>= 1.f){//一秒採集一次
CGFloat fps = _scheduleTimes/timePassed;
_fps = fps;
_timestamp = link.timestamp;
_scheduleTimes = 0;
}
}
但是基於CADisplayLink實現的 FPS 在生產場景中只有指導意義,不能代表真實的 FPS,它無法完全檢測出當前Core Animation的效能情況。
二、主執行緒卡頓檢測
在主執行緒在Runloop的某個階段進行長時間的耗時操作,因此主要思路就是開闢一個子執行緒去計算kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting兩個狀態區域之間的耗時是否超過某個閥值來斷定主執行緒的卡頓情況。
那麼,為什麼要用 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 狀態進行判定呢?首先 要理清楚Runloop的執行機制,以下為RunLoop 順序:
看完RunLoop順序,就可以看到處理事件主要有兩個時間段 — kCFRunLoopBeforeSources 傳送之後與 kCFRunLoopAfterWaiting 傳送之後。dispatch_semaphore_t 是一個訊號量機制,訊號量到達或者超時會繼續向下進行。若超時則返回的結果必定不為0,若訊號量到達返回的結果為0。利用這個特性我們判斷卡頓出現的條件為在訊號量傳送 kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting後進行了大量的操作,在一段時間內沒有再發送訊號量,看成超時。也就是主執行緒長時間的停留在這兩個狀態上。轉換為程式碼就是判斷有沒有超時,若超時了,再判斷當前停留的狀態是不是這兩個狀態,如果是,就判定為卡頓。具體參考程式碼如下:
_observer= CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
dispatch_async(dispatch_get_global_queue(0, 0),^{
while (!weakSelf.needStop){
// 假定單次超時250ms,看成卡頓
long st = dispatch_semaphore_wait(_semaphore,dispatch_time(DISPATCH_TIME_NOW, 250*NSEC_PER_MSEC));
if (st != 0){
if (_activity==kCFRunLoopBeforeSources || _activity == kCFRunLoopAfterWaiting){
NSLog(@"看成一個等待時間超多250ms的卡頓");
}
}
...
}
});
流量資料
流量資料主要統計在當前App內發生的所有網路請求相應的資料大小。首先,先通過facebook提供的sonar框架捕捉app內的所有request和response。在實際的網路請求中 Request 和 Response 不一定是成對的,如果網路斷開、或者突然關閉程序,都會導致不成對現象,如果將 Request 和 Response 記錄在同一條資料,將會對統計造成偏差。因此request和response分開統計流量。若有對SonarKit框架感興趣的同學可以直接訪問官網進一步瞭解,官網:https://fbsonar.com/docs/getting-started.html
一、統計Request流量
首先需要了解請求報文的組成,如圖:
那麼,Request所花費的流量就是將把Line的大小,Header的大小,空格以及Body大小累加的合。
1、Line大小的統計
Line沒有可以直接轉換成 CFNetwork相關資料的私有介面,但是我們很清楚 HTTP 請求報文 Line 部分的組成,因此可以手動計算Line的大小。
- (uint64_t)wbge_getLineLength {
NSString *lineStr =[NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod,self.URL.path, @"HTTP/1.1"];
NSData *lineData =[lineStr dataUsingEncoding:NSUTF8StringEncoding];
return lineData.length;
}
2、Header 大小統計
通過request.allHTTPHeaderFields 拿到的頭部資料是有很多缺失的,並不是完整的資料。同時 由於無法直接轉換到 CFNetwork 層,所以一直拿不到完整的 Header 資料。 缺少的數包括但不限於以下幾個欄位: Accept,Connection,Host, 當前Request的Cookie。由於 基本上缺失的都是固定的幾個欄位,忽略這幾個欄位對統計的結果影響不大。 因此主要針對cookie的資料並且手動大小進行補全。 因此總Header 的大小可以看成request.allHTTPHeaderFields資料大小加上cookie大小。
3、Body大小統計
最後是body部分,通過resquest.HTTPBody來計算Body大小。這裡要注意的地方就是通過 NSURLConnection 發出的網路請求 resquest.HTTPBody 拿到的是 nil。需要通過 HTTPBodyStream 讀取 stream 來獲取 request 的 Body 大小。
最後,將Line大小,Header大小,Body大小相加就是當前request所話費的流量。
二、統計Response流量
請求報文的組成如下:
那麼Response所花費的流量就是將把Status Line的大小,Header的大小,空格以及Body大小累加的合。
1、StatusLine大小
NSURLResponse沒有介面能直接獲取報文中的 Status Line。因此,最後通過轉換到 CFNetwork 相關類拿到了Status Line 的資料後計算它的大小,這其中可能涉及到了讀取私有 API,因此需要注意稽核問題。
2、Header大小
通過 httpResponse.allHeaderFields拿到 Header 字典,轉換成 NSData 計算大小。
3、Body大小
對於 Body 的計算,採用 expectedContentLength 或者去 NSURLResponse 物件的 allHeaderFields 中獲取 Content-Length 值,其實都不夠準確。Content-Length 只是表示 Body 部分的大小,因此採取直接獲取body大小的方式。還有一個需要注意對 gzip 情況進行區別分析。我們知道 HTTP 請求中,客戶端在傳送請求的時候會帶上 Accept-Encoding,這個欄位的值將會告知伺服器客戶端能夠理解的內容壓縮演算法。而伺服器進行相應時,會在 Response 中新增 Content-Encoding 告知客戶端選中的壓縮演算法。若Content-Encoding使用了 gzip,則模擬一次 gzip 壓縮,再計算位元組大小。
冷啟動時間
App的冷啟動就是,當應用啟動時,後臺沒有該應用的程序,這時系統會重新建立一個新的程序分配給該應用, 這個啟動方式就叫做冷啟動(後臺不存在該應用程序)。下面先看看蘋果官方文件給的應用的啟動時序圖,圖中可以看到冷啟動是一個User taps app icon到Final initialization(applicationDidFinishLaunching: withOptions:)的過程,所以冷啟動時間就是從使用者喚醒App開始一直到App已啟動所消耗的時間。
因此,冷啟動時間 =DidLauching時間 - main()函式執行之前的時間。類的+ load方法在main函式執行之前呼叫,所以我們採取在+ load方法記錄開始時間的方案。具體參考程式碼如下:
+(void)load{
NSTimeIntervalstartTime = [[NSDate date] timeIntervalSince1970];
NSString* appStartTime = [NSStringstringWithFormat:@"%0.0f",startTime*1000.0];
NSLog(@"main()函式執行之前的時間為%@",appStartTime);
}
當applicationDidFinishLaunching:withOptions:方法執行完畢後,新增一個回撥獲取App DidFinishLaunching後的 時間。並且將開始時間與load開始時間相減作為應用冷啟動時間。
總結
以上介紹了iOS中通過程式碼採集效能資料的方案,目前還在繼續優化採集方案,希望本文章能夠幫助大家對iOS效能資料採集的瞭解。
參考文章:
https://fbsonar.com/docs/getting-started.html
http://www.cocoachina.com/ios/20170629/19680.html
http://www.cocoachina.com/ios/20180606/23691.html
https://cloud.tencent.com/developer/article/1006222
https://www.jianshu.com/p/6c10ca55d343
http://ddrccw.github.io/2017/12/30/2017-12-30-reverse-xcode-with-lldb-and-hopper-disassembler/
https://www.jianshu.com/p/8e764d05275b?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation