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 的退出和進入實質(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秒,等待超時則說明主線程卡頓,并進行相關(guān)上報
3、優(yōu)化方法
平時簡單的方案有:
- 避免使用 透明UIView
- 盡量使用PNG圖片
- 避免離屏渲染(圓角使用貝塞爾曲線等)
3.1、預(yù)排版
- 就是常規(guī)的在Model層請求數(shù)據(jù)后提前將cell高度算好
3.2、預(yù)編碼 / 解碼
UIImage 是一個Model,二進制流數(shù)據(jù) 存儲在DataBuffer中,經(jīng)過decode解碼,加載到imageBuffer中,最終進入FrameBuffer才能被渲染
- 當(dāng)使用 UIImage 或CGImageSource的方法創(chuàng)建圖片時,圖片的數(shù)據(jù)不會立即解碼,而是在設(shè)置UIImageView.image時解碼
- 將圖片設(shè)置到UIImageView/CALayer.contents中,然后在CALayer提交至GPU渲染前,CGImage中的數(shù)據(jù)才進行解碼
- 如果任由系統(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é)束時進行 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進而基于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)文章
詳解Swift 利用Opration和OprationQueue來下載網(wǎng)絡(luò)圖片
這篇文章主要介紹了詳解Swift 利用Opration和OprationQueue來下載網(wǎng)絡(luò)圖片的相關(guān)資料,希望通過本文能幫助到大家,需要的朋友可以參考下2017-09-09淺談iOS開發(fā)如何適配暗黑模式(Dark Mode)
這篇文章主要介紹了淺談iOS開發(fā)如何適配暗黑模式(Dark Mode),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-09-09