一. CPU和GPU的作用
在屏幕成像的過程中,CPU和GPU起著至關重要的作用:
CPU(Central Processing Unit,中央處理器)
CPU的工作:對象的創(chuàng)建和銷毀、對象屬性的調整、布局計算、文本的計算和排版、圖片的格式轉換和解碼、圖像的繪制(Core Graphics)GPU(Graphics Processing Unit,圖形處理器)
GPU的工作:紋理的渲染
具體流程:
CPU計算文字大小、位置、顏色,圖片解碼等等,計算好之后將數據提交給GPU,GPU拿到這些數據進行渲染,渲染之后將數據放到幀緩存里面,然后視頻控制器再從幀緩存讀取數據,讀取到數據之后直接顯示到屏幕上。

在iOS中是雙緩沖機制,有前幀緩存、后幀緩存。就比如上圖的幀緩存有兩塊區(qū)域,當一塊區(qū)域滿了或者一塊區(qū)域正在忙其他事情,那么GPU可以先用另外一塊緩存,這樣效率比較高。
二. 屏幕成像原理
先看下圖:

- 雖然手機屏幕上的動畫是可以動的,其實它都是通過一幀一幀(或者說一頁)數據組成的。
- 當屏幕想顯示一幀數據的時候,就會發(fā)送一個垂直同步信號,一旦發(fā)送一個垂直同步信號就表明它要顯示一幀數據了,接下來,首先它會發(fā)送一個水平同步信號,接著再發(fā)送下一行水平同步信號,再下一行,直到填充整個屏幕,這時候一幀數據就顯示完成。
- 接下來再發(fā)送一個垂直同步信號,同理,也是一個一個發(fā)送水平同步信號,直到完成這一幀。
- 當所有幀數據發(fā)送完成之后,這些幀連起來就是屏幕上的動畫了。
三. 卡頓產生的原因
卡頓一般就是某個列表拖拽起來不是很流暢,那么卡頓產生的原因是什么呢?

上面說了,每一幀的顯示都需要CPU和GPU共同操作,如上圖,紅色的是CPU計算需要的時間,藍色是GPU渲染需要的時間。
- 第一幀的數據要顯示,接下來垂直同步信號來了,就將GPU渲染好放在幀緩存里面的數據顯示到屏幕上,就完成了第一幀的顯示。
注意:一旦來一個垂直同步信號就會立馬把GPU渲染好放在幀緩存里面的數據顯示到屏幕上,并且馬上開始下一幀的操作。 - 第二幀的數據CPU計算和GPU渲染的比較快,所以在下一次垂直同步信號來之前,CPU和GPU的工作早早就完成了,這樣下一個垂直同步信號來了就會把幀緩存里面的數據拿出來顯示到屏幕上,完成了第二幀的顯示。
- 第三幀的數據CPU計算和GPU渲染的比較慢,在垂直同步信號來到之后還沒渲染完,這時候幀緩存里面還是第二幀的數據,所以取出數據顯示到屏幕上的還是第二幀的數據,就會產生掉幀現象,也就是我們說的卡頓。
- 第三幀辛辛苦苦計算的數據會在下一幀的垂直信號來到之后再顯示到屏幕上,所以整整慢了一幀的時間。
四. 卡頓解決的主要思路
盡可能減少CPU、GPU資源消耗。
一般FPS達到60FPS就會感覺不到卡頓,按照60FPS的刷幀率,每隔16ms就會有一次VSync信號(1000ms / 60 = 16.667ms)。
五. 卡頓優(yōu)化 CPU
- 盡量用輕量級的對象,比如用不到事件處理的地方,可以考慮使用CALayer取代UIView,能用int就不用NSNumber。
- 不要頻繁地調用UIView的相關屬性,比如frame、bounds、transform等屬性,盡量減少不必要的修改,因為每次修改都要重新計算和渲染,消耗性能比較多。
- 盡量提前計算好布局,在有需要時一次性調整對應的屬性,不要多次修改屬性,因為多次修改也會重新計算和渲染。
- Autolayout會比直接設置frame消耗更多的CPU資源,因為Autolayout本身性能就不是很高。
- 圖片的size最好剛好跟UIImageView的size保持一致,如果不一致CPU就會對圖片進行伸縮操作,這樣比較消耗CPU資源。
- 控制一下線程的最大并發(fā)數量,不要無限制的并發(fā),這樣會讓CPU很忙。
- 盡量把耗時的操作放到子線程,這樣可以充分利用CPU的多核,這樣CPU的資源消耗分擔的也比較合理。
那么哪些操作比較耗時呢?
① 文本尺寸計算、繪制
比如boundingRectWithSize計算文字寬高,或者drawWithRect文本繪制,都是可以放到子線程去處理的,如下:
- (void)text
{
//下面操作都可以放到子線程
// 文字計算
[@"text" boundingRectWithSize:CGSizeMake(100, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
// 文字繪制
[@"text" drawWithRect:CGRectMake(0, 0, 100, 100) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
}
② 圖片解碼、繪制
我們經常會寫如下代碼加載圖片:
- (void)image
{
UIImageView *imageView = [[UIImageView alloc] init];
imageView.frame = CGRectMake(100, 100, 100, 56);
imageView.image = [UIImage imageNamed:@"timg"]; //加載圖片
[self.view addSubview:imageView];
self.imageView = imageView;
}
- 其實通過imageNamed加載圖片,加載完成后是不會直接顯示到屏幕上面的,因為加載后的是經過壓縮的圖片二進制,當真正想要渲染到屏幕上的時候再拿到圖片二進制解碼成屏幕顯示所需要的那種格式,然后渲染顯示,而這種解碼一般默認是在主線程操作的,如果圖片數據比較多比較大的話也會產生卡頓。
- 一般我們的做法是在子線程提前解碼圖片二進制,主線程就不需要解碼,這樣在圖片渲染顯示之前就已經解碼出來了,主線程拿到解碼后的數據進行渲染顯示就可以了,這樣主線程就不會卡頓了。
其實網上好多圖片處理框架都有這個異步解碼功能的,下面演示一下:
- (void)image
{
UIImageView *imageView = [[UIImageView alloc] init];
imageView.frame = CGRectMake(100, 100, 100, 56);
// imageView.image = [UIImage imageNamed:@"timg"];
[self.view addSubview:imageView];
self.imageView = imageView;
//異步圖片解碼
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 獲取CGImage
CGImageRef cgImage = [UIImage imageNamed:@"timg"].CGImage;
// 獲取網絡圖片
// CGImageRef cgImage = [UIImage imageWithContentsOfFile:@"www.baidu.com"].CGImage;
// alphaInfo
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// bitmapInfo
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
// size
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
// context
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
// draw
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
// get CGImage
cgImage = CGBitmapContextCreateImage(context);
// into UIImage
UIImage *newImage = [UIImage imageWithCGImage:cgImage];
// release
CGContextRelease(context);
CGImageRelease(cgImage);
// 回到主線程
dispatch_async(dispatch_get_main_queue(), ^{
self.imageView.image = newImage;
});
});
}
上面代碼,不單單通過imageNamed加載的本地圖片可以提前渲染,通過imageWithContentsOfFile加載的網絡圖片也可以這樣進行提前渲染,只要獲取到UIImage對象都可以對UIImage對象進行提前渲染。
六. 卡頓優(yōu)化 GPU
- 盡量避免短時間內大量圖片的顯示,盡可能將多張圖片合成一張進行顯示,這樣只渲染一張圖片,渲染更快。
- GPU能處理的最大紋理尺寸是4096x4096,一旦超過這個尺寸,就會占用CPU資源進行處理,所以紋理盡量不要超過這個尺寸。
- 盡量減少視圖數量和層級,視圖層級太多會增加渲染時間。
- 減少透明的視圖(alpha<1),不透明的就設置opaque為YES,因為一旦有透明的視圖就會進行很多混合計算增加渲染的資源消耗。
- 盡量避免出現離屏渲染
那么什么是離屏渲染呢?
在OpenGL中,GPU有2種渲染方式:
- On-Screen Rendering:當前屏幕渲染,在當前用于顯示的屏幕緩沖區(qū)進行渲染操作
- Off-Screen Rendering:離屏渲染,在當前屏幕緩沖區(qū)以外新開辟一個緩沖區(qū)進行渲染操作
當前用于顯示的屏幕緩沖區(qū)就是下圖的幀緩存。

為什么離屏渲染消耗性能?
- 需要創(chuàng)建新的緩沖區(qū)
- 離屏渲染的整個過程,需要多次切換上下文環(huán)境,先是從當前屏幕(On-Screen)切換到離屏(Off-Screen),等到離屏渲染結束以后,想要將離屏緩沖區(qū)的渲染結果顯示到當前屏幕上,就又需要將上下文環(huán)境從離屏切換到當前屏幕。
哪些操作會觸發(fā)離屏渲染?
- 光柵化:layer.shouldRasterize = YES
- 遮罩:layer.mask
-
圓角:同時設置layer.masksToBounds = YES、layer.cornerRadius大于0。
可以考慮通過CoreGraphics繪制裁剪圓角,或者叫美工提供圓角圖片。 -
陰影:layer.shadowXXX。
如果設置了layer.shadowPath就不會產生離屏渲染。
為什么要開辟新的緩沖區(qū)?
因為上面進行的那些操作比較耗性能、資源,當前屏幕緩沖區(qū)不夠用(就算是雙緩沖機制也不夠用),所以才會開辟新的緩沖區(qū)。
七. 卡頓檢測
平時所說的“卡頓”主要是因為在主線程執(zhí)行了比較耗時的操作,可以添加Observer到主線程RunLoop中,通過監(jiān)聽RunLoop狀態(tài)切換的耗時,以達到監(jiān)控卡頓的目的。
上面兩句話是什么意思呢?
如下圖,主線程的大部分操作,比如點擊事件的處理,view的計算、 繪制等基本上都在source0和source1。我們只要監(jiān)控一下從結束休眠(下圖08)處理soure1(下圖08-03)一直到繞回來處理source0(下圖05), 如果發(fā)現中間消耗的時間比較長,那么就有可能可以證明這些操作比較耗時。

1. LXDAppFluecyMonitor
我們自己寫比較復雜,文末的Demo有一個別人寫的可以監(jiān)控哪個方法卡頓的第三方庫LXDAppFluecyMonitor,下面介紹一下LXDAppFluecyMonitor是如何使用的:
- (void)viewDidLoad {
[super viewDidLoad];
//開啟卡頓檢測
[[LXDAppFluecyMonitor sharedMonitor] startMonitoring];
[self.tableView registerClass: [UITableViewCell class] forCellReuseIdentifier: @"cell"];
}
- (void)viewDidAppear: (BOOL)animated {
[super viewDidAppear: animated];
}
#pragma mark - UITableViewDataSource
- (NSInteger)tableView: (UITableView *)tableView numberOfRowsInSection: (NSInteger)section {
return 1000;
}
- (UITableViewCell *)tableView: (UITableView *)tableView cellForRowAtIndexPath: (NSIndexPath *)indexPath {
UITableViewCell * cell = [tableView dequeueReusableCellWithIdentifier: @"cell"];
cell.textLabel.text = [NSString stringWithFormat: @"%lu", indexPath.row];
if (indexPath.row > 0 && indexPath.row % 30 == 0) {
// usleep(2000000);
sleep(2.0); //模擬卡頓
}
return cell;
}
上面代碼,開啟卡頓檢測后,在cellForRowAtIndexPath方法里面休眠2s模擬卡頓,運行代碼,打印如下:
2019-12-27 15:40:18.132792+0800 LXDAppFluecyMonitor[77494:12090145] Backtrace of Thread 771:
======================================================================================
libsystem_kernel.dylib 0x109fe6f32 __semwait_signal + 10
libsystem_c.dylib 0x109dcac92 sleep + 41
LXDAppFluecyMonitor 0x1070a3911 -[ViewController tableView:cellForRowAtIndexPath:] + 353
UIKitCore 0x10b392f60 -[UITableView _createPreparedCellForGlobalRow:withIndexPath:willDisplay:] + 764
UIKitCore 0x10b393499 -[UITableView _createPreparedCellForGlobalRow:willDisplay:] + 73
UIKitCore 0x10b35b654 -[UITableView _updateVisibleCellsNow:isRecursive:] + 2870
UIKitCore 0x10b37b76b -[UITableView layoutSubviews] + 165
UIKitCore 0x10b635e69 -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1417
QuartzCore 0x10cbb8d22 -[CALayer layoutSublayers] + 173
QuartzCore 0x10cbbd9fc _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 396
QuartzCore 0x10cbc9d58 _ZN2CA5Layer28layout_and_display_if_neededEPNS_11TransactionE + 72
QuartzCore 0x10cb3924a _ZN2CA7Context18commit_transactionEPNS_11TransactionE + 328
QuartzCore 0x10cb70606 _ZN2CA11Transaction6commitEv + 610
QuartzCore 0x10caa58a7 _ZN2CA7Display11DisplayLink14dispatch_itemsEyyy + 951
QuartzCore 0x10cb745a9 _ZL22display_timer_callbackP12__CFMachPortPvlS1_ + 297
CoreFoundation 0x10831b266 __CFMachPortPerform + 150
CoreFoundation 0x1083475e9 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ + 41
CoreFoundation 0x108346c4b __CFRunLoopDoSource1 + 459
CoreFoundation 0x1083411da __CFRunLoopRun + 2490
CoreFoundation 0x1083404d2 CFRunLoopRunSpecific + 626
GraphicsServices 0x1109842fe GSEventRunModal + 65
UIKitCore 0x10b156fc2 UIApplicationMain + 140
LXDAppFluecyMonitor 0x1070a4e50 main + 112
libdyld.dylib 0x109cc5541 start + 1
======================================================================================
上面打印從下往上就是方法調用棧,其中有一行打?。?/p>
LXDAppFluecyMonitor 0x1070a3911 -[ViewController tableView:cellForRowAtIndexPath:] + 353
可以發(fā)現,的確可以檢測到cellForRowAtIndexPath卡頓了。
2. LXDAppFluecyMonitor框架的核心代碼
LXDAppFluecyMonitor框架里面就兩個文件,LXDBacktraceLogger文件里面是關于方法調用棧的一些代碼,LXDAppFluecyMonitor文件就是卡頓檢測文件,進入LXDAppFluecyMonitor文件的startMonitoring方法:
- (void)startMonitoring {
if (_isMonitoring) { return; }
_isMonitoring = YES;
CFRunLoopObserverContext context = {
0,
(__bridge void *)self,
NULL,
NULL
};
//創(chuàng)建observer,監(jiān)聽所有狀態(tài)
_observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &lxdRunLoopObserverCallback, &context);
//添加observer
CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
dispatch_async(lxd_event_monitor_queue(), ^{
while (SHAREDMONITOR.isMonitoring) { //一直監(jiān)聽
if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeWaiting) {
__block BOOL timeOut = YES;
dispatch_async(dispatch_get_main_queue(), ^{
timeOut = NO;
dispatch_semaphore_signal(SHAREDMONITOR.eventSemphore);
});
[NSThread sleepForTimeInterval: lxd_time_out_interval];
if (timeOut) {
[LXDBacktraceLogger lxd_logMain];
}
dispatch_wait(SHAREDMONITOR.eventSemphore, DISPATCH_TIME_FOREVER);
}
}
});
dispatch_async(lxd_fluecy_monitor_queue(), ^{
while (SHAREDMONITOR.isMonitoring) {
long waitTime = dispatch_semaphore_wait(self.semphore, dispatch_time(DISPATCH_TIME_NOW, lxd_wait_interval));
if (waitTime != LXD_SEMPHORE_SUCCESS) {
if (!SHAREDMONITOR.observer) {
SHAREDMONITOR.timeOut = 0;
[SHAREDMONITOR stopMonitoring];
continue;
}
if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeSources || SHAREDMONITOR.currentActivity == kCFRunLoopAfterWaiting) {
//連續(xù)卡頓次數小于5,繼續(xù)
if (++SHAREDMONITOR.timeOut < 5) {
continue;
}
//連續(xù)卡頓次數大于等于5,打印主線程的方法調用棧
[LXDBacktraceLogger lxd_logMain];
[NSThread sleepForTimeInterval: lxd_restore_interval];
}
}
SHAREDMONITOR.timeOut = 0;
}
});
}
參考上面注釋簡單看一下。
面試題
- 優(yōu)化你是從哪幾方面著手?
卡頓優(yōu)化、耗電優(yōu)化、啟動優(yōu)化、APP瘦身 - 造成tableView卡頓的原因大致有哪些?你平時是怎么優(yōu)化的?
一般列表卡頓就是一些耗時的操作放在主線程了,具體可參考上面針對CPU、GPU卡頓優(yōu)化的方式。
Demo地址:卡頓優(yōu)化