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步操作缺失,這一步也會丟幀
所以說,卡頓造成的原因通常是CPU和GPU導致的掉幀引起的,主要原因如下:
  1. 主線程在進行大量I/O操作:為了方便代碼編寫,直接在主線程去寫入大量數據;
  2. 主線程在進行大量計算:代碼編寫不合理,主線程進行復雜計算;
  3. 大量UI繪制:界面過於復雜,UI繪制需要大量時間;
  4. 主線程在等鎖:主線程需要獲得鎖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就不會產生離屏渲染

畫圓角避免離屏渲染

CAShapeLayer與UIBezierPath(貝塞爾曲線)配合畫圓角

- (void)drawCornerPicture{
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];
    imageView.image = [UIImage imageNamed:@"1"];
    // 開啟圖片上下文
    // UIGraphicsBeginImageContext(imageView.bounds.size);
    // 一般使用下面的方法
    UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0);
    // 繪制貝塞爾曲線
    UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:100];
    // 按繪制的貝塞爾曲線剪切
    [bezierPath addClip];
    // 畫圖
    [imageView drawRect:imageView.bounds];
    // 獲取上下文中的圖片
    imageView.image = UIGraphicsGetImageFromCurrentImageContext();
    // 關閉圖片上下文
    UIGraphicsEndImageContext();
    [self.view addSubview:imageView];
}


使用 Core Graphics 繪制圓角

- (void)circleImage{
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];
    imageView.image = [UIImage imageNamed:@"001.jpeg"];
    // NO代表透明
    UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0.0);
    // 獲得上下文
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    // 添加一個圓
    CGRect rect = CGRectMake(0, 0, imageView.bounds.size.width, imageView.bounds.size.height);
    CGContextAddEllipseInRect(ctx, rect);
    // 裁剪
    CGContextClip(ctx);
    // 將圖片畫上去
//    [imageView drawRect:rect];
    [imageView.image drawInRect:rect];
    imageView.image = UIGraphicsGetImageFromCurrentImageContext();
    // 關閉上下文
    UIGraphicsEndImageContext();
    [self.view addSubview:imageView];
}

查看離屏渲染,模擬器可以選中“Debug - Color Off-screen Rendered”開啟調試,真機可以用Instruments檢測,“Instruments - Core Animation - Debug Options - Color Offscreen-Rendered Yellow”開啟調試,開啟后,有離屏渲染的圖層會變成高亮的黃色。

卡頓檢測

原理

平時所說的“卡頓”主要是因為在主線程執行了比較耗時的操作,可以添加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 日志堆棧解析

解析成我們想要看懂的樣子,如:

主要分析一下最頂的主線程出現的卡頓位置,再結合代碼去查看。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM