一、前言
iOS研發助手DoraemonKit技術實現(一)中介紹了幾個常用工具集的技術實現,大家如果有疑問的話,可以在文章中進行留言,也希望大家接入試用,或者加入到DoraemonKit交流群一起交流。
效能問題極大程度的會影響到使用者的體驗,對於我們開發者和測試同學要隨時隨地保證我們app的質量,避免不好的體驗帶來使用者的流失。本篇文章我們來講一下,效能監控的幾款工具的技術實現。主要包括,幀率監控、CPU監控、記憶體監控、流量監控、卡頓監控和自定義監控這幾個功能。
有人說幀率、CPU和記憶體這些資訊我們都可以在Xcode中的Instruments工具進行聯調的時候可以檢視,為什麼還要在客戶端中列印出來呢?
- 第一、很多測試同學比較關注App質量,但是他們卻沒有Xcode執行環境,他們對於質量資料無法很有效的檢視。
- 第二、App端實時的檢視App的質量資料,不依賴IDE,方便快捷直觀。
- 第三、實時採集效能資料,為後期結合測試平臺產生效能資料包表提供資料來源。
二、技術實現
3.1:幀率展示
app的流暢度是最直接影響使用者體驗的,如果我們app持續卡頓,會嚴重影響我們app的使用者留存度。所以對於使用者App是否流暢進行監控,能夠讓我們今早的發現我們app的效能問題。對於App流暢度最直觀最簡單的監控手段就是對我們App的幀率進行監控。
幀率(FPS)是指畫面每秒傳輸幀數,通俗來講就是指動畫或視訊的畫面數。FPS是測量用於儲存、顯示動態視訊的資訊數量。每秒鐘幀數愈多,所顯示的動作就會越流暢。對於我們App開發來說,我們要保持FPS高於50以上,使用者體驗才會流暢。
在YYKit Demo工程中有一個工具類叫YYFPSLabel,它是基於CADisplayLink這個類做FPS計算的,CADisplayLink是CoreAnimation提供的另一個類似於NSTimer的類,它會在螢幕每次重新整理回撥一次。既然CADisplayLink可以以螢幕重新整理的頻率呼叫指定selector,而且iOS系統中正常的螢幕重新整理率為60Hz(60次每秒),那隻要在這個方法裡面統計每秒這個方法執行的次數,通過次數/時間就可以得出當前螢幕的重新整理率了。
大致實現思路如下:
- (void)startRecord{
if (_link) {
_link.paused = NO;
}else{
_link = [CADisplayLink displayLinkWithTarget:self selector:@selector(trigger:)];
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
_record = [DoraemonRecordModel instanceWithType:DoraemonRecordTypeFPS];
_record.startTime = [[NSDate date] timeIntervalSince1970];
}
}
- (void)trigger:(CADisplayLink *)link{
if (_lastTime == 0) {
_lastTime = link.timestamp;
return;
}
_count++;
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
CGFloat fps = _count / delta;
_count = 0;
NSInteger intFps = (NSInteger)(fps+0.5);
// 0~60 對應 高度0~200
[self.record addRecordValue:fps time:[[NSDate date] timeIntervalSince1970]];
[_oscillogramView addHeightValue:fps*200./60. andTipValue:[NSString stringWithFormat:@"%zi",intFps]];
}
複製程式碼
值得注意的是基於CADisplayLink實現的 FPS 在生產場景中只有指導意義,不能代表真實的 FPS,因為基於CADisplayLink實現的 FPS 無法完全檢測出當前 Core Animation 的效能情況,它只能檢測出當前 RunLoop 的幀率。但要真正定位到準確的效能問題所在,最好還是通過Instrument來確認。具體原因可以參考iOS中基於CADisplayLink的FPS指示器詳解。
所有程式碼請參考:DorameonKit/Core/Plugin/FPS
3.2:CPU展示
CPU是移動裝置的運算核心和控制核心,如果我們的App的使用率長時間處於高消耗的話,我們的手機會發熱,電量使用加劇,導致App產生卡頓,嚴重影響使用者體驗。所以對於CPU使用率進行實時的監控,也有利於及時的把控我們App的整體質量,阻止不合格的功能上線。
對於app使用率的獲取,網上的方案還是比較統一的。
- 使用task_threads函式,獲取當前App行程中所有的執行緒列表。
- 對於第一步中獲取的執行緒列表進行遍歷,通過thread_info函式獲取每一個非閒置執行緒的cpu使用率,進行相加。
- 使用vm_deallocate函式釋放資源。
程式碼實現如下:
+ (CGFloat)cpuUsageForApp {
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;
// get threads in the task
// 獲取當前程式中 執行緒列表
kr = task_threads(mach_task_self(), &thread_list, &thread_count);
if (kr != KERN_SUCCESS)
return -1;
float tot_cpu = 0;
for (int j = 0; j < thread_count; j++) {
thread_info_count = THREAD_INFO_MAX;
//獲取每一個執行緒資訊
kr = thread_info(thread_list[j], THREAD_BASIC_INFO,
(thread_info_t)thinfo, &thread_info_count);
if (kr != KERN_SUCCESS)
return -1;
basic_info_th = (thread_basic_info_t)thinfo;
if (!(basic_info_th->flags & TH_FLAGS_IDLE)) {
// cpu_usage : Scaled cpu usage percentage. The scale factor is TH_USAGE_SCALE.
//巨集定義TH_USAGE_SCALE返回CPU處理總頻率:
tot_cpu += basic_info_th->cpu_usage / (float)TH_USAGE_SCALE;
}
} // for each thread
// 注意方法最後要呼叫 vm_deallocate,防止出現記憶體洩漏
kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
assert(kr == KERN_SUCCESS);
return tot_cpu;
}
複製程式碼
測試結果基本和Xcode測量出來的cpu使用率是一樣的,還是比較準確的。
所有程式碼請參考:DorameonKit/Core/Plugin/CPU
3.3:記憶體展示
裝置記憶體和CPU一樣都是系統中最稀少的資源,也是最有可能產生競爭的資源,應用記憶體跟app的效能直接相關。如果一個app在前臺消耗記憶體過多,會引起系統強殺,這種現象叫做OOM。表現跟crash一樣,而且這種crash事件無法被捕獲到的。
獲取app消耗的記憶體,剛開始使用的是獲取使用的實體記憶體大小resident_size,網上大部分也是這種方案。
- (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;
}
}
複製程式碼
使用這種方式之後方向,會與Xcode自帶統計記憶體消耗的工具有一些偏差。這個時候,多謝yxjxx同學提交的PRuse phys_footprint to get instruments memory usage。使用phys_footprint代替resident_size獲取的記憶體消耗基本與Xcode自帶的統計工具相同。具體原因可以參考正確地獲取 iOS 應用佔用的記憶體。
修改之後,具體實現的主要程式碼如下:
//當前app消耗的記憶體
+ (NSUInteger)useMemoryForApp{
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if(kernelReturn == KERN_SUCCESS)
{
int64_t memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
return memoryUsageInByte/1024/1024;
}
else
{
return -1;
}
}
//裝置總的記憶體
+ (NSUInteger)totalMemoryForDevice{
return [NSProcessInfo processInfo].physicalMemory/1024/1024;
}
複製程式碼
所有程式碼請參考:DorameonKit/Core/Plugin/Memory
3.4:流量監控
線上下開發階段,我們開發要和服務端聯調結果,我們需要Xcode斷點除錯伺服器返回的結果是否正確。測試階段,測試同學會通過Charles設定代理檢視結果,這些操作都需要依賴第三方工具才能實現流量監控。能不能有一個工具,能夠隨身攜帶,對流量進行監控攔截,能夠方便我們很多。我們DoraemonKit就做了這件事。
對於流量監控,業界基本有以上幾個方案:
方案1 : 騰訊GT的方案,監控系統的上行流量和下行流量。這樣監控的話,力度太粗了,不能得到每一個app的流量統計,更不能的得到每一個介面的流量和統計,不符合我們的需求。
方案2 : 浸入業務方自己的網路庫,做流量統計,這種方案可以做的非常細節,但是不是特別通用。我們公司內部omega監控平臺就是這麼做的,omega的流量監控程式碼是寫在OneNetworking中的。不是特別通用。比如我們杭州團隊的網路庫是自研的,如果要接入omega的網路監控功能,就需要在自己的網路庫中,寫流量統計程式碼。
方案3 : hook系統底層網路庫,這種方式比較通用,但是非常繁瑣,需要hook很多個類和方法。阿里有篇文件化介紹了他們流量監控的方案,就是採用這種,下面這張圖我擷取過來的,看一下,還是比較複雜的。
方案4 : 也是DoraemonKit採用的方案,使用iOS中一個非常強大的類,叫NSURLProtocol,這個類可以攔截NSURLConnection、NSUrlSession、UIWebView中所有的網路請求,獲取每一個網路請求的request和response物件。但是這個類無法攔截tcp的請求,這個是他的缺點。美團的內部監控工具赫茲就是基於該類進行處理的。之餘這個類具體怎麼使用,由於時間原因,我在這裡就不說,我想大家推薦一下我的部落格,我有篇文章專門寫了這個類的使用。
下面就是DoraemonKit中NSURLProtocol的具體實現:
@interface DoraemonNSURLProtocol()<NSURLConnectionDelegate,NSURLConnectionDataDelegate>
@property (nonatomic, strong) NSURLConnection *connection;
@property (nonatomic, assign) NSTimeInterval startTime;
@property (nonatomic, strong) NSURLResponse *response;
@property (nonatomic, strong) NSMutableData *data;
@property (nonatomic, strong) NSError *error;
@end
@implementation DoraemonNSURLProtocol
+ (BOOL)canInitWithRequest:(NSURLRequest *)request{
if ([NSURLProtocol propertyForKey:kDoraemonProtocolKey inRequest:request]) {
return NO;
}
if (![DoraemonNetFlowManager shareInstance].canIntercept) {
return NO;
}
if (![request.URL.scheme isEqualToString:@"http"] &&
![request.URL.scheme isEqualToString:@"https"]) {
return NO;
}
//NSLog(@"DoraemonNSURLProtocol == %@",request.URL.absoluteString);
return YES;
}
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request{
//NSLog(@"canonicalRequestForRequest");
NSMutableURLRequest *mutableReqeust = [request mutableCopy];
[NSURLProtocol setProperty:@YES forKey:kDoraemonProtocolKey inRequest:mutableReqeust];
return [mutableReqeust copy];
}
- (void)startLoading{
//NSLog(@"startLoading");
self.connection = [[NSURLConnection alloc] initWithRequest:[[self class] canonicalRequestForRequest:self.request] delegate:self];
[self.connection start];
self.data = [NSMutableData data];
self.startTime = [[NSDate date] timeIntervalSince1970];
}
- (void)stopLoading{
//NSLog(@"stopLoading");
[self.connection cancel];
DoraemonNetFlowHttpModel *httpModel = [DoraemonNetFlowHttpModel dealWithResponseData:self.data response:self.response request:self.request];
if (!self.response) {
httpModel.statusCode = self.error.localizedDescription;
}
httpModel.startTime = self.startTime;
httpModel.endTime = [[NSDate date] timeIntervalSince1970];
httpModel.totalDuration = [NSString stringWithFormat:@"%f",[[NSDate date] timeIntervalSince1970] - self.startTime];
[[DoraemonNetFlowDataSource shareInstance] addHttpModel:httpModel];
}
#pragma mark - NSURLConnectionDelegate
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error{
[[self client] URLProtocol:self didFailWithError:error];
self.error = error;
}
- (BOOL)connectionShouldUseCredentialStorage:(NSURLConnection *)connection {
return YES;
}
- (void)connection:(NSURLConnection *)connection didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
[[self client] URLProtocol:self didReceiveAuthenticationChallenge:challenge];
}
- (void)connection:(NSURLConnection *)connection didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
[[self client] URLProtocol:self didCancelAuthenticationChallenge:challenge];
}
#pragma mark - NSURLConnectionDataDelegate
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response{
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
self.response = response;
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data{
[[self client] URLProtocol:self didLoadData:data];
[self.data appendData:data];
}
- (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse{
return cachedResponse;
}
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
[[self client] URLProtocolDidFinishLoading:self];
}
複製程式碼
所有程式碼請參考:DorameonKit/Core/Plugin/NetFlow
3.5:自定義監控
以上所有的操作都是針對於單個指標,無法提供一套全面的監控資料,自定義監控可以選擇你需要監控的資料,目前包括幀率、CPU使用率、記憶體使用量和流量監控,這些監控沒有波形圖進行顯示,均在後臺進行監控,測試完畢,會把這些資料上傳到我們後臺進行分析。
因為目前後臺是基於我們內部平臺上開發的,暫時不提供開源。不過後續的話,我們也會考慮將後臺的功能的功能對外提供,請大家拭目以待。對於開源版本的話,目前效能測試的結果儲存在沙盒Library/Caches/DoraemonPerformance中,使用者可以使用沙盒瀏覽器功能匯出來之後自己進行分析。
所有程式碼請參考:DorameonKit/Core/Plugin/AllTest
三、總結
寫這篇文章主要是為了能夠讓大家對於DorameonKit進行快速的瞭解,大家如果有什麼好的想法,或者發現我們的這個專案有bug,歡迎大家去github上提Issues或者直接Pull requests,我們會第一時間處理,也可以加入我們的qq交流群進行交流,也希望我們這個工具集合能在大家的一起努力下,繼續做大做好。
如果大家覺得我們這個專案還可以的話,點上一顆star吧。
DoraemonKit專案地址:github.com/didi/Doraem…