1. 概述
iOS 客戶端的應用性能數據監控一般包括如下指標
- 卡頓監測
- FPS 采集
- CPU 采集
- Memory 采集
- 冷啟動測速
- 流量監控
而我們關注監控技術的目的,通常是為了開發一套相關的監控 SDK 或者功能,需要了解各個監控指標的監控手段和原理;因此這里將記錄各個監控指標的基本原理和機制,不過多涉及具體的代碼實現,大部分監控代碼能玩的花樣不多,延展出去的監控數據展示、持久化與上報機制又遠遠比監控本身復雜,此處就不贅述。
2. 卡頓檢測
卡頓監控需要利用信號量,對主線程 Runloop 加入 observer 進行監聽,通過信號量等待機制,檢測出主線程 Runloop 卡頓情況,進行上報。
2.1 加入監聽
CFRunLoopActivity observedActivities = kCFRunLoopBeforeSources | kCFRunLoopBeforeWaiting | kCFRunLoopAfterWaiting;
_runloopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, observedActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
__strong __typeof(weakSelf)strongSelf = weakSelf;
if (strongSelf.semaphore != NULL) {
dispatch_semaphore_signal(strongSelf.semaphore);
}
});
CFRunLoopAddObserver(CFRunLoopGetMain(), _runloopObserver, kCFRunLoopCommonModes);
CFRelease(_runloopObserver);
此處主要監聽 Runloop 的三個 activity,beforeSources,beforeWaiting 和 afterWaiting,原因是根據 Runloop 內部執行順序,具體見下圖
Runloop 執行 Source0,Source1,MainQueue,Timer 和 Block 的階段均在這三個時機之間,因此對三個時機插點,就可以監控出執行卡頓的問題。
2.2 信號量等待機制
while (!self.cancelled) {
long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, self.threshold * NSEC_PER_MSEC));
if (status != 0) {
if (self.callback) {
self.callback();
}
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
}
}
此處利用 dispatch_semaphore_wait
函數,在一段時間內(一般是3s-5s)等待信號量,假如 Runloop 運行正常則在上面三個時機點均會執行信號量釋放操作,因此如果出現卡頓不能如期釋放信號量,則調用 callback 進行卡頓處理和上報。
dispatch_semaphore_wait
返回為 0 代表信號量獲取成功,否則未能獲取到信號量,此時將永久等待信號量,以確保不再重復上報卡頓。
當然卡頓上報也可以加入次數限制,例如卡頓發生 3 次就不再上報等邏輯。
3. FPS 采集
3.1 基礎原理及步驟
FPS 采集完全依賴于 iOS 提供的 CADisplayLink 類,它提供了屏幕刷新時機,并支持自定義回調,從而獲知到屏幕刷新的時間戳,依據如下公式就可以得到應用的 FPS 信息。
FPS = FrameCount/Duration
因此對于 FPS 監控的基本步驟如下
- 初始化一個 CADisplayLink
[CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)]
- 回調中記錄當前時間戳,記錄與上一幀時間戳間隔,記錄瞬時 FPS,甚至可以記錄自某一時刻開始到當前,總的幀數和總時間間隔,從而計算出平均 FPS
- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
currentTimestamp = displayLink.timestamp;
instantDuration = currentTimestamp - lastTimestamp;
instantFPS = round(1.0/instantDuration);
totalFrameCount++;
totalDuration += instantDuration;
avgFPS = totalFrameCount/totalDuration;
}
但是更進一步,除了關注整體 FPS,我們還可以考慮關注特定 VC,特定 ScrollView,自定義時機的 FPS。
3.2 UIViewController 的 FPS
一個 VC 的 FPS 統計與基礎 FPS 統計無異,唯一要關注的是如何確定統計時機,一般選取如下時機
- viewDidAppear 時開啟當前 VC 的 FPS 統計,關閉其他 VC 的 FPS 統計
- applicationWillResignActive 退出后臺時上報數據,關閉計時器
- applicationDidBecomeActive 進入前臺后重啟計時器,重置數據
當然可監控的 VC 的選取也存在一些規則,大致如下
- 排除 UIViewController 等系統 VC
- 排除 UINavigationController、UITabBarController、UIInputViewController、UIAlertController 等非頁面級的 VC
- 排除一個 UIViewController 內的子 VC
- 排除無父 VC 且不是 present 出來的 VC
這樣排除的考慮是監控 FPS 的實體一般只有一個,同一時刻只針對一個 VC 進行監控,子 VC 等不排除的話可能導致監控數據不合理。當然如果能針對每一個 VC 都加入 FPS 監控就可以解決這一問題,但是這樣會引入額外的統計時機,比如 VC 的 view 需要添加到其他 VC 上以后才應該監控。
3.3 ScrollView 的 FPS
ScrollView 是常用的展示抽象程度較高、數目較大元素的視圖組件,也是 FPS 重災區,在數據處理、渲染、滑動手勢等多處都可能引發掉幀現象,因此有必要對其進行 FPS 監控。
ScrollView 的具體監控依賴于 UIScrollView 的兩個屬性
- isDragging 用戶開始滑動 ScrollView
- isDecelerating 用戶停止滑動,但 ScrollView 仍在滾動中
通過 CADisplayLink 回調中檢查當前監控的 UIScrollView 實例的兩個狀態,與其前一次狀態對比,進行如下邏輯
- 由未滑動進入到滑動狀態,初始化 FPS 數據
- 滑動中,更新統計幀數和統計總時間間隔
- 由滑動狀態進入到未滑動狀態,上報 FPS 數據
在這一過程中也可以加入當前 ScrollView 所屬 VC 的信息方便后續排查。
3.4 自定義 FPS 時機
自定義時機更加靈活,只需要明確統計開始點和結束點,即可按照 FPS 基本原理進行統計。
- (void)startRecordWithIdentifier:(NSString *)identifier;
- (void)stopRecordWithIdentifier:(NSString *)identifier;
4. CPU 采集
iOS 是基于 Apple Darwin 內核,由 kernel、XNU 和 Runtime 組成,而 XNU 是 Darwin 的內核,它是“X is not UNIX”的縮寫,是一個混合內核,由 Mach 微內核和 BSD 組成。Mach 內核是輕量級的平臺,只能完成操作系統最基本的職責,比如:進程和線程、虛擬內存管理、任務調度、進程通信和消息傳遞機制。其他的工作,例如文件操作和設備訪問,都由 BSD 層實現。
在 Mach 層中定義了一個 thread_basic_info
結構體,提供了線程的基本信息
struct thread_basic_info {
time_value_t user_time; /* user run time */
time_value_t system_time; /* system run time */
integer_t cpu_usage; /* scaled cpu usage percentage */
policy_t policy; /* scheduling policy in effect */
integer_t run_state; /* run state (see below) */
integer_t flags; /* various flags (see below) */
integer_t suspend_count; /* suspend count for thread */
integer_t sleep_time; /* number of seconds that thread
has been sleeping */
};
其中就有我們所需要的 cpu_usage
字段,因此如果獲知了組成當前應用進程的所有線程的 thread_basic_info
,就可以統計出 CPU 使用情況了。
在 Mach 層,一個應用進程嚴格關聯一個 Mach Task 對象,通過如下函數可以獲知當前應用所在進程的全部線程信息
thread_array_t thread_list;
mach_msg_type_number_t thread_count;
thread_info_data_t thinfo;
mach_msg_type_number_t thread_info_count;
thread_basic_info_t basic_info_th;
kern_return_t kr = task_threads(mach_task_self(), &thread_list, &thread_count);
if (kr != KERN_SUCCESS) {
return -1;
}
接下來遍歷整個 thread_list
,算出 CPU 總和
CGFloat total_cpu = 0;
for (int j = 0; j < thread_count; j++)
{
thread_info_count = THREAD_INFO_MAX;
kr = thread_info(thread_list[j], THREAD_BASIC_INFO,(thread_info_t)thinfo, &thread_info_count);
if (kr != KERN_SUCCESS) {
return -1;
}
basic_info_th = (thread_basic_info_t)thinfo;
if (!(basic_info_th->flags & TH_FLAGS_IDLE)) {
total_cpu = total_cpu + basic_info_th->cpu_usage / (CGFloat)TH_USAGE_SCALE * 100.0;
}
}
這里通過 thread_info
函數,將一個 thread 的基礎信息(BASIC_INFO
)讀入到 thinfo 中,最終獲取到的 cpu_usage 還需要除以 TH_USAGE_SCALE
(CPU處理總頻率),從而得到 CPU 占比。
此處由于我們創建了一個 thread_list
結構體,因此需要手動釋放掉,以避免泄漏內存
kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
assert(kr == KERN_SUCCESS);
獲得了瞬時 CPU 占比,可以啟動一個定時器定期(1s)采集數據,最終匯總出最大占比和平均占比等數據。
5. Memory 采集
上一節提到一個應用進程對應于一個 Mach Task,而 thread_info
也可以獲取到當前進程的所有數據,它們均定義在一個 mach_task_basic_info
結構體中
struct mach_task_basic_info {
mach_vm_size_t virtual_size; /* virtual memory size (bytes) */
mach_vm_size_t resident_size; /* resident memory size (bytes) */
mach_vm_size_t resident_size_max; /* maximum resident memory size (bytes) */
time_value_t user_time; /* total user run time for
terminated threads */
time_value_t system_time; /* total system run time for
terminated threads */
policy_t policy; /* default policy for new threads */
integer_t suspend_count; /* suspend count for task */
};
注釋寫的也很清楚,這里 resident_size
即代表了物理內存使用情況。
所以獲取方式如下
struct mach_task_basic_info info;
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)& info, &count);
return (kr == KERN_SUCCESS) ? info.resident_size : 0;
這里我們返回的是 Byte 單位的內存占用,因而還需要進行一些數學運算以簡化數字展示。
但是實際上通過此方法并不能夠獲取到與 Xcode 上的 Memory 一樣的參數,就觀察來看它比 Xcode 的統計數據要大很多。這里還有另一種 方法,它獲取到的內存占用值更加貼合于 Xcode 的統計值
+ (double)getMemoryUsage {
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
if(task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count) == KERN_SUCCESS) {
return (double)vmInfo.phys_footprint;
} else {
return -1.0;
}
}
而 iOS 的內存殺手 Jetsam 也是通過 phys_footprint
這一參數來獲知內存使用是否達到上界的。
6. 冷啟動測速
冷啟動測速很多時候都與打點密不可分,通常來說我們會在以下一系列地方進行打點獲知啟動流程
- main 函數
- AppDelegate 代理方法
- homePage 首頁
但是在 main 函數執行前其實也有很大一部分耗時工作需要執行,例如
- 加載可執行文件
- 加載動態鏈接庫
- 初始化 Runtime
- +load 函數
完整示意圖如下
所以從 main 函數開始計時是與真實情況不夠貼合的,更早的時間點獲取方式有以下 3 種
- 以可執行文件中任意一個類的 +load 方法的執行時間作為起始點
- 分析 dylib 的依賴關系,找到葉子節點的 dylib,然后以其中某個類的 +load 方法的執行時間作為起始點
- 以 App 的進程創建時間(即 exec 函數執行時間)作為冷啟動的起始時間,通過 sysctl 函數獲取
這三者里,第三個方式的時間戳統計最早,而且目前未發現更早更準確且更有意義的起始點
#import <sys/sysctl.h>
#import <mach/mach.h>
+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(*procInfo);
return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
+ (NSTimeInterval)processStartTime
{
struct kinfo_proc kProcInfo;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
} else {
NSAssert(NO, @"無法取得進程的信息");
return 0;
}
}
有了起始點,其他打點就可以依次相減得到每一段的具體耗時了。
這里需要補充一點,假如應用執行了安裝后啟動的操作,例如模擬器上進行編譯調試,sysctl 獲取的時間戳會從安裝起始點開始計算,當然這對于實際使用來說影響不大。
7. 流量監控
流量監控主要需要關注的點有以下四個
- URL,毋庸置疑,監控出問題后需要 URL 來排查
- requestSize,請求大小,具體包括 URL 長度、 header 長度和 body 長度,實際上嚴格意義上 Method 字段和 Version 字段也需要考慮,但是考慮到它們都是固定長度且占比較小所以不計
NSURL *URL = request.URL;
NSUInteger URLLength = URL.absoluteString.length;
NSUInteger requestHeaderLength = 0;
if (request && [NSJSONSerialization isValidJSONObject:[request allHTTPHeaderFields]]) {
requestHeaderLength = [NSJSONSerialization dataWithJSONObject:[request allHTTPHeaderFields] options:0 error:NULL].length;
}
NSUInteger requestBodyLength = request.HTTPBody.length;
NSUInteger requestSize = URLLength + requestHeaderLength + requestBodyLength;
- responseSize,響應大小,具體包括 header 長度和 body 長度
NSUInteger responseHeaderLength = 0;
if (response && [NSJSONSerialization isValidJSONObject:[response allHeaderFields]]) {
responseHeaderLength = [NSJSONSerialization dataWithJSONObject:[(NSHTTPURLResponse *)response allHeaderFields] options:0 error:NULL].length;
}
NSUInteger responseSize = responseHeaderLength + responseDataLength;
- type,請求類型,具體可以分為
- Web - H5頁面,一般來說它的 MIMEType 會是這幾種 "text/css","text/html","application/x-javascript","application/javascript"
- API - Native 側進行 API 接口請求
- Resource - Native 側進行多媒體資源等資源數據請求,與 API 的區分需要從 URLHost 上著手
- Other
當然流量數據的特點是頻率高、次數多、體積不定,所以做好緩存和批次上報、壓縮上報等工作也是必不可少的。