iOS性能優化總結-卡頓

卡頓緣由

成像

圖像的顯示能夠簡單理解成先通過CPU的計算/排版/編解碼等操做,而後交由GPU去完成渲染放入緩衝中,當視頻控制器接受到vSync時會從緩衝中讀取已經渲染完成的幀並顯示到屏幕上。ios

卡頓原理
iOS手機默認刷新率是60hz,因此GPU渲染只要達到60fps就不會產生卡頓。 以60fps爲例,vSync會每16.67ms發出,如在16.67ms內沒有準備好下一幀數據就會使畫面停留在上一幀,產生卡頓,例如圖中第3幀渲染完成以前一直顯示的是第2幀的內容。
解決思路: 儘可能減少CPU和GPU的資源消耗

一些概念:
CPU:負責對象的建立和銷燬、對象屬性的調整、佈局計算、文本的計算和排版、圖片的格式轉換和解碼、圖像的繪製(Core Graphics)
GPU:負責紋理的渲染(將數據渲染到屏幕) 垂直同步技術:讓CPU和GPU在收到vSync信號後再開始準備數據,防止撕裂感和跳幀,通俗來說就是保證每秒輸出的幀數不高於屏幕顯示的幀數。
雙緩衝技術:iOS是雙緩衝機制,前幀緩存和後幀緩存,cpu計算完GPU渲染後放入緩衝區中,當gpu下一幀已經渲染完放入緩衝區,且視頻控制器已經讀完前幀,GPU會等待vSync(垂直同步信號)信號發出後,瞬間切換先後幀緩存,並讓cpu開始準備下一幀數據
安卓4.0後採用三重緩衝,多了一個後幀緩衝,可下降連續丟幀的可能性,但會佔用更多的CPU和GPUgit

卡頓優化-CPU

  • 儘可能用輕量級的對象,好比用不到事件處理的地方使用CALayer取代UIView
  • 儘可能提早計算好佈局(例如cell行高)
  • 不要頻繁地調用和調整UIView的相關屬性,好比frame、bounds、transform等屬性,儘可能減小沒必要要的調用和修改(UIView的顯示屬性實際都是CALayer的映射,而CALayer自己是沒有這些屬性的,都是初次調用屬性時經過resolveInstanceMethod添加並建立Dictionry保存的,耗費資源)
  • Autolayout會比直接設置frame消耗更多的CPU資源,當視圖數量增加時會呈指數級增加
  • 圖片的size最好恰好跟UIImageView的size保持一致,減小圖片顯示時的處理計算
  • 控制一下線程的最大併發數量
  • 儘可能把耗時的操做放到子線程
  • 文本處理(尺寸計算、繪製、CoreText和YYText)
    1. 計算文本寬高boundingRectWithSize:options:context: 和文本繪製drawWithRect:options:context:放在子線程操做
    2. 使用CoreText自定義文本空間,在對象建立過程當中能夠緩存寬高等信息,避免像UILabel/UITextView須要屢次計算(調整和繪製都要計算一次),且CoreText直接使用了CoreGraphics佔用內存小,效率高。(YYText)
  • 圖片處理(解碼、繪製) 圖片都須要先解碼成bitmap才能渲染到UI上,iOS建立UIImage,不會馬上進行解碼,只有等到顯示前纔會在主線程進行解碼,固可使用Core Graphics中的CGBitmapContextCreate相關操做提早在子線程中進行強制解壓縮得到位圖 (YYImage/SDWebImage/kingfisher的對比)
SDWebImage的使用:
 CGImageRef imageRef = image.CGImage;
        // device color space
        CGColorSpaceRef colorspaceRef = SDCGColorSpaceGetDeviceRGB();
        BOOL hasAlpha = SDCGImageRefContainsAlpha(imageRef);
        // iOS display alpha info (BRGA8888/BGRX8888)
        CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
        bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
        
        size_t width = CGImageGetWidth(imageRef);
        size_t height = CGImageGetHeight(imageRef);
        
        // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
        // Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
        // to create bitmap graphics contexts without alpha info.
        CGContextRef context = CGBitmapContextCreate(NULL,
                                                     width,
                                                     height,
                                                     kBitsPerComponent,
                                                     0,
                                                     colorspaceRef,
                                                     bitmapInfo);
        if (context == NULL) {
            return image;
        }
        
        // Draw the image into the context and retrieve the new bitmap image without alpha
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
        CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);
        UIImage *imageWithoutAlpha = [[UIImage alloc] initWithCGImage:imageRefWithoutAlpha scale:image.scale orientation:image.imageOrientation];
        CGContextRelease(context);
        CGImageRelease(imageRefWithoutAlpha);
        
        return imageWithoutAlpha;
複製代碼

卡頓優化-GPU

  • 儘可能避免短期內大量圖片的顯示,儘量將多張圖片合成一張進行顯示
  • GPU能處理的最大紋理尺寸是4096x4096,一旦超過這個尺寸,就會佔用CPU資源進行處理,因此紋理儘可能不要超過這個尺寸
  • GPU會將多個視圖混合在一塊兒再去顯示,混合的過程會消耗CPU資源,儘可能減小視圖數量和層次
  • 減小透明的視圖(alpha<1),不透明的就設置opaque爲YES,GPU就不會去進行alpha的通道合成
  • 儘可能避免出現離屏渲染

離屏渲染
在OpenGL中,GPU有2種渲染方式 On-Screen Rendering:當前屏幕渲染,在當前用於顯示的屏幕緩衝區進行渲染操做 Off-Screen Rendering:離屏渲染,在當前屏幕緩衝區之外新開闢一個緩衝區進行渲染操做github

離屏渲染消耗性能的緣由 須要建立新的緩衝區 離屏渲染的整個過程,須要屢次切換上下文環境,先是從當前屏幕(On-Screen)切換到離屏(Off-Screen);等到離屏渲染結束之後,將離屏緩衝區的渲染結果顯示到屏幕上,又須要將上下文環境從離屏切換到當前屏幕api

哪些操做會觸發離屏渲染?緩存

  • 光柵化,layer.shouldRasterize = YES性能優化

  • 遮罩,layer.maskbash

  • 圓角,同時設置layer.masksToBounds = YES、layer.cornerRadius大於0 考慮經過CoreGraphics繪製裁剪圓角,或者叫美工提供圓角圖片服務器

  • 陰影,layer.shadowXXX 若是設置了layer.shadowPath就不會產生離屏渲染併發

卡頓監控

Xcode自帶Instruments

在開發階段,能夠直接使用Instrument來檢測性能問題,Time Profiler查看與CPU相關的耗時操做,Core Animation查看與GPU相關的渲染操做。app

FPS(CADisplayLink)

正常狀況下,App的FPS只要保持在50~60之間,用戶就不會感到界面卡頓。經過向主線程添加CADisplayLink咱們能夠接收到每次屏幕刷新的回調,從而統計出每秒屏幕刷新次數。這種方案最多見,例如YYFPSLabel,且只用了CADisplayLink,實現成本較低,但因爲只能在CPU空閒時纔去回調,沒法精確採集到卡頓時調用棧信息,能夠在開發階段做爲輔助手段使用。

//
//  YYFPSLabel.m
//  YYKitExample
//
//  Created by ibireme on 15/9/3.
//  Copyright (c) 2015 ibireme. All rights reserved.
//

#import "YYFPSLabel.h"
//#import <YYKit/YYKit.h>
#import "YYText.h"
#import "YYWeakProxy.h"

#define kSize CGSizeMake(55, 20)

@implementation YYFPSLabel {
    CADisplayLink *_link;
    NSUInteger _count;
    NSTimeInterval _lastTime;
    UIFont *_font;
    UIFont *_subFont;
    
    NSTimeInterval _llll;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (frame.size.width == 0 && frame.size.height == 0) {
        frame.size = kSize;
    }
    self = [super initWithFrame:frame];
    
    self.layer.cornerRadius = 5;
    self.clipsToBounds = YES;
    self.textAlignment = NSTextAlignmentCenter;
    self.userInteractionEnabled = NO;
    self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];
    
    _font = [UIFont fontWithName:@"Menlo" size:14];
    if (_font) {
        _subFont = [UIFont fontWithName:@"Menlo" size:4];
    } else {
        _font = [UIFont fontWithName:@"Courier" size:14];
        _subFont = [UIFont fontWithName:@"Courier" size:4];
    }
    // 建立CADisplayLink並添加到主線程的RunLoop中
    _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    return self;
}

- (void)dealloc {
    [_link invalidate];
}

- (CGSize)sizeThatFits:(CGSize)size {
    return kSize;
}

//刷新回調時去計算fps
- (void)tick:(CADisplayLink *)link {
    if (_lastTime == 0) {
        _lastTime = link.timestamp;
        return;
    }
    
    _count++;
    NSTimeInterval delta = link.timestamp - _lastTime;
    if (delta < 1) return;
    _lastTime = link.timestamp;
    float fps = _count / delta;
    _count = 0;
    
    CGFloat progress = fps / 60.0;
    UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];
    
    NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
    [text yy_setColor:color range:NSMakeRange(0, text.length - 3)];
    [text yy_setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
    text.yy_font = _font;
    [text yy_setFont:_subFont range:NSMakeRange(text.length - 4, 1)];
    
    self.attributedText = text;
}

@end
複製代碼

RunLoop

關於RunLoop,推薦參考深刻理解RunLoop,這裏只列出其簡化版的狀態。

經典圖片

// 1.進入loop
__CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled)

// 2.RunLoop 即將觸發 Timer 回調。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
// 3.RunLoop 即將觸發 Source0 (非port) 回調。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
// 4.RunLoop 觸發 Source0 (非port) 回調。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle)
// 5.執行被加入的block等Source1事件
__CFRunLoopDoBlocks(runloop, currentMode);

// 6.RunLoop 的線程即將進入休眠(sleep)。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);

// 7.調用 mach_msg 等待接受 mach_port 的消息。線程將進入休眠, 直到被下面某一個事件喚醒。
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort)


// 進入休眠


// 8.RunLoop 的線程剛剛被喚醒了。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting

// 9.1.若是一個 Timer 到時間了,觸發這個Timer的回調
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())

// 9.2.若是有dispatch到main_queue的block,執行bloc
 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
 
 // 9.3.若是一個 Source1 (基於port) 發出事件了,處理這個事件
__CFRunLoopDoSource1(runloop, currentMode, source1, msg);

// 10.RunLoop 即將退出
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);

複製代碼

因爲source0處理的是app內部事件,包括UI事件,因此可知處理事件主要是在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之間。咱們能夠建立一個子線程去監聽主線程狀態變化,經過dispatch_semaphore在主線程進入狀態時發送信號量,子線程設置超時時間循環等待信號量,若超過期間後還未接收到主線程發出的信號量則可判斷爲卡頓,保存響應的調用棧信息去進行分析。線上卡頓的收集多采用這種方式,可將卡頓信息上傳至服務器且用戶無感知。

#pragma mark - 註冊RunLoop觀察者

//在主線程註冊RunLoop觀察者
- (void)registerMainRunLoopObserver
{
    //監聽每一個步湊的回調
    CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
    self.runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                   kCFRunLoopAllActivities,
                                                   YES,
                                                   0,
                                                   &runLoopObserverCallBack,
                                                   &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopObserver, kCFRunLoopCommonModes);
}

//觀察者方法
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    self.runLoopActivity = activity;
    //觸發信號,說明開始執行下一個步驟。
    if (self.semaphore != nil)
    {
        dispatch_semaphore_signal(self.semaphore);
    }
}

#pragma mark - RunLoop狀態監測

//建立一個子線程去監聽主線程RunLoop狀態
- (void)createRunLoopStatusMonitor
{
    //建立信號
    self.semaphore = dispatch_semaphore_create(0);
    if (self.semaphore == nil)
    {
        return;
    }
    
    //建立一個子線程,監測Runloop狀態時長
    dispatch_async(dispatch_get_global_queue(0, 0), ^
    {
        while (YES)
        {
            //若是觀察者已經移除,則中止進行狀態監測
            if (self.runLoopObserver == nil)
            {
                self.runLoopActivity = 0;
                self.semaphore = nil;
                return;
            }
            
            //信號量等待。狀態不等於0,說明狀態等待超時
        //方案一->設置單次超時時間爲500毫秒
            long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC));
            if (status != 0)
            {
                if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting)
                {
                    ...
                    //發生超過500毫秒的卡頓,此時去記錄調用棧信息
                }
            }
        /*
       //方案二->連續5次卡頓50ms上報
        long status = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
        if (status != 0)
        {
            if (!observer)
            {
                timeoutCount = 0;
                semaphore = 0;
                activity = 0;
                return;
            }
            
            if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
            {
                if (++timeoutCount < 5)
                    continue;
                //保存調用棧信息
            }
        }
        timeoutCount = 0;
        */
        }
    });
}

複製代碼

子線程Ping

根據卡頓發生時,主線程無響應的原理,建立一個子線程循環去Ping主線程,Ping以前先設卡頓置標誌爲True,再派發到主線程執行設置標誌爲False,最後子線程在設定的閥值時間內休眠結束後判斷標誌來判斷主線程有無響應。該方法的監控準確性和性能損耗與ping頻率成正比。
代碼部分來源於ANREye

private class AppPingThread: Thread {
    
    
    private let semaphore = DispatchSemaphore(value: 0)
    //判斷主線程是否卡頓的標識
    private var isMainThreadBlock = false
    
    private var threshold: Double = 0.4
    
    fileprivate var handler: (() -> Void)?
    
    func start(threshold:Double, handler: @escaping AppPingThreadCallBack) {
        self.handler = handler
        self.threshold = threshold
        self.start()
    }
    
    override func main() {
        
        while self.isCancelled == false {
            self.isMainThreadBlock = true
            //主線程去重置標識
            DispatchQueue.main.async {
                self.isMainThreadBlock = false
                self.semaphore.signal()
            }
            
            Thread.sleep(forTimeInterval: self.threshold)
            //若標識未重置成功則說明再設置的閥值時間內主線程未響應,此時去作響應處理
            if self.isMainThreadBlock  {
                //採集卡頓調用棧信息
                self.handler?()
            }
            
            _ = self.semaphore.wait(timeout: DispatchTime.distantFuture)
        }
    }
    

}
複製代碼

參考文章:
iOS 保持界面流暢的技巧
屏幕成像原理
iOS 性能優化總結
質量監控-卡頓檢測

相關文章
相關標籤/搜索