iOS 底層原理:界面優(yōu)化
界面優(yōu)化無(wú)非就是解決卡頓問(wèn),優(yōu)化界面流暢度,以下就通過(guò)先分析卡頓的原因,然后再介紹具體的優(yōu)化方案,來(lái)分析如何做界面優(yōu)化
界面渲染流程
具體流程可以參考圖片渲染初探[1]這里就大概講一下圖片渲染的流程,大體上可以分為三個(gè)階段就是
CPU處理階段GPU處理階段和視頻控制器顯示階段。大致流程圖解如下:

蘋果為了解決圖片撕裂的問(wèn)題使用了VSync+ 雙緩沖區(qū)的形式,就是顯示器顯示完成一幀的渲染的時(shí)候會(huì)向 發(fā)送一個(gè)垂直信號(hào)VSync,收到這個(gè)這個(gè)垂直信號(hào)之后顯示器開(kāi)始讀取另外一個(gè)幀緩沖區(qū)中的數(shù)據(jù)而App接到垂直信號(hào)之后開(kāi)始新一幀的渲染。CPU主要是計(jì)算出需要渲染的模型數(shù)據(jù)GPU主要是根據(jù)CPU提供的渲染模型數(shù)據(jù)渲染圖片然后存到幀緩沖區(qū)視頻控制器沖幀緩沖區(qū)中讀取數(shù)據(jù)最后成像 卡頓原理
通過(guò)上文張的界面渲染流程知道,在圖一幀渲染完成之后會(huì)發(fā)送一個(gè)垂直信號(hào)此時(shí)開(kāi)始讀取另外一個(gè)幀緩沖區(qū)中的數(shù)據(jù),加入此時(shí)
CPU和GPU的工作還沒(méi)有完成,也就是另外一個(gè)幀緩沖區(qū)還是加鎖狀態(tài)沒(méi)有數(shù)據(jù)的時(shí)候,此時(shí)顯示器顯示的還是上一幀的圖像那么這種情況就會(huì)一直等待下一幀繪制完成然后視頻控制器再讀取另外一個(gè)幀緩沖區(qū)中的數(shù)據(jù)然后成像,中間這個(gè)等待的過(guò)程就造成了掉幀,也就是會(huì)卡頓。
卡頓圖解如下:
這種情況隨會(huì)造成卡頓卡頓檢測(cè)
FPS監(jiān)控
60Hz,也就是每秒中刷新屏幕60次,也就是每秒中有60幀渲染完成,差不多每幀渲染的時(shí)間是1000/60 = 16.67毫秒整個(gè)界面會(huì)比較流暢,一般刷新率低于45Hz的就會(huì)出現(xiàn)明顯的卡頓現(xiàn)象。這里可以通過(guò)YYFPSLabel來(lái)實(shí)現(xiàn)FPS的監(jiān)控,該原理主要是依靠 CADisplayLink來(lái)實(shí)現(xiàn)的,通過(guò)CADisplayLink來(lái)監(jiān)聽(tīng)每次屏幕刷新并獲取屏幕刷新的時(shí)間,然后使用次數(shù)(也就是1)除以每次刷新的時(shí)間間隔得到FPS,具體源碼如下:#import "YYFPSLabel.h"
#import "YYKit.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];
}
//YYWeakProxy 這里使用了虛擬類來(lái)解決強(qiáng)引用問(wèn)題
_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;
}
- (void)tick:(CADisplayLink *)link {
if (_lastTime == 0) {
_lastTime = link.timestamp;
NSLog(@"sdf");
return;
}
//次數(shù)
_count++;
//時(shí)間
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 setColor:color range:NSMakeRange(0, text.length - 3)];
[text setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
text.font = _font;
[text setFont:_subFont range:NSMakeRange(text.length - 4, 1)];
self.attributedText = text;
}
@end
FPS只用在開(kāi)發(fā)階段的輔助性的數(shù)值,因?yàn)樗麜?huì)頻繁喚醒 runloop如果 runloop在閑置的狀態(tài)被 CADisplayLink喚醒則會(huì)消耗性能。
通過(guò)RunLoop檢測(cè)卡頓
通過(guò)監(jiān)聽(tīng)主線程 Runloop一次循環(huán)的時(shí)間來(lái)判斷是否卡頓,這里需要配合使用 GCD的信號(hào)量來(lái)實(shí)現(xiàn),設(shè)置初始化信號(hào)量為0,然后開(kāi)一個(gè)子線程等待信號(hào)量的觸發(fā),也是就是在子線程的方法里面調(diào)用 dispatch_semaphore_wait方法設(shè)置等待時(shí)間是1秒,然后主線程的 Runloop的 Observer回調(diào)方法中發(fā)送信號(hào)也就是調(diào)用 dispatch_semaphore_signal方法,此時(shí)時(shí)間可以置為0了,如果是等待時(shí)間超時(shí)則看此時(shí)的 Runloop的狀態(tài)是否是 kCFRunLoopBeforeSources或者是 kCFRunLoopAfterWaiting,如果在這兩個(gè)狀態(tài)下兩秒則說(shuō)明有卡頓,詳細(xì)代碼如下:(代碼中也有相關(guān)的注釋)
#import "LGBlockMonitor.h"
@interface LGBlockMonitor (){
CFRunLoopActivity activity;
}
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end
@implementation LGBlockMonitor
+ (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];
}
static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
LGBlockMonitor *monitor = (__bridge LGBlockMonitor *)info;
monitor->activity = activity;
// 發(fā)送信號(hào)
dispatch_semaphore_t semaphore = monitor->_semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)registerObserver{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
//NSIntegerMax : 優(yōu)先級(jí)最小
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
NSIntegerMax,
&CallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
- (void)startMonitor{
// 創(chuàng)建信號(hào)c
_semaphore = dispatch_semaphore_create(0);
// 在子線程監(jiān)控時(shí)長(zhǎng)
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 超時(shí)時(shí)間是 1 秒,沒(méi)有等到信號(hào)量,st 就不等于 0, RunLoop 所有的任務(wù)
// 沒(méi)有接收到信號(hào)底層會(huì)先對(duì)信號(hào)量進(jìn)行減減操作,此時(shí)信號(hào)量就變成負(fù)數(shù)
// 所以開(kāi)始進(jìn)入等到,等達(dá)到了等待時(shí)間還沒(méi)有收到信號(hào)則進(jìn)行加加操作復(fù)原信號(hào)量
// 執(zhí)行進(jìn)入等待的方法dispatch_semaphore_wait會(huì)返回非0的數(shù)
// 收到信號(hào)的時(shí)候此時(shí)信號(hào)量是1 底層是減減操作,此時(shí)剛好等于0 所以直接返回0
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)
{
//如果一直處于處理source0或者接受mach_port的狀態(tài)則說(shuō)明runloop的這次循環(huán)還沒(méi)有完成
if (++self->_timeoutCount < 2){
NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 如果超過(guò)兩秒則說(shuō)明卡頓了
// 一秒左右的衡量尺度 很大可能性連續(xù)來(lái) 避免大規(guī)模打印!
NSLog(@"檢測(cè)到超過(guò)兩次連續(xù)卡頓");
}
}
self->_timeoutCount = 0;
}
});
}
@end
微信matrix
此方案也是借助 runloop實(shí)現(xiàn)的大體流程和方案三相同,不過(guò)微信加入了堆棧分析,能夠定位到耗時(shí)的方法調(diào)用堆棧,所以需要準(zhǔn)確的分析卡頓原因可以借助微信matrix來(lái)分析卡頓。當(dāng)然也可以在方案2中使用 PLCrashReporter這個(gè)開(kāi)源的第三方庫(kù)來(lái)獲取堆棧信息
滴滴DoraemonKit
實(shí)現(xiàn)方案大概就是在子線程中一直 ping主線程,在主線程卡頓的情況下,會(huì)出現(xiàn)斷在的無(wú)響應(yīng)的表現(xiàn),進(jìn)而檢測(cè)卡頓
優(yōu)化方案
上文中分析卡頓的原因我們知道主要就是在
CPU和GPU階段占用時(shí)間太長(zhǎng)導(dǎo)致了掉幀卡頓,所以界面優(yōu)化主要工作就是給CPU和GPU減負(fù)預(yù)排版
預(yù)排版主要是對(duì)
CPU進(jìn)行減負(fù)。
假設(shè)現(xiàn)在又個(gè)TableView其中需要根據(jù)每個(gè)cell的內(nèi)容來(lái)定cell的高度。我們知道TableView有重用機(jī)制,如果復(fù)用池中有數(shù)據(jù),即將滑入屏內(nèi)的cell就會(huì)使用復(fù)用池內(nèi)的cell,做到節(jié)省資源,但是還是要根據(jù)新數(shù)據(jù)的內(nèi)容來(lái)計(jì)算cell的高度,重新布局新cell中內(nèi)容的布局 ,這樣反復(fù)滑動(dòng)TableView相同的cell就會(huì)反復(fù)計(jì)算其frame,這樣也給CPU帶來(lái)了負(fù)擔(dān)。如果在得到數(shù)據(jù)創(chuàng)建模型的時(shí)候就把cellframe算出,TableView返回模型中的frame這樣的話同樣的一條cell就算來(lái)回反復(fù)滑動(dòng)TableView,計(jì)算frame這個(gè)操作也就僅僅只會(huì)執(zhí)行一次,所以也就做到了減負(fù)的功能,如下圖:一個(gè)cell的組成需要modal找到數(shù)據(jù),也需要layout找到這個(gè)cell如何布局:
預(yù)解碼 & 預(yù)渲染
圖片的渲染流程,在
CPU階段拿到圖片的頂點(diǎn)數(shù)據(jù)和紋理之后會(huì)進(jìn)行解碼生產(chǎn)位圖,然后傳遞到GPU進(jìn)行渲染主要流程圖如下
如果圖片很多很大的情況下解碼工作就會(huì)占用主線程 RunLoop導(dǎo)致其他工作無(wú)法執(zhí)行比如滑動(dòng),這樣就會(huì)造成卡頓現(xiàn)象,所以這里就可以將解碼的工作放到異步線程中不占用主線程,可能有人會(huì)想只要將圖片加載放到異步線程中在異步線程中生成一個(gè)UIImage或者是CGImage然后再主線程中設(shè)置給UIImageView,此時(shí)可以寫段代碼使用instruments的Time Profiler查看一下堆棧信息
發(fā)現(xiàn)圖片的編解碼還是在主線程。針對(duì)這種問(wèn)題常見(jiàn)的做法是在子線程中先將圖片繪制到CGBitmapContext,然后從Bitmap直接創(chuàng)建圖片,例如SDWebImage三方框架中對(duì)圖片編解碼的處理。這就是Image的預(yù)解碼,代碼如下:dispatch_async(queue, ^{
CGImageRef cgImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:self]]].CGImage;
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
cgImage = CGBitmapContextCreateImage(context);
UIImage * image = [[UIImage imageWithCGImage:cgImage] cornerRadius:width * 0.5];
CGContextRelease(context);
CGImageRelease(cgImage);
completion(image);
});按需加載
顧名思義需要顯示的加載出來(lái),不需要顯示的加載,例如
TableView中的圖片滑動(dòng)的時(shí)候不加載,在滑動(dòng)停止的時(shí)候加載(可以使用Runloop,圖片繪制設(shè)置defaultModal就行)異步渲染
再說(shuō)異步渲染之前先了解一下
UIView和CALayer的關(guān)系:UIView是基于UIKit框架的,能夠接受點(diǎn)擊事件,處理用戶的觸摸事件,并管理子視圖CALayer是基于CoreAnimation,而CoreAnimation是基于QuartzCode的。所以CALayer只負(fù)責(zé)顯示,不能處理用戶的觸摸事件UIView是直接繼承UIResponder的,CALayer是繼承NSObject的UIVIew的主要職責(zé)是負(fù)責(zé)接收并響應(yīng)事件;而CALayer的主要職責(zé)是負(fù)責(zé)顯示UI。UIView依賴于CALayer得以顯示其他
減少圖層的層級(jí) 減少離屏渲染 圖片顯示的話圖片的大小設(shè)置(不要太大) 少使用 addView給cell動(dòng)態(tài)添加view盡量避免使用透明 view,因?yàn)槭褂猛该?code style="font-size: 14px;font-family: 'Operator Mono', Consolas, Monaco, Menlo, monospace;word-break: break-all;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(233, 105, 0);background: rgb(248, 248, 248);">view,會(huì)導(dǎo)致在GPU中計(jì)算像素時(shí),會(huì)將透明view下層圖層的像素也計(jì)算進(jìn)來(lái),即顏色混合處理(當(dāng)有兩個(gè)圖層的時(shí)候一個(gè)是半透明一個(gè)是不透明如果半透明的層級(jí)更高的話此時(shí)就會(huì)觸發(fā)顏色混合,底層的混合并不是僅僅的將兩個(gè)圖層疊加而是會(huì)將兩股顏色混合計(jì)算出新的色值顯示在屏幕中)
總結(jié):UIView主要負(fù)責(zé)時(shí)間處理,CALayer主要是視圖顯示 異步渲染的原理其實(shí)也就是在子線程將所有的視圖繪制成一張位圖,然后回到主線程賦值給 layer的 contents,例如 Graver框架的異步渲染流程如下:
核心源碼如下:
if (drawingFinished && targetDrawingCount == layer.drawingCount)
{
CGImageRef CGImage = context ? CGBitmapContextCreateImage(context) : NULL;
{
// 讓 UIImage 進(jìn)行內(nèi)存管理
// 最終生成的位圖
UIImage *image = CGImage ? [UIImage imageWithCGImage:CGImage] : nil;
void (^finishBlock)(void) = ^{
// 由于block可能在下一runloop執(zhí)行,再進(jìn)行一次檢查
if (targetDrawingCount != layer.drawingCount)
{
failedBlock();
return;
}
//主線程中賦值完成顯示
layer.contents = (id)image.CGImage;
// ...
}
if (drawInBackground) dispatch_async(dispatch_get_main_queue(), finishBlock);
else finishBlock();
}
// 一些清理工作: release CGImageRef, Image context ending
}
最終效果圖如下:
也可以使用 YYAsyncLayer和
參考資料
https://juejin.cn/post/6847902220403343374: https://juejin.cn/post/6847902220403343374
轉(zhuǎn)自:掘金 Potato_土豆
https://juejin.cn/post/6977666830114816030
-End-
最近有一些小伙伴,讓我?guī)兔φ乙恍?nbsp;面試題 資料,于是我翻遍了收藏的 5T 資料后,匯總整理出來(lái),可以說(shuō)是程序員面試必備!所有資料都整理到網(wǎng)盤了,歡迎下載!

面試題】即可獲取