一、需求背景
1、現(xiàn)狀
iOS所提供的UIKit框架,其工作基本是在主線程上進行,界面繪制、用戶輸入響應交互等等。當大量且頻繁的繪制任務(wù),以及各種業(yè)務(wù)邏輯同時放在主線程上完成時,便有可能造成界面卡頓,丟幀現(xiàn)象,即在16.7ms內(nèi)未能完成1幀的繪制,幀率低于60fps黃金標準。目前常用的UITableView或UICollectionView,在大量復雜文本及圖片內(nèi)容填充后,如果沒有優(yōu)化處理,快速滑動的情況下易出現(xiàn)卡頓,流暢性差問題。
2、需求
不依賴任何第三方pod框架,主要從異步線程繪制、圖片異步下載渲染等方面,盡可能優(yōu)化UITableView的使用,提高滑動流暢性,讓幀率穩(wěn)定在60fps。
(網(wǎng)上有很多優(yōu)秀的性能優(yōu)化博客和開源代碼,本方案也是基于前人的經(jīng)驗,結(jié)合自身的理解和梳理寫成demo,關(guān)鍵代碼有做注釋,很多細節(jié)值得推敲和持續(xù)優(yōu)化,不足之處望指正。)
二、解決方案及亮點
1、方案概述
? 異步繪制任務(wù)收集與去重;
? 通過單例監(jiān)聽main runloop回調(diào),執(zhí)行異步繪制任務(wù);
? 支持異步繪制動態(tài)文本內(nèi)容,減輕主線程壓力,并緩存高度減少CPU計算;
? 支持異步下載和渲染圖片并緩存,僅在可視區(qū)域渲染;
? 異步隊列并發(fā)管理,擇優(yōu)選取執(zhí)行任務(wù);
? 發(fā)現(xiàn)UITableView首次reload會觸發(fā)3次的系統(tǒng)問題,初始開銷增大,待優(yōu)化;
2、問題點
? 異步繪制時機及減少重復繪制;
? 隊列的并發(fā)和擇優(yōu);
3、分析過程
1)異步繪制時機及減少重復繪制
這里簡單描述下繪制原理:當UI被添加到界面后,我們改變Frame,或更新 UIView/CALayer層次,或調(diào)用setNeedsLayout/setNeedsDisplay方法,均會添加重新繪制任務(wù)。這個時候系統(tǒng)會注冊一個Observer監(jiān)聽BeforeWaiting(即將進入休眠)和Exit(即將退出Loop)事件,并回調(diào)執(zhí)行當前繪制任務(wù)(setNeedsDisplay->display->displayLayer),最終更新界面。
由上可知,我們可以模擬系統(tǒng)繪制任務(wù)的收集,在runloop回調(diào)中去執(zhí)行,并重寫layer的dispaly方法,開辟子線程進行異步繪制,再返回主線程刷新。
當同個UI多次觸發(fā)繪制請求時,怎樣減少重復繪制,以便減輕并發(fā)壓力比較重要。本案通過維護一個全局線程安全的原子性狀態(tài),在繪制過程中的關(guān)鍵步驟處理前均校驗是否要放棄當前多余的繪制任務(wù)。
2)隊列的并發(fā)和擇優(yōu)
一次runloop回調(diào),經(jīng)常會執(zhí)行多個繪制任務(wù),這里考慮開辟多個線程去異步執(zhí)行。首選并行隊列可以滿足,但為了滿足性能效率的同時確保不過多的占用資源和避免線程間競爭等待,更好的方案應該是開辟多個串行隊列單線程處理并發(fā)任務(wù)。
接下來的問題是,異步繪制創(chuàng)建幾個串行隊列合適?
我們知道一個n核設(shè)備,并發(fā)執(zhí)行n個任務(wù),最多創(chuàng)建n個線程時,線程之間將不會互相競爭資源。因此,不建議數(shù)量設(shè)置超過當前激活的處理器數(shù),并可根據(jù)項目界面復雜度以及設(shè)備性能適配,適當限制并發(fā)開銷,文本異步繪制最大隊列數(shù)設(shè)置如下:
#definekMAX_QUEUE_COUNT6
- (NSUInteger)limitQueueCount {
if(_limitQueueCount ==0) {
// 獲取當前系統(tǒng)處于激活狀態(tài)的處理器數(shù)量
NSUInteger processorCount = [NSProcessInfo processInfo].activeProcessorCount;
// 根據(jù)處理器的數(shù)量和設(shè)置的最大隊列數(shù)來設(shè)定當前隊列數(shù)組的大小
_limitQueueCount = processorCount >0? (processorCount > kMAX_QUEUE_COUNT ? kMAX_QUEUE_COUNT : processorCount) :1;
}
return_limitQueueCount;
}
文本的異步繪制串行隊列用GCD實現(xiàn),圖片異步下載通過NSOperationQueue實現(xiàn),兩者最大并發(fā)數(shù)參考SDWebImage圖片下載并發(fā)數(shù)的限制數(shù):6。
如何擇優(yōu)選取執(zhí)行任務(wù)?文本異步隊列的選取,可以自定義隊列的任務(wù)數(shù)標記,在隊列執(zhí)行任務(wù)前計算+1,當任務(wù)執(zhí)行結(jié)束計算-1。這里忽略每次繪制難易度的略微差異,我們便可以判定任務(wù)數(shù)最少接近于最優(yōu)隊列。圖片異步下載任務(wù),交由NSOperationQueue處理并發(fā),我們要處理的是,讓同個圖片在多次并發(fā)下載請求下,僅生成1個NSOperation添加到queue,即去重只下載一次并緩存,且在下載完成后返回主線程同步渲染多個觸發(fā)該下載請求的控件(本案demo僅用一張圖片,所以這種情況必須考慮到)。
三、詳細設(shè)計
1、設(shè)計圖
2、代碼原理剖析(寫在注釋)
1)設(shè)置runloop監(jiān)聽及回調(diào)
/**
runloop回調(diào),并發(fā)執(zhí)行異步繪制任務(wù)
*/
staticNSMutableSet *_taskSet =nil;
staticvoidADRunLoopCallBack(CFRunLoopObserverRefobserver,CFRunLoopActivityactivity,void*info) {
if(_taskSet.count==0)return;
NSSet*currentSet = _taskSet;
_taskSet = [NSMutableSetset];
[currentSet enumerateObjectsUsingBlock:^(ADTask *task,BOOL*stop) {
[task excute];
}];
}
/** task調(diào)用函數(shù)
- (void)excute {
((void (*)(id, SEL))[self.target methodForSelector:self.selector])(self.target, self.selector);
}
*/
- (void)setupRunLoopObserver {
// 創(chuàng)建任務(wù)集合
_taskSet = [NSMutableSetset];
// 獲取主線程的runloop
CFRunLoopRefrunloop =CFRunLoopGetMain();
// 創(chuàng)建觀察者,監(jiān)聽即將休眠和退出
CFRunLoopObserverRefobserver =CFRunLoopObserverCreate(CFAllocatorGetDefault(),
kCFRunLoopBeforeWaiting| kCFRunLoopExit,
true,// 重復
0xFFFFFF,// 設(shè)置優(yōu)先級低于CATransaction(2000000)
ADRunLoopCallBack,NULL);
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
CFRelease(observer);
2)創(chuàng)建、獲取文本異步繪制隊列,并擇優(yōu)選取
- (ADQueue *)ad_getExecuteTaskQueue {
// 1、創(chuàng)建對應數(shù)量串行隊列處理并發(fā)任務(wù),并行隊列線程數(shù)無法控制
if(self.queueArr.count
ADQueue *q = [[ADQueue alloc] init];
q.index=self.queueArr.count;
[self.queueArraddObject:q];
q.asyncCount+=1;
NSLog(@"queue[%ld]-asyncCount:%ld", (long)q.index, (long)q.asyncCount);
returnq;
}
// 2、當隊列數(shù)已達上限,擇優(yōu)獲取異步任務(wù)數(shù)最少的隊列
NSUIntegerminAsync = [[self.queueArrvalueForKeyPath:@"@min.asyncCount"] integerValue];
__block ADQueue *q =nil;
[self.queueArrenumerateObjectsUsingBlock:^(ADQueue * _Nonnull obj,NSUIntegeridx,BOOL* _Nonnull stop) {
if(obj.asyncCount<= minAsync) {
*stop =YES;
q = obj;
}
}];
q.asyncCount+=1;
NSLog(@"queue[%ld]-excute-count:%ld", (long)q.index, (long)q.asyncCount);
returnq;
}
- (void)ad_finishTask:(ADQueue *)q {
q.asyncCount-=1;
if(q.asyncCount<0) {
q.asyncCount=0;
}
NSLog(@"queue[%ld]-done-count:%ld", (long)q.index, (long)q.asyncCount);
}
3)異步繪制
/**
維護線程安全的繪制狀態(tài)
*/
@property(atomic,assign) ADLayerStatus status;
- (void)setNeedsDisplay {
// 收到新的繪制請求時,同步正在繪制的線程本次取消
self.status= ADLayerStatusCancel;
[supersetNeedsDisplay];
}
- (void)display {
// 標記正在繪制
self.status= ADLayerStatusDrawing;
if([self.delegaterespondsToSelector:@selector(asyncDrawLayer:inContext:canceled:)]) {
[selfasyncDraw];
}else{
[superdisplay];
}
}
- (void)asyncDraw {
__block ADQueue *q = [[ADManager shareInstance] ad_getExecuteTaskQueue];
__blockid delegate = (id)self.delegate;
dispatch_async(q.queue, ^{
// 重繪取消
if([selfcanceled]) {
[[ADManager shareInstance] ad_finishTask:q];
return;
}
// 生成上下文context
CGSizesize =self.bounds.size;
BOOLopaque =self.opaque;
CGFloatscale = [UIScreenmainScreen].scale;
CGColorRefbackgroundColor = (opaque &&self.backgroundColor) ?CGColorRetain(self.backgroundColor) :NULL;
UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
CGContextRefcontext =UIGraphicsGetCurrentContext();
if(opaque && context) {
CGContextSaveGState(context); {
if(!backgroundColor ||CGColorGetAlpha(backgroundColor) <1) {
CGContextSetFillColorWithColor(context, [UIColorwhiteColor].CGColor);
CGContextAddRect(context,CGRectMake(0,0, size.width* scale, size.height* scale));
CGContextFillPath(context);
}
if(backgroundColor) {
CGContextSetFillColorWithColor(context, backgroundColor);
CGContextAddRect(context,CGRectMake(0,0, size.width* scale, size.height* scale));
CGContextFillPath(context);
}
}CGContextRestoreGState(context);
CGColorRelease(backgroundColor);
}else{CGColorRelease(backgroundColor);
}// 使用context繪制
[delegate asyncDrawLayer:selfinContext:context canceled:[selfcanceled]];
// 重繪取消
if([selfcanceled]) {
[[ADManager shareInstance] ad_finishTask:q];
UIGraphicsEndImageContext();
return;
}
// 獲取image
UIImage*image =UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 結(jié)束任務(wù)
[[ADManager shareInstance] ad_finishTask:q];
// 重繪取消
if([selfcanceled]) {
return;
}
// 主線程刷新
dispatch_async(dispatch_get_main_queue(), ^{
self.contents= (__bridgeid)(image.CGImage);
});
});
}
4)異步下載緩存圖片
#pragma mark - 處理圖片
- (void)ad_setImageWithURL:(NSURL*)url target:(id)target completed:(void(^)(UIImage* _Nullable image,NSError* _Nullable error))completedBlock {
if(!url) {
if(completedBlock) {
NSDictionary*userInfo = @{NSLocalizedFailureReasonErrorKey:NSLocalizedStringFromTable(@"Expected URL to be a image URL",@"AsyncDraw",nil)};
NSError*error = [[NSErroralloc] initWithDomain:kERROR_DOMAIN code:NSURLErrorBadURLuserInfo:userInfo];
completedBlock(nil, error);
}
return;
}
// 1、緩存中讀取
NSString*imageKey = url.absoluteString;
NSData*imageData =self.imageDataDict[imageKey];
if(imageData) {
UIImage*image = [UIImageimageWithData:imageData];
if(completedBlock) {
completedBlock(image,nil);
}
}else{
// 2、沙盒中讀取
NSString*imagePath = [NSStringstringWithFormat:@"%@/Library/Caches/%@",NSHomeDirectory(), url.lastPathComponent];
imageData = [NSDatadataWithContentsOfFile:imagePath];
if(imageData) {
UIImage*image = [UIImageimageWithData:imageData];
if(completedBlock) {
completedBlock(image,nil);
}
}else{
// 3、下載并緩存寫入沙盒
ADOperation *operation = [selfad_downloadImageWithURL:url toPath:imagePath completed:completedBlock];
// 4、添加圖片渲染對象
[operation addTarget:target];
}
}
}
- (ADOperation *)ad_downloadImageWithURL:(NSURL*)url toPath:(NSString*)imagePath completed:(void(^)(UIImage* _Nullable image,NSError* _Nullable error))completedBlock? {
NSString*imageKey = url.absoluteString;
ADOperation *operation =self.operationDict[imageKey];
if(!operation) {
operation = [ADOperation blockOperationWithBlock:^{
NSLog(@"AsyncDraw image loading~");
NSData*newImageData = [NSDatadataWithContentsOfURL:url];
// 下載失敗處理
if(!newImageData) {
[self.operationDictremoveObjectForKey:imageKey];
NSDictionary*userInfo = @{NSLocalizedFailureReasonErrorKey:NSLocalizedStringFromTable(@"Failed to load the image",@"AsyncDraw",nil)};
NSError*error = [[NSErroralloc] initWithDomain:kERROR_DOMAIN code:NSURLErrorUnknownuserInfo:userInfo];
if(completedBlock) {
completedBlock(nil, error);
}
return;
}
// 緩存圖片數(shù)據(jù)
[self.imageDataDictsetValue:newImageData forKey:imageKey];
}];
// 設(shè)置完成回調(diào)
__block ADOperation *blockOperation = operation;
[operation setCompletionBlock:^{
NSLog(@"AsyncDraw image load completed~");
// 取緩存
NSData*newImageData =self.imageDataDict[imageKey];
if(!newImageData) {
return;
}
// 返回主線程刷新
[[NSOperationQueuemainQueue] addOperationWithBlock:^{
UIImage*newImage = [UIImageimageWithData:newImageData];
// 遍歷渲染同個圖片地址的所有控件
[blockOperation.targetSetenumerateObjectsUsingBlock:^(id_Nonnull obj,BOOL* _Nonnull stop) {
if([obj isKindOfClass:[UIImageViewclass]]) {
UIImageView*imageView = (UIImageView*)obj;
// ADImageView內(nèi)部判斷“超出可視范圍,放棄渲染~”
imageView.image= newImage;
}
}];
[blockOperation removeAllTargets];
}];
// 寫入沙盒
[newImageData writeToFile:imagePath atomically:YES];
// 移除任務(wù)
[self.operationDictremoveObjectForKey:imageKey];
}];
// 加入隊列
[self.operationQueueaddOperation:operation];
// 添加opertion
[self.operationDictsetValue:operation forKey:imageKey];
}
returnoperation;
}
四、使用示例
1)文本異步繪制
@implementationADLabel
#pragma mark - Pub MD
- (void)setText:(NSString*)text {
_text = text;
[[ADManager shareInstance] addTaskWith:selfselector:@selector(asyncDraw)];
}
// 綁定異步繪制layer
+ (Class)layerClass {
returnADLayer.class;
}
#pragma mark - Pri MD
- (void)asyncDraw {
[self.layersetNeedsDisplay];
}
#pragma mark - ADLayerDelegate
- (void)layerWillDraw:(CALayer*)layer {
}
- (void)asyncDrawLayer:(ADLayer *)layer inContext:(CGContextRef__nullable)ctx canceled:(BOOL)canceled {
if(canceled) {
NSLog(@"異步繪制取消~");
return;
}
UIColor*backgroundColor = _backgroundColor;
NSString*text = _text;
UIFont*font = _font;
UIColor*textColor = _textColor;
CGSizesize = layer.bounds.size;
CGContextSetTextMatrix(ctx,CGAffineTransformIdentity);
CGContextTranslateCTM(ctx,0, size.height);
CGContextScaleCTM(ctx,1, -1);
// 繪制區(qū)域
CGMutablePathRefpath =CGPathCreateMutable();
CGPathAddRect(path,NULL,CGRectMake(0,0, size.width, size.height));
// 繪制的內(nèi)容屬性字符串
NSDictionary*attributes = @{NSFontAttributeName: font,
NSForegroundColorAttributeName: textColor,
NSBackgroundColorAttributeName: backgroundColor,
NSParagraphStyleAttributeName:self.paragraphStyle?:[NSParagraphStylenew]
};
NSMutableAttributedString*attrStr = [[NSMutableAttributedStringalloc] initWithString:text attributes:attributes];
// 使用NSMutableAttributedString創(chuàng)建CTFrame
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrStr);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0, attrStr.length), path,NULL);
CFRelease(framesetter);
CGPathRelease(path);
// 使用CTFrame在CGContextRef上下文上繪制
CTFrameDraw(frame, ctx);
CFRelease(frame);
}
2)圖片異步下載渲染
@implementationADImageView
#pragma mark - Public Methods
- (void)setUrl:(NSString*)url {
_url = url;
[[ADManager shareInstance] ad_setImageWithURL:[NSURLURLWithString:self.url] target:selfcompleted:^(UIImage* _Nullable image,NSError* _Nullable error) {
if(image) {
self.image= image;
}
}];
}
五、成效舉證
針對本案制作了AsyncDrawDemo,是一個圖文排列布局的UITableView列表,類似新聞列表,TestTableViewCell.m中有異步繪制和圖片異步下載渲染開關(guān)
#define kAsyncDraw true// 異步開關(guān)
//#define kOnlyShowText true // 僅顯示文本進行測試
kAsyncDraw開啟前后測試對比清單:
? 同樣加載1000條數(shù)據(jù)的列表
? 動態(tài)文本緩存高度
? 同一設(shè)備:真機iPhone11 iOS13.5.1
? 操作:列表首次加載完成,幀率顯示60fps后,快速向上滑動至底部
本案通過YYFPSLabel觀察幀率大致均值變化,以及內(nèi)存/CPU變化截圖如下:
1)未開啟異步前:
穩(wěn)定60fps后開始快速滑動至列表底部的前后對比(幀率最低到1fps,滑動過程異??D,cpu未超過40%,內(nèi)存占用也不多,但非常耗電):
2)開啟異步后:
穩(wěn)定60fps后開始快速滑動至列表底部的前后對比(幀率穩(wěn)定在60fps,滑動過程非常流暢,cpu最高超過90%,內(nèi)存占用到達200MB,耗電?。?/p>
通過以上對比得出的結(jié)論是:未開啟“異步繪制和異步下載渲染”,雖然cpu、內(nèi)存未見異常,但列表滑動卡頓,非常耗電;開啟后,雖然內(nèi)存占用翻倍、cpu也達到過90%,但相對于4G內(nèi)存和6核CPU的iPhone11來說影響不大,流暢性和耗電得到保障。由此得出結(jié)論,UITableView性能優(yōu)化的關(guān)鍵在于“系統(tǒng)資源充分滿足調(diào)配的前提下,能異步的盡量異步”,否則主線程壓力大引起卡頓,丟幀和耗電在所難免。
補充說明:當打開kOnlyShowText開關(guān),僅顯示文本內(nèi)容進行測試時,在未打開kAsyncDraw開關(guān)前快速滑動列表,幀率出現(xiàn)40~50fps,可感知快速滑動下并不流暢。雖然UITableView性能優(yōu)化主要體現(xiàn)在大圖異步下載渲染的優(yōu)化,文本高度的緩存對于多核CPU設(shè)備性能提升效果確實不明顯,但文本異步繪制則讓性能更上一層。
六、核心代碼范圍
DEMO地址:https://github.com/stkusegithub/AsyncDraw
代碼位于目錄 AsyncDrawDemo/AsyncDrawDemo/Core/下
\---AsyncDraw
+---ADManager.h
+---ADManager.m
+---ADLayer.h
+---ADLayer.m
+---ADTask.h
+---ADTask.m
+---ADQueue.h
+---ADQueue.m
+---ADOperation.h
+---ADOperation.m
\---AsyncUI
+---ADLabel.h
+---ADLabel.m
+---ADImageView.h
+---ADImageView.m