iOS應用千萬級架構:效能優化與卡頓監控
CPU和GPU
在螢幕成像的過程中,CPU和GPU起著至關重要的作用
CPU(Central Processing Unit,中央處理器) 物件的建立和銷燬、物件屬性的調整、佈局計算、文字的計算和排版、圖片的格式轉換和解碼、影象的繪製(Core Graphics)
GPU(Graphics Processing Unit,圖形處理器) 紋理的渲染
另:在iOS中是雙緩衝機制,有前幀快取、後幀快取
螢幕成像原理
GPU 通常有一個機制叫做垂直同步(簡寫也是 V-Sync),通常以固定頻率進行重新整理,這個重新整理率就是 VSync 訊號產生的頻率。CRT 的電子槍按照上面方式,從上到下一行行掃描,掃描完成後顯示器就呈現一幀畫面,隨後電子槍回到初始位置繼續下一次掃描。為了把顯示器的顯示過程和系統的視訊控制器進行同步,顯示器(或者其他硬體)會用硬體時鐘產生一系列的定時訊號。當電子槍換到新的一行,準備進行掃描時,顯示器會發出一個水平同步訊號(horizonal synchronization),簡稱 HSync;
簡單來說,就是產生一個VSync,之後不斷的進行水平同步訊號HSync將螢幕顯示完,再產生下一個VSync,再不斷的進行水平同步訊號HSync將螢幕顯示完,重複這樣的操作。
按照60FPS的刷幀率,每隔16ms就會有一次VSync訊號。1秒是1000ms,1000/60 = 16。
卡頓的原因分析
此圖更為形象的反映了螢幕成像的原理流程是怎麼樣的。CPU計算顯示內容,例如檢視建立,佈局計算、圖片解碼、文字繪製等;接著 CPU 會將計算好的內容提交到 GPU進行合成、渲染。隨後 GPU 會把渲染結果提交到幀緩衝區去,等待VSync 訊號到來時顯示到螢幕上。如果此時下一個VSync 訊號到來時,CPU或GPU都沒有完成相應的工作時,則那一幀將會丟失,則就是我們看到螢幕卡頓的原因。- 如圖第3步:VSync訊號回來時,GPU還沒有完成相應的工作,這一幀將會丟失
- 如圖第4步:當第3步丟失了,可能會導致第4步操作缺失,這一步也會丟幀
- 主執行緒在進行大量I/O操作:為了方便程式碼編寫,直接在主執行緒去寫入大量資料;
- 主執行緒在進行大量計算:程式碼編寫不合理,主執行緒進行復雜計算;
- 大量UI繪製:介面過於複雜,UI繪製需要大量時間;
- 主執行緒在等鎖:主執行緒需要獲得鎖A,但是當前某個子執行緒持有這個鎖A,導致主執行緒不得不等待子執行緒完成任務。
卡頓優化
CPU資源消耗分析
1、物件建立:物件的建立會分配記憶體、調整屬性、甚至還有讀取檔案等操作,比較消耗CPU資源。儘量採取輕量級物件,儘量放到後臺執行緒處理,儘量推遲物件的建立時間。(如UIView / CALayer)
2、物件調整:frame、bounds、transform及檢視層次等屬性調整很耗費CPU資源。儘量減少不必要屬性的修改,儘量避免調整檢視層次、新增和移除檢視。
3、佈局計算:隨著檢視數量的增長,Autolayout帶來的CPU消耗會呈指數級增長,所以儘量提前算好佈局,在需要時一次性調整好對應屬性。
4、文字渲染:螢幕上能看到的所有文字內容控制元件,包括UIWebView,在底層都是通過CoreText排版、繪製為點陣圖顯示的。常見的文字控制元件,其排版與繪製都是在主執行緒進行的,顯示大量文字是,CPU壓力很大。對此解決方案唯一就是自定義文字控制元件,用CoreText對文字非同步繪製。(很麻煩,開發成本高)
5、圖片解碼:當用UIImage或CGImageSource建立圖片時,圖片資料並不會立刻解碼。圖片設定到UIImageView或CALayer.contents中去,並且CALayer被提交到GPU前,CGImage中的資料才會得到解碼。這一步是發生在主執行緒的,並且不可避免。SD_WebImage處理方式:在後臺執行緒先把圖片繪製到CGBitmapContext中,然後從Bitmap直接建立圖片。
6、影象繪製:影象的繪製通常是指用那些以CG開頭的方法把影象繪製到畫布中,然後從畫布建立圖片並顯示的一個過程。CoreGraphics方法是執行緒安全的,可以非同步繪製,主執行緒回撥。
7、控制一下執行緒的最大併發數量
GPU資源消耗分析
1、紋理混合:儘量減少短時間內大量圖片的顯示,儘可能將多張圖片合成一張進行顯示。GPU能處理的最大紋理尺寸是4096x4096,一旦超過這個尺寸,就會佔用CPU資源進行處理,所以紋理儘量不要超過這個尺寸
2、檢視混合:儘量減少檢視層次和數量,減少透明的檢視(alpha<1),不透明的就設定opaque為YES。
3、圖形生成:儘量避免離屏渲染,儘量採用非同步繪製,儘量避免使用圓角、陰影、遮罩等屬性。必要時用靜態圖片實現展示效果,也可嘗試光柵化快取複用屬性。
什麼是離屏渲染?
在OpenGL中,GPU有2種渲染方式
- On-Screen Rendering:當前螢幕渲染,在當前用於顯示的螢幕緩衝區進行渲染操作
- Off-Screen Rendering:離屏渲染,在當前螢幕緩衝區以外新開闢一個緩衝區進行渲染操作
離屏渲染消耗效能的原因
- 需要建立新的緩衝區
- 離屏渲染的整個過程,需要多次切換上下文環境,先是從當前螢幕(On-Screen)切換到離屏(Off-Screen);等到離屏渲染結束以後,將離屏緩衝區的渲染結果顯示到螢幕上,又需要將上下文環境從離屏切換到當前螢幕
哪些操作會觸發離屏渲染?
- 光柵化:layer.shouldRasterize = YES
- 遮罩:layer.mask
- 圓角:同時設定layer.masksToBounds = YES、layer.cornerRadius大於0。考慮通過CoreGraphics繪製裁剪圓角,或者叫美工提供圓角圖片
- 陰影:layer.shadowXXX,如果設定了layer.shadowPath就不會產生離屏渲染
卡頓檢測
原理
平時所說的“卡頓”主要是因為在主執行緒執行了比較耗時的操作,可以新增Observer到主執行緒RunLoop中,通過監聽RunLoop狀態切換的耗時,以達到監控卡頓的目的。
其中核心方法CFRunLoopRun簡化後的主要邏輯大概是這樣的:
/// 1. 通知Observers,即將進入RunLoop /// 此處有Observer會建立AutoreleasePool: _objc_autoreleasePoolPush(); __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry); do { /// 2. 通知 Observers: 即將觸發 Timer 回撥。 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers); /// 3. 通知 Observers: 即將觸發 Source (非基於port的,Source0) 回撥。 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources); __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block); /// 4. 觸發 Source0 (非基於port的) 回撥。 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0); /// 5. GCD處理main block __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block); /// 6. 通知Observers,即將進入休眠 /// 此處有Observer釋放並新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush(); __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting); /// 7. sleep to wait msg. mach_msg() -> mach_msg_trap(); /// 8. 通知Observers,執行緒被喚醒 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting); /// 9. 如果是被Timer喚醒的,回撥Timer __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer); /// 9. 如果是被dispatch喚醒的,執行所有呼叫 dispatch_async 等方法放入main queue 的 block __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block); /// 9. 如果如果Runloop是被 Source1 (基於port的) 的事件喚醒了,處理這個事件 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1); } while (...); /// 10. 通知Observers,即將退出RunLoop /// 此處有Observer釋放AutoreleasePool: _objc_autoreleasePoolPop(); __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit); }不難發現NSRunLoop呼叫方法主要就是在kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting之間,還有kCFRunLoopAfterWaiting之後,也就是如果我們發現這兩個時間內耗時太長,那麼就可以判定出此時主執行緒卡頓。
那麼,我們卡頓監控在 Runloop 的起始最開始和結束最末尾位置新增 Observer,從而獲得主執行緒的開始和結束狀態。卡頓監控起一個子執行緒定時檢查主執行緒的狀態,當主執行緒的狀態執行超過一定閾值則認為主執行緒卡頓,從而標記為一個卡頓。
分析實現
使用Runloop進行卡頓監控之後,需要定義一個閥值來判定卡頓的出現,並記錄下來,上報到伺服器
比如:
1、主程式 Runloop 超時的閾值是 2 秒,子執行緒的檢查週期是 1 秒。每隔 1 秒,子執行緒檢查主執行緒的執行狀態;如果檢查到主執行緒 Runloop 執行超過 2 秒則認為是卡頓,並獲得當前的執行緒快照。
2、假定連續5次超時50ms認為卡頓(當然也包含了單次超時250ms)
可參考的核心程式碼:
// 開始監聽 - (void)startMonitor { if (observer) { return; } // 建立訊號 semaphore = dispatch_semaphore_create(0); NSLog(@"dispatch_semaphore_create:%@",[BGPerformanceMonitor getCurTime]); // 註冊RunLoop狀態觀察 CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL}; //建立Run loop observer物件 //第一個引數用於分配observer物件的記憶體 //第二個引數用以設定observer所要關注的事件,詳見回撥函式myRunLoopObserver中註釋 //第三個引數用於標識該observer是在第一次進入run loop時執行還是每次進入run loop處理時均執行 //第四個引數用於設定該observer的優先順序 //第五個引數用於設定該observer的回撥函式 //第六個引數用於設定該observer的執行環境 observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); // 在子執行緒監控時長 dispatch_async(dispatch_get_global_queue(0, 0), ^{ while (YES) { // 有訊號的話 就查詢當前runloop的狀態 // 假定連續5次超時50ms認為卡頓(當然也包含了單次超時250ms) // 因為下面 runloop 狀態改變回調方法runLoopObserverCallBack中會將訊號量遞增 1,所以每次 runloop 狀態改變後,下面的語句都會執行一次 // dispatch_semaphore_wait:Returns zero on success, or non-zero if the timeout occurred. long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC)); NSLog(@"dispatch_semaphore_wait:st=%ld,time:%@",st,[self getCurTime]); if (st != 0) { // 訊號量超時了 - 即 runloop 的狀態長時間沒有發生變更,長期處於某一個狀態下 if (!observer) { timeoutCount = 0; semaphore = 0; activity = 0; return; } NSLog(@"st = %ld,activity = %lu,timeoutCount = %d,time:%@",st,activity,timeoutCount,[self getCurTime]); // kCFRunLoopBeforeSources - 即將處理source kCFRunLoopAfterWaiting - 剛從休眠中喚醒 // 獲取kCFRunLoopBeforeSources到kCFRunLoopBeforeWaiting再到kCFRunLoopAfterWaiting的狀態就可以知道是否有卡頓的情況。 // kCFRunLoopBeforeSources:停留在這個狀態,表示在做很多事情 if (activity == kCFRunLoopBeforeSources || activity == kCFRunLoopAfterWaiting) { // 發生卡頓,記錄卡頓次數 if (++timeoutCount < 5) { continue; // 不足 5 次,直接 continue 當次迴圈,不將timeoutCount置為0 } // 收集Crash資訊也可用於實時獲取各執行緒的呼叫堆疊 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]; NSLog(@"---------卡頓資訊\n%@\n--------------",report); } } NSLog(@"dispatch_semaphore_wait timeoutCount = 0,time:%@",[self getCurTime]); timeoutCount = 0; } }); }
也可以檢視一個開源庫:LXDAppFluecyMonitor ,裡面有打印出堆疊資訊。
實際專案使用
當前,實際專案使用,是使用騰訊微信的開源庫,Matrix,說明wiki:Matrix-iOS 卡頓監控
上傳到伺服器之後,需要進行日誌符號化堆疊解析,可參考:iOS crash 日誌堆疊解析
解析成我們想要看懂的樣子,如:
主要分析一下最頂的主執行緒出現的卡頓位置,再結合程式碼去檢視。
&n