iOS開發(fā)底層探索界面優(yōu)化示例詳解
1、卡頓原理
1.1、界面顯示原理

- CPU:Layout UI布局、文本計算、Display繪制、Prepare圖片解碼、Commit提交位圖給 GPU
- GPU:用于渲染,將結(jié)果放入 FrameBuffer
- FrameBuffer:幀緩沖
- Video Controller:根據(jù)Vsync(垂直同步)信號,逐行讀取 FrameBuffer 中的數(shù)據(jù),經(jīng)過數(shù)模轉(zhuǎn)換傳遞給 Monitor
- Monitor:顯示器,用于顯示;對于顯示模塊來說,會按照手機刷新率以固定的頻率:1 / 刷新率 向 FrameBuffer 索要數(shù)據(jù),這個索要數(shù)據(jù)的命令就是 垂直同步信號Vsync(低刷60幀為16.67毫秒,高刷120幀為 8.33毫秒,下邊舉例主要以低刷16.67毫秒為主)
1.2、界面撕裂
顯示端每16.67ms從 FrameBuffer(幀緩存區(qū))讀取一幀數(shù)據(jù),如果遇到耗時操作交付不了,那么當(dāng)前畫面就還是舊一幀的畫面,但顯示過程中,下一幀數(shù)據(jù)準(zhǔn)備完畢,導(dǎo)致部分顯示的又是新數(shù)據(jù),這樣就會造成屏幕撕裂
1.3、界面卡頓
為了解決界面撕裂,蘋果使用雙緩沖機制 + 垂直同步信號,使用 2個FrameBuffer 存儲 GPU 處理結(jié)果,顯示端交替從這2個FrameBuffer中讀取數(shù)據(jù),一個被讀取時另一個去緩存;但解決界面撕裂的問題也帶來了新的問題:掉幀

如果遇到畫面帶馬賽克等情況,導(dǎo)致GPU渲染能力跟不上,會有2種掉幀情況;
如圖,F(xiàn)rameBuffer2 未渲染完第2幀,下一個16.67ms去 FrameBuffer1 中拿第3幀:
- 掉幀情況1:第3幀渲染完畢,接下來需要第4幀,第2幀被丟棄
- 掉幀情況2:第3幀未渲染完,再一個16.67ms去 FrameBuffer2 拿到第2幀,但第1幀多停留了16.67*2毫秒
小結(jié)
固定的時間間隔會收到垂直同步信號(Vsync),如果 CPU 和 GPU 還沒有將下一幀數(shù)據(jù)放到對應(yīng)的幀 FrameBuffer緩沖區(qū),就會出現(xiàn) 掉幀

2、卡頓檢測
2.1、CADisplayLink
系統(tǒng)在每次發(fā)送 VSync 時,就會觸發(fā)CADisplayLink,通過統(tǒng)計每秒發(fā)送 VSync 的數(shù)量來查看 App 的 FPS 是否穩(wěn)定
#import "ViewController.h"
@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *link;
@property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒記錄一次時間
@property (nonatomic, assign) NSUInteger count; // 記錄VSync1秒內(nèi)發(fā)送的數(shù)量
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkAction:)];
[_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)linkAction: (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;
NSLog(@"?? FPS : %f ", fps);
}
@end
2.2、RunLoop檢測
RunLoop 的退出和進(jìn)入實質(zhì)都是Observer的通知,我們可以監(jiān)聽Runloop的狀態(tài),并在相關(guān)回調(diào)里發(fā)送信號,如果在設(shè)定的時間內(nèi)能夠收到信號說明是流暢的;如果在設(shè)定的時間內(nèi)沒有收到信號,說明發(fā)生了卡頓。
#import "LZBlockMonitor.h"
@interface LZBlockMonitor (){
CFRunLoopActivity activity;
}
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end
@implementation LZBlockMonitor
+ (instancetype)sharedInstance {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (void)start{
[self registerObserver];
[self startMonitor];
}
- (void)registerObserver{
CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
//NSIntegerMax : 優(yōu)先級最小
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
NSIntegerMax,
&CallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
LZBlockMonitor *monitor = (__bridge LZBlockMonitor *)info;
monitor->activity = activity;
// 發(fā)送信號
dispatch_semaphore_t semaphore = monitor->_semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)startMonitor{
// 創(chuàng)建信號
_semaphore = dispatch_semaphore_create(0);
// 在子線程監(jiān)控時長
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 超時時間是 1 秒,沒有等到信號量,st 就不等于 0, RunLoop 所有的任務(wù)
long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
if (st != 0)
{
if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting)
{
if (++self->_timeoutCount < 2){
NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 一秒左右的衡量尺度 很大可能性連續(xù)來 避免大規(guī)模打印!
NSLog(@"檢測到超過兩次連續(xù)卡頓");
}
}
self->_timeoutCount = 0;
}
});
}
@end
- 主線程監(jiān)聽 kCFRunLoopBeforeSources(即將處理事件)和kCFRunLoopAfterWaiting(即將休眠),子線程監(jiān)控時長,若連續(xù)兩次 1秒 內(nèi)沒有收到信號,說明發(fā)生了卡頓
2.3、微信matrix
- 微信的matrix也是借助 runloop 實現(xiàn),大體流程與上面 Runloop 方式相同,它使用退火算法優(yōu)化捕獲卡頓的效率,防止連續(xù)捕獲相同的卡頓,并且通過保存最近的20個主線程堆棧信息,獲取最近最耗時堆棧
2.4、滴滴DoraemonKit
- DoraemonKit的卡頓檢測方案不使用 RunLoop,它也是while循環(huán)中根據(jù)一定的狀態(tài)判斷,通過主線程中不斷發(fā)送信號semaphore,循環(huán)中等待信號的時間為5秒,等待超時則說明主線程卡頓,并進(jìn)行相關(guān)上報
3、優(yōu)化方法
平時簡單的方案有:
- 避免使用 透明UIView
- 盡量使用PNG圖片
- 避免離屏渲染(圓角使用貝塞爾曲線等)
3.1、預(yù)排版
- 就是常規(guī)的在Model層請求數(shù)據(jù)后提前將cell高度算好
3.2、預(yù)編碼 / 解碼
UIImage 是一個Model,二進(jìn)制流數(shù)據(jù) 存儲在DataBuffer中,經(jīng)過decode解碼,加載到imageBuffer中,最終進(jìn)入FrameBuffer才能被渲染

- 當(dāng)使用 UIImage 或CGImageSource的方法創(chuàng)建圖片時,圖片的數(shù)據(jù)不會立即解碼,而是在設(shè)置UIImageView.image時解碼
- 將圖片設(shè)置到UIImageView/CALayer.contents中,然后在CALayer提交至GPU渲染前,CGImage中的數(shù)據(jù)才進(jìn)行解碼
- 如果任由系統(tǒng)處理,這一步則無法避免,并且會發(fā)生在主線程中。如果想避免這個機制,在子線程先將圖片繪制到CGBitmapContext,然后從Bitmap中創(chuàng)建圖片
3.3、按需加載
如果目標(biāo)行與當(dāng)前行相差超過指定行數(shù),只加載目標(biāo)滾動范圍的前后指定3行
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
[needLoadArr removeAllObjects];
}
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{
NSIndexPath *ip = [self indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
NSIndexPath *cip = [[self indexPathsForVisibleRows] firstObject];
NSInteger skipCount = 8;
if (labs(cip.row-ip.row)>skipCount) {
NSArray *temp = [self indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.width, self.height)];
NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
if (velocity.y<0) {
NSIndexPath *indexPath = [temp lastObject];
if (indexPath.row+3<datas.count) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+1 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+3 inSection:0]];
}
} else {
NSIndexPath *indexPath = [temp firstObject];
if (indexPath.row>3) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]];
}
}
[needLoadArr addObjectsFromArray:arr];
}
}
在滑動結(jié)束時進(jìn)行 Cell 的渲染
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView{
scrollToToping = YES;
return YES;
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView{
scrollToToping = NO;
[self loadContent];
}
- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView{
scrollToToping = NO;
[self loadContent];
}
//用戶觸摸時第一時間加載內(nèi)容
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
if (!scrollToToping) {
[needLoadArr removeAllObjects];
[self loadContent];
}
return [super hitTest:point withEvent:event];
}
- (void)loadContent{
if (scrollToToping) {
return;
}
if (self.indexPathsForVisibleRows.count<=0) {
return;
}
if (self.visibleCells && self.visibleCells.count>0) {
for (id temp in [self.visibleCells copy]) {
VVeboTableViewCell *cell = (VVeboTableViewCell *)temp;
[cell draw];
}
}
}
- 這種方式會導(dǎo)致滑動時有空白內(nèi)容,因此要做好占位內(nèi)容
3.4、異步渲染
- 異步渲染 就是在子線程把需要繪制的圖形提前處理好,然后將處理好的圖像數(shù)據(jù)直接返給主線程使用
- 異步渲染操作的是layer層,將多層堆疊的控件們通過UIGraphics畫成一張位圖,然后展示在layer.content上
3.4.1、CALayer
- CALayer基于CoreAnimation進(jìn)而基于QuartzCode,只負(fù)責(zé)顯示,且顯示的是位圖,不能處理用戶的觸摸事件
- 不需要與用戶交互時,使用 UIView 和 CALayer 都可以,甚至 CALayer 更簡潔高效
3.4.2、異步渲染實現(xiàn)
- 異步渲染的框架推薦:Graver、YYAsyncLayer
- CALayer 在調(diào)用display方法后回去調(diào)用繪制相關(guān)的方法,繪制會執(zhí)行drawRect:方法
簡單例子
繼承 CALayer
#import "LZLayer.h"
@implementation LZLayer
//前面斷點調(diào)用寫下的代碼
- (void)layoutSublayers{
if (self.delegate && [self.delegate respondsToSelector:@selector(layoutSublayersOfLayer:)]) {
//UIView
[self.delegate layoutSublayersOfLayer:self];
}else{
[super layoutSublayers];
}
}
//繪制流程的發(fā)起函數(shù)
- (void)display{
// Graver 實現(xiàn)思路
CGContextRef context = (__bridge CGContextRef)([self.delegate performSelector:@selector(createContext)]);
[self.delegate layerWillDraw:self];
[self drawInContext:context];
[self.delegate displayLayer:self];
[self.delegate performSelector:@selector(closeContext)];
}
@end
繼承 UIView
// - (CGContextRef)createContext 和 - (void)closeContext要在.h中聲明
#import "LZView.h"
#import "LZLayer.h"
@implementation LZView
- (void)drawRect:(CGRect)rect {
// Drawing code, 繪制的操作, BackingStore(額外的存儲區(qū)域產(chǎn)于的) -- GPU
}
//子視圖的布局
- (void)layoutSubviews{
[super layoutSubviews];
}
+ (Class)layerClass{
return [LZLayer class];
}
//
- (void)layoutSublayersOfLayer:(CALayer *)layer{
[super layoutSublayersOfLayer:layer];
[self layoutSubviews];
}
- (CGContextRef)createContext{
UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
CGContextRef context = UIGraphicsGetCurrentContext();
return context;
}
- (void)layerWillDraw:(CALayer *)layer{
//繪制的準(zhǔn)備工作,do nontihing
}
//繪制的操作
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
[super drawLayer:layer inContext:ctx];
// 畫個不規(guī)則圖形
CGContextMoveToPoint(ctx, self.bounds.size.width / 2- 20, 20);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 20, 20);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 40, 80);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 100);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20);
CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor);
CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描邊
CGContextDrawPath(ctx, kCGPathFillStroke);
// 畫個紅色方塊
[[UIColor redColor] set];
//Core Graphics
UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(self.bounds.size.width / 2- 20, self.bounds.size.height / 2- 20, 40, 40)];
CGContextAddPath(ctx, path.CGPath);
CGContextFillPath(ctx);
// 文字
[@"LZ" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:20],NSForegroundColorAttributeName: UIColor.blueColor}];
// 圖片
[[UIImage imageWithContentsOfFile:@"/Volumes/Disk_D/test code/Test/Test/yasuo.png"] drawInRect:CGRectMake(10, self.bounds.size.height/2, self.bounds.size.width - 20, self.bounds.size.height/2 -10)];
}
//layer.contents = (位圖)
- (void)displayLayer:(CALayer *)layer{
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
layer.contents = (__bridge id)(image.CGImage);
});
}
- (void)closeContext{
UIGraphicsEndImageContext();
}
控件們被繪制成了一張圖

此外,雖然將控件畫到一張位圖上,但是還有問題,就是控件的交互事件,內(nèi)容較多建議鉆研一下graver的源碼
以上就是iOS開發(fā)底層探索界面優(yōu)化示例詳解的詳細(xì)內(nèi)容,更多關(guān)于iOS開發(fā)界面優(yōu)化的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
iOS tableView多輸入框如何獲取數(shù)據(jù)
這篇文章主要給大家介紹了關(guān)于iOS tableView多輸入框如何獲取數(shù)據(jù)的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-04-04
iOS13適配深色模式(Dark Mode)的實現(xiàn)
這篇文章主要介紹了iOS13適配深色模式(Dark Mode)的實現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-03-03
iOS應(yīng)用開發(fā)中AFNetworking庫的常用HTTP操作方法小結(jié)
AFNetworking庫是Objective-C語言寫成的用于處理HTTP的第三方庫,在GitHub上開源并且一直在被更新和維護(hù),下面就一起來看一下iOS應(yīng)用開發(fā)中AFNetworking庫的常用HTTP操作方法小結(jié)2016-05-05
iOS 使用 socket 實現(xiàn)即時通信示例(非第三方庫)
這篇文章主要介紹了iOS 使用 socket 即時通信示例(非第三方庫)的資料,這里整理了詳細(xì)的代碼,有需要的小伙伴可以參考下。2017-02-02

