1. 程式人生 > IOS開發 >iOS效能優化總結-卡頓

iOS效能優化總結-卡頓

前言

卡頓雖然不像閃退一樣致命,但也會帶給使用者極差的體驗,甚至導致使用者解除安裝應用,可是卻常常被我們忽視。

我們該如何解決卡頓呢?
本編文章將從1.認識卡頓 2.解決卡頓 3.監控卡頓 帶你由淺到深完全解密卡頓的奧祕。

卡頓原因

成像

影象的顯示可以簡單理解成先經過CPU的計算/排版/編解碼等操作,然後交由GPU去完成渲染放入緩衝中,當視訊控制器接受到vSync時會從緩衝中讀取已經渲染完成的幀並顯示到螢幕上。

卡頓原理
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和GPU

卡頓優化-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,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,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:離屏渲染,在當前螢幕緩衝區以外新開闢一個緩衝區進行渲染操作

離屏渲染消耗效能的原因 需要建立新的緩衝區 離屏渲染的整個過程,需要多次切換上下文環境,先是從當前螢幕(On-Screen)切換到離屏(Off-Screen);等到離屏渲染結束以後,將離屏緩衝區的渲染結果顯示到螢幕上,又需要將上下文環境從離屏切換到當前螢幕

哪些操作會觸發離屏渲染?

  • 光柵化,layer.shouldRasterize = YES

  • 遮罩,layer.mask

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

  • 陰影,layer.shadowXXX 如果設定了layer.shadowPath就不會產生離屏渲染

卡頓監控

Xcode自帶Instruments

在開發階段,可以直接使用Instrument來檢測效能問題,Time Profiler檢視與CPU相關的耗時操作,Core Animation檢視與GPU相關的渲染操作。

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,kCFRunLoopBeforeTimers);
// 3.RunLoop 即將觸發 Source0 (非port) 回撥。
__CFRunLoopDoObservers(runloop,kCFRunLoopBeforeSources);
// 4.RunLoop 觸發 Source0 (非port) 回撥。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop,stopAfterHandle)
// 5.執行被加入的block等Source1事件
__CFRunLoopDoBlocks(runloop,currentMode);

// 6.RunLoop 的執行緒即將進入休眠(sleep)。
__CFRunLoopDoObservers(runloop,kCFRunLoopBeforeWaiting);

// 7.呼叫 mach_msg 等待接受 mach_port 的訊息。執行緒將進入休眠,直到被下面某一個事件喚醒。
__CFRunLoopServiceMachPort(waitSet,&msg,sizeof(msg_buffer),&livePort)


// 進入休眠


// 8.RunLoop 的執行緒剛剛被喚醒了。
__CFRunLoopDoObservers(runloop,kCFRunLoopAfterWaiting

// 9.1.如果一個 Timer 到時間了,觸發這個Timer的回撥
__CFRunLoopDoTimers(runloop,mach_absolute_time())

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

// 10.RunLoop 即將退出
__CFRunLoopDoObservers(rl,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,&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,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 效能優化總結
質量監控-卡頓檢測

From:SimonYe