卡頓原因
圖像的顯示可以簡單理解成先經過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)
- 計算文本寬高boundingRectWithSize:options:context: 和文本繪制drawWithRect:options:context:放在子線程操作
- 使用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)
}
}
}