1. 概述
iOS 客戶端的應(yīng)用性能數(shù)據(jù)監(jiān)控一般包括如下指標(biāo)
- 卡頓監(jiān)測(cè)
- FPS 采集
- CPU 采集
- Memory 采集
- 冷啟動(dòng)測(cè)速
- 流量監(jiān)控
而我們關(guān)注監(jiān)控技術(shù)的目的枕屉,通常是為了開(kāi)發(fā)一套相關(guān)的監(jiān)控 SDK 或者功能,需要了解各個(gè)監(jiān)控指標(biāo)的監(jiān)控手段和原理;因此這里將記錄各個(gè)監(jiān)控指標(biāo)的基本原理和機(jī)制猫缭,不過(guò)多涉及具體的代碼實(shí)現(xiàn),大部分監(jiān)控代碼能玩的花樣不多捣染,延展出去的監(jiān)控?cái)?shù)據(jù)展示冤议、持久化與上報(bào)機(jī)制又遠(yuǎn)遠(yuǎn)比監(jiān)控本身復(fù)雜旬迹,此處就不贅述。
2. 卡頓檢測(cè)
卡頓監(jiān)控需要利用信號(hào)量求类,對(duì)主線程 Runloop 加入 observer 進(jìn)行監(jiān)聽(tīng)奔垦,通過(guò)信號(hào)量等待機(jī)制,檢測(cè)出主線程 Runloop 卡頓情況尸疆,進(jìn)行上報(bào)椿猎。
2.1 加入監(jiān)聽(tīng)
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);
此處主要監(jiān)聽(tīng) Runloop 的三個(gè) activity惶岭,beforeSources,beforeWaiting 和 afterWaiting犯眠,原因是根據(jù) Runloop 內(nèi)部執(zhí)行順序按灶,具體見(jiàn)下圖
Runloop 執(zhí)行 Source0,Source1筐咧,MainQueue鸯旁,Timer 和 Block 的階段均在這三個(gè)時(shí)機(jī)之間,因此對(duì)三個(gè)時(shí)機(jī)插點(diǎn)量蕊,就可以監(jiān)控出執(zhí)行卡頓的問(wèn)題铺罢。
2.2 信號(hào)量等待機(jī)制
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
函數(shù),在一段時(shí)間內(nèi)(一般是3s-5s)等待信號(hào)量残炮,假如 Runloop 運(yùn)行正常則在上面三個(gè)時(shí)機(jī)點(diǎn)均會(huì)執(zhí)行信號(hào)量釋放操作韭赘,因此如果出現(xiàn)卡頓不能如期釋放信號(hào)量,則調(diào)用 callback 進(jìn)行卡頓處理和上報(bào)势就。
dispatch_semaphore_wait
返回為 0 代表信號(hào)量獲取成功泉瞻,否則未能獲取到信號(hào)量,此時(shí)將永久等待信號(hào)量苞冯,以確保不再重復(fù)上報(bào)卡頓袖牙。
當(dāng)然卡頓上報(bào)也可以加入次數(shù)限制,例如卡頓發(fā)生 3 次就不再上報(bào)等邏輯舅锄。
3. FPS 采集
3.1 基礎(chǔ)原理及步驟
FPS 采集完全依賴于 iOS 提供的 CADisplayLink 類鞭达,它提供了屏幕刷新時(shí)機(jī),并支持自定義回調(diào)巧娱,從而獲知到屏幕刷新的時(shí)間戳,依據(jù)如下公式就可以得到應(yīng)用的 FPS 信息烘贴。
FPS = FrameCount/Duration
因此對(duì)于 FPS 監(jiān)控的基本步驟如下
- 初始化一個(gè) CADisplayLink
[CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)]
- 回調(diào)中記錄當(dāng)前時(shí)間戳禁添,記錄與上一幀時(shí)間戳間隔,記錄瞬時(shí) FPS桨踪,甚至可以記錄自某一時(shí)刻開(kāi)始到當(dāng)前老翘,總的幀數(shù)和總時(shí)間間隔,從而計(jì)算出平均 FPS
- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
currentTimestamp = displayLink.timestamp;
instantDuration = currentTimestamp - lastTimestamp;
instantFPS = round(1.0/instantDuration);
totalFrameCount++;
totalDuration += instantDuration;
avgFPS = totalFrameCount/totalDuration;
}
但是更進(jìn)一步锻离,除了關(guān)注整體 FPS铺峭,我們還可以考慮關(guān)注特定 VC,特定 ScrollView汽纠,自定義時(shí)機(jī)的 FPS卫键。
3.2 UIViewController 的 FPS
一個(gè) VC 的 FPS 統(tǒng)計(jì)與基礎(chǔ) FPS 統(tǒng)計(jì)無(wú)異,唯一要關(guān)注的是如何確定統(tǒng)計(jì)時(shí)機(jī)虱朵,一般選取如下時(shí)機(jī)
- viewDidAppear 時(shí)開(kāi)啟當(dāng)前 VC 的 FPS 統(tǒng)計(jì)莉炉,關(guān)閉其他 VC 的 FPS 統(tǒng)計(jì)
- applicationWillResignActive 退出后臺(tái)時(shí)上報(bào)數(shù)據(jù)钓账,關(guān)閉計(jì)時(shí)器
- applicationDidBecomeActive 進(jìn)入前臺(tái)后重啟計(jì)時(shí)器,重置數(shù)據(jù)
當(dāng)然可監(jiān)控的 VC 的選取也存在一些規(guī)則絮宁,大致如下
- 排除 UIViewController 等系統(tǒng) VC
- 排除 UINavigationController梆暮、UITabBarController、UIInputViewController绍昂、UIAlertController 等非頁(yè)面級(jí)的 VC
- 排除一個(gè) UIViewController 內(nèi)的子 VC
- 排除無(wú)父 VC 且不是 present 出來(lái)的 VC
這樣排除的考慮是監(jiān)控 FPS 的實(shí)體一般只有一個(gè)啦粹,同一時(shí)刻只針對(duì)一個(gè) VC 進(jìn)行監(jiān)控,子 VC 等不排除的話可能導(dǎo)致監(jiān)控?cái)?shù)據(jù)不合理窘游。當(dāng)然如果能針對(duì)每一個(gè) VC 都加入 FPS 監(jiān)控就可以解決這一問(wèn)題唠椭,但是這樣會(huì)引入額外的統(tǒng)計(jì)時(shí)機(jī),比如 VC 的 view 需要添加到其他 VC 上以后才應(yīng)該監(jiān)控张峰。
3.3 ScrollView 的 FPS
ScrollView 是常用的展示抽象程度較高泪蔫、數(shù)目較大元素的視圖組件,也是 FPS 重災(zāi)區(qū)喘批,在數(shù)據(jù)處理撩荣、渲染、滑動(dòng)手勢(shì)等多處都可能引發(fā)掉幀現(xiàn)象饶深,因此有必要對(duì)其進(jìn)行 FPS 監(jiān)控餐曹。
ScrollView 的具體監(jiān)控依賴于 UIScrollView 的兩個(gè)屬性
- isDragging 用戶開(kāi)始滑動(dòng) ScrollView
- isDecelerating 用戶停止滑動(dòng),但 ScrollView 仍在滾動(dòng)中
通過(guò) CADisplayLink 回調(diào)中檢查當(dāng)前監(jiān)控的 UIScrollView 實(shí)例的兩個(gè)狀態(tài)敌厘,與其前一次狀態(tài)對(duì)比台猴,進(jìn)行如下邏輯
- 由未滑動(dòng)進(jìn)入到滑動(dòng)狀態(tài),初始化 FPS 數(shù)據(jù)
- 滑動(dòng)中俱两,更新統(tǒng)計(jì)幀數(shù)和統(tǒng)計(jì)總時(shí)間間隔
- 由滑動(dòng)狀態(tài)進(jìn)入到未滑動(dòng)狀態(tài)饱狂,上報(bào) FPS 數(shù)據(jù)
在這一過(guò)程中也可以加入當(dāng)前 ScrollView 所屬 VC 的信息方便后續(xù)排查。
3.4 自定義 FPS 時(shí)機(jī)
自定義時(shí)機(jī)更加靈活宪彩,只需要明確統(tǒng)計(jì)開(kāi)始點(diǎn)和結(jié)束點(diǎn)休讳,即可按照 FPS 基本原理進(jìn)行統(tǒng)計(jì)。
- (void)startRecordWithIdentifier:(NSString *)identifier;
- (void)stopRecordWithIdentifier:(NSString *)identifier;
4. CPU 采集
iOS 是基于 Apple Darwin 內(nèi)核尿孔,由 kernel俊柔、XNU 和 Runtime 組成,而 XNU 是 Darwin 的內(nèi)核活合,它是“X is not UNIX”的縮寫(xiě)雏婶,是一個(gè)混合內(nèi)核,由 Mach 微內(nèi)核和 BSD 組成白指。Mach 內(nèi)核是輕量級(jí)的平臺(tái)留晚,只能完成操作系統(tǒng)最基本的職責(zé),比如:進(jìn)程和線程告嘲、虛擬內(nèi)存管理倔丈、任務(wù)調(diào)度憨闰、進(jìn)程通信和消息傳遞機(jī)制。其他的工作需五,例如文件操作和設(shè)備訪問(wèn)鹉动,都由 BSD 層實(shí)現(xiàn)。
在 Mach 層中定義了一個(gè) thread_basic_info
結(jié)構(gòu)體宏邮,提供了線程的基本信息
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
字段泽示,因此如果獲知了組成當(dāng)前應(yīng)用進(jìn)程的所有線程的 thread_basic_info
,就可以統(tǒng)計(jì)出 CPU 使用情況了蜜氨。
在 Mach 層械筛,一個(gè)應(yīng)用進(jìn)程嚴(yán)格關(guān)聯(lián)一個(gè) Mach Task 對(duì)象,通過(guò)如下函數(shù)可以獲知當(dāng)前應(yīng)用所在進(jìn)程的全部線程信息
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;
}
接下來(lái)遍歷整個(gè) 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;
}
}
這里通過(guò) thread_info
函數(shù)埋哟,將一個(gè) thread 的基礎(chǔ)信息(BASIC_INFO
)讀入到 thinfo 中,最終獲取到的 cpu_usage 還需要除以 TH_USAGE_SCALE
(CPU處理總頻率)郎汪,從而得到 CPU 占比赤赊。
此處由于我們創(chuàng)建了一個(gè) thread_list
結(jié)構(gòu)體,因此需要手動(dòng)釋放掉煞赢,以避免泄漏內(nèi)存
kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
assert(kr == KERN_SUCCESS);
獲得了瞬時(shí) CPU 占比抛计,可以啟動(dòng)一個(gè)定時(shí)器定期(1s)采集數(shù)據(jù),最終匯總出最大占比和平均占比等數(shù)據(jù)照筑。
5. Memory 采集
上一節(jié)提到一個(gè)應(yīng)用進(jìn)程對(duì)應(yīng)于一個(gè) Mach Task吹截,而 thread_info
也可以獲取到當(dāng)前進(jìn)程的所有數(shù)據(jù),它們均定義在一個(gè) mach_task_basic_info
結(jié)構(gòu)體中
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 */
};
注釋寫(xiě)的也很清楚凝危,這里 resident_size
即代表了物理內(nèi)存使用情況波俄。
所以獲取方式如下
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 單位的內(nèi)存占用,因而還需要進(jìn)行一些數(shù)學(xué)運(yùn)算以簡(jiǎn)化數(shù)字展示蛾默。
但是實(shí)際上通過(guò)此方法并不能夠獲取到與 Xcode 上的 Memory 一樣的參數(shù)懦铺,就觀察來(lái)看它比 Xcode 的統(tǒng)計(jì)數(shù)據(jù)要大很多。這里還有另一種 方法趴生,它獲取到的內(nèi)存占用值更加貼合于 Xcode 的統(tǒng)計(jì)值
+ (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 的內(nèi)存殺手 Jetsam 也是通過(guò) phys_footprint
這一參數(shù)來(lái)獲知內(nèi)存使用是否達(dá)到上界的阀趴。
6. 冷啟動(dòng)測(cè)速
冷啟動(dòng)測(cè)速很多時(shí)候都與打點(diǎn)密不可分昏翰,通常來(lái)說(shuō)我們會(huì)在以下一系列地方進(jìn)行打點(diǎn)獲知啟動(dòng)流程
- main 函數(shù)
- AppDelegate 代理方法
- homePage 首頁(yè)
但是在 main 函數(shù)執(zhí)行前其實(shí)也有很大一部分耗時(shí)工作需要執(zhí)行苍匆,例如
- 加載可執(zhí)行文件
- 加載動(dòng)態(tài)鏈接庫(kù)
- 初始化 Runtime
- +load 函數(shù)
完整示意圖如下
所以從 main 函數(shù)開(kāi)始計(jì)時(shí)是與真實(shí)情況不夠貼合的,更早的時(shí)間點(diǎn)獲取方式有以下 3 種
- 以可執(zhí)行文件中任意一個(gè)類的 +load 方法的執(zhí)行時(shí)間作為起始點(diǎn)
- 分析 dylib 的依賴關(guān)系棚菊,找到葉子節(jié)點(diǎn)的 dylib浸踩,然后以其中某個(gè)類的 +load 方法的執(zhí)行時(shí)間作為起始點(diǎn)
- 以 App 的進(jìn)程創(chuàng)建時(shí)間(即 exec 函數(shù)執(zhí)行時(shí)間)作為冷啟動(dòng)的起始時(shí)間,通過(guò) sysctl 函數(shù)獲取
這三者里统求,第三個(gè)方式的時(shí)間戳統(tǒng)計(jì)最早检碗,而且目前未發(fā)現(xiàn)更早更準(zhǔn)確且更有意義的起始點(diǎn)
#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, @"無(wú)法取得進(jìn)程的信息");
return 0;
}
}
有了起始點(diǎn)据块,其他打點(diǎn)就可以依次相減得到每一段的具體耗時(shí)了。
這里需要補(bǔ)充一點(diǎn)折剃,假如應(yīng)用執(zhí)行了安裝后啟動(dòng)的操作另假,例如模擬器上進(jìn)行編譯調(diào)試,sysctl 獲取的時(shí)間戳?xí)陌惭b起始點(diǎn)開(kāi)始計(jì)算怕犁,當(dāng)然這對(duì)于實(shí)際使用來(lái)說(shuō)影響不大边篮。
7. 流量監(jiān)控
流量監(jiān)控主要需要關(guān)注的點(diǎn)有以下四個(gè)
- URL,毋庸置疑奏甫,監(jiān)控出問(wèn)題后需要 URL 來(lái)排查
- requestSize戈轿,請(qǐng)求大小,具體包括 URL 長(zhǎng)度阵子、 header 長(zhǎng)度和 body 長(zhǎng)度思杯,實(shí)際上嚴(yán)格意義上 Method 字段和 Version 字段也需要考慮,但是考慮到它們都是固定長(zhǎng)度且占比較小所以不計(jì)
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挠进,響應(yīng)大小色乾,具體包括 header 長(zhǎng)度和 body 長(zhǎng)度
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,請(qǐng)求類型奈梳,具體可以分為
- Web - H5頁(yè)面杈湾,一般來(lái)說(shuō)它的 MIMEType 會(huì)是這幾種 "text/css","text/html"攘须,"application/x-javascript"漆撞,"application/javascript"
- API - Native 側(cè)進(jìn)行 API 接口請(qǐng)求
- Resource - Native 側(cè)進(jìn)行多媒體資源等資源數(shù)據(jù)請(qǐng)求,與 API 的區(qū)分需要從 URLHost 上著手
- Other
當(dāng)然流量數(shù)據(jù)的特點(diǎn)是頻率高于宙、次數(shù)多浮驳、體積不定,所以做好緩存和批次上報(bào)捞魁、壓縮上報(bào)等工作也是必不可少的至会。