iOS性能優化-卡頓

卡頓原因

成像

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

卡頓原理

iOS手機默認刷新率是60hz,所以GPU渲染只要達到60fps就不會產生卡頓。
以60fps為例,vSync會每16.67ms發出,如在16.67ms內沒有準備好下一幀數據就會使畫面停留在上一幀,產生卡頓,例如圖中第3幀的渲染。
解決思路:盡量減小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,
                                                     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:離屏渲染,在當前屏幕緩沖區以外新開辟一個緩沖區進行渲染操作

離屏渲染消耗性能的原因
需要創建新的緩沖區
離屏渲染的整個過程,需要多次切換上下文環境,先是從當前屏幕(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, 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 性能優化總結
質量監控-卡頓檢測

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373

推薦閱讀更多精彩內容