一扭屁、需求背景
1、現(xiàn)狀
iOS所提供的UIKit框架涩禀,其工作基本是在主線程上進(jìn)行料滥,界面繪制、用戶輸入響應(yīng)交互等等艾船。當(dāng)大量且頻繁的繪制任務(wù)葵腹,以及各種業(yè)務(wù)邏輯同時(shí)放在主線程上完成時(shí),便有可能造成界面卡頓屿岂,丟幀現(xiàn)象践宴,即在16.7ms內(nèi)未能完成1幀的繪制,幀率低于60fps黃金標(biāo)準(zhǔn)爷怀。目前常用的UITableView或UICollectionView阻肩,在大量復(fù)雜文本及圖片內(nèi)容填充后,如果沒(méi)有優(yōu)化處理运授,快速滑動(dòng)的情況下易出現(xiàn)卡頓烤惊,流暢性差問(wèn)題。
2徒坡、需求
不依賴任何第三方pod框架撕氧,主要從異步線程繪制瘤缩、圖片異步下載渲染等方面喇完,盡可能優(yōu)化UITableView的使用,提高滑動(dòng)流暢性,讓幀率穩(wěn)定在60fps锦溪。
(網(wǎng)上有很多優(yōu)秀的性能優(yōu)化博客和開(kāi)源代碼不脯,本方案也是基于前人的經(jīng)驗(yàn),結(jié)合自身的理解和梳理寫成demo刻诊,關(guān)鍵代碼有做注釋防楷,很多細(xì)節(jié)值得推敲和持續(xù)優(yōu)化,不足之處望指正则涯。)
二复局、解決方案及亮點(diǎn)
1、方案概述
? 異步繪制任務(wù)收集與去重;
? 通過(guò)單例監(jiān)聽(tīng)main runloop回調(diào)粟判,執(zhí)行異步繪制任務(wù)亿昏;
? 支持異步繪制動(dòng)態(tài)文本內(nèi)容,減輕主線程壓力档礁,并緩存高度減少CPU計(jì)算角钩;
? 支持異步下載和渲染圖片并緩存,僅在可視區(qū)域渲染呻澜;
? 異步隊(duì)列并發(fā)管理递礼,擇優(yōu)選取執(zhí)行任務(wù);
? 發(fā)現(xiàn)UITableView首次reload會(huì)觸發(fā)3次的系統(tǒng)問(wèn)題羹幸,初始開(kāi)銷增大脊髓,待優(yōu)化;
2栅受、問(wèn)題點(diǎn)
? 異步繪制時(shí)機(jī)及減少重復(fù)繪制供炼;
? 隊(duì)列的并發(fā)和擇優(yōu);
3窘疮、分析過(guò)程
1)異步繪制時(shí)機(jī)及減少重復(fù)繪制
這里簡(jiǎn)單描述下繪制原理:當(dāng)UI被添加到界面后袋哼,我們改變Frame,或更新 UIView/CALayer層次闸衫,或調(diào)用setNeedsLayout/setNeedsDisplay方法涛贯,均會(huì)添加重新繪制任務(wù)。這個(gè)時(shí)候系統(tǒng)會(huì)注冊(cè)一個(gè)Observer監(jiān)聽(tīng)BeforeWaiting(即將進(jìn)入休眠)和Exit(即將退出Loop)事件蔚出,并回調(diào)執(zhí)行當(dāng)前繪制任務(wù)(setNeedsDisplay->display->displayLayer)弟翘,最終更新界面。
由上可知骄酗,我們可以模擬系統(tǒng)繪制任務(wù)的收集稀余,在runloop回調(diào)中去執(zhí)行,并重寫layer的dispaly方法趋翻,開(kāi)辟子線程進(jìn)行異步繪制睛琳,再返回主線程刷新。
當(dāng)同個(gè)UI多次觸發(fā)繪制請(qǐng)求時(shí),怎樣減少重復(fù)繪制师骗,以便減輕并發(fā)壓力比較重要历等。本案通過(guò)維護(hù)一個(gè)全局線程安全的原子性狀態(tài),在繪制過(guò)程中的關(guān)鍵步驟處理前均校驗(yàn)是否要放棄當(dāng)前多余的繪制任務(wù)辟癌。
2)隊(duì)列的并發(fā)和擇優(yōu)
一次runloop回調(diào)寒屯,經(jīng)常會(huì)執(zhí)行多個(gè)繪制任務(wù),這里考慮開(kāi)辟多個(gè)線程去異步執(zhí)行黍少。首選并行隊(duì)列可以滿足寡夹,但為了滿足性能效率的同時(shí)確保不過(guò)多的占用資源和避免線程間競(jìng)爭(zhēng)等待,更好的方案應(yīng)該是開(kāi)辟多個(gè)串行隊(duì)列單線程處理并發(fā)任務(wù)厂置。
接下來(lái)的問(wèn)題是要出,異步繪制創(chuàng)建幾個(gè)串行隊(duì)列合適?
我們知道一個(gè)n核設(shè)備农渊,并發(fā)執(zhí)行n個(gè)任務(wù)患蹂,最多創(chuàng)建n個(gè)線程時(shí),線程之間將不會(huì)互相競(jìng)爭(zhēng)資源砸紊。因此传于,不建議數(shù)量設(shè)置超過(guò)當(dāng)前激活的處理器數(shù),并可根據(jù)項(xiàng)目界面復(fù)雜度以及設(shè)備性能適配醉顽,適當(dāng)限制并發(fā)開(kāi)銷沼溜,文本異步繪制最大隊(duì)列數(shù)設(shè)置如下:
#definekMAX_QUEUE_COUNT6
- (NSUInteger)limitQueueCount {
if(_limitQueueCount ==0) {
// 獲取當(dāng)前系統(tǒng)處于激活狀態(tài)的處理器數(shù)量
NSUInteger processorCount = [NSProcessInfo processInfo].activeProcessorCount;
// 根據(jù)處理器的數(shù)量和設(shè)置的最大隊(duì)列數(shù)來(lái)設(shè)定當(dāng)前隊(duì)列數(shù)組的大小
_limitQueueCount = processorCount >0? (processorCount > kMAX_QUEUE_COUNT ? kMAX_QUEUE_COUNT : processorCount) :1;
}
return_limitQueueCount;
}
文本的異步繪制串行隊(duì)列用GCD實(shí)現(xiàn),圖片異步下載通過(guò)NSOperationQueue實(shí)現(xiàn)游添,兩者最大并發(fā)數(shù)參考SDWebImage圖片下載并發(fā)數(shù)的限制數(shù):6系草。
如何擇優(yōu)選取執(zhí)行任務(wù)?文本異步隊(duì)列的選取唆涝,可以自定義隊(duì)列的任務(wù)數(shù)標(biāo)記找都,在隊(duì)列執(zhí)行任務(wù)前計(jì)算+1,當(dāng)任務(wù)執(zhí)行結(jié)束計(jì)算-1廊酣。這里忽略每次繪制難易度的略微差異能耻,我們便可以判定任務(wù)數(shù)最少接近于最優(yōu)隊(duì)列。圖片異步下載任務(wù)亡驰,交由NSOperationQueue處理并發(fā)晓猛,我們要處理的是,讓同個(gè)圖片在多次并發(fā)下載請(qǐng)求下凡辱,僅生成1個(gè)NSOperation添加到queue戒职,即去重只下載一次并緩存,且在下載完成后返回主線程同步渲染多個(gè)觸發(fā)該下載請(qǐng)求的控件(本案demo僅用一張圖片透乾,所以這種情況必須考慮到)洪燥。
三磕秤、詳細(xì)設(shè)計(jì)
1、設(shè)計(jì)圖
2蚓曼、代碼原理剖析(寫在注釋)
1)設(shè)置runloop監(jiān)聽(tīng)及回調(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)聽(tīng)即將休眠和退出
CFRunLoopObserverRefobserver =CFRunLoopObserverCreate(CFAllocatorGetDefault(),
kCFRunLoopBeforeWaiting| kCFRunLoopExit,
true,// 重復(fù)
0xFFFFFF,// 設(shè)置優(yōu)先級(jí)低于CATransaction(2000000)
ADRunLoopCallBack,NULL);
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
CFRelease(observer);
2)創(chuàng)建纫版、獲取文本異步繪制隊(duì)列,并擇優(yōu)選取
- (ADQueue *)ad_getExecuteTaskQueue {
// 1客情、創(chuàng)建對(duì)應(yīng)數(shù)量串行隊(duì)列處理并發(fā)任務(wù)其弊,并行隊(duì)列線程數(shù)無(wú)法控制
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、當(dāng)隊(duì)列數(shù)已達(dá)上限膀斋,擇優(yōu)獲取異步任務(wù)數(shù)最少的隊(duì)列
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)異步繪制
/**
維護(hù)線程安全的繪制狀態(tài)
*/
@property(atomic,assign) ADLayerStatus status;
- (void)setNeedsDisplay {
// 收到新的繪制請(qǐng)求時(shí)梭伐,同步正在繪制的線程本次取消
self.status= ADLayerStatusCancel;
[supersetNeedsDisplay];
}
- (void)display {
// 標(biāo)記正在繪制
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、添加圖片渲染對(duì)象
[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];
// 遍歷渲染同個(gè)圖片地址的所有控件
[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];
}];
// 加入隊(duì)列
[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;
}
}];
}
五、成效舉證
針對(duì)本案制作了AsyncDrawDemo贮尉,是一個(gè)圖文排列布局的UITableView列表拌滋,類似新聞列表,TestTableViewCell.m中有異步繪制和圖片異步下載渲染開(kāi)關(guān)
#define kAsyncDraw true// 異步開(kāi)關(guān)
//#define kOnlyShowText true // 僅顯示文本進(jìn)行測(cè)試
kAsyncDraw開(kāi)啟前后測(cè)試對(duì)比清單:
? 同樣加載1000條數(shù)據(jù)的列表
? 動(dòng)態(tài)文本緩存高度
? 同一設(shè)備:真機(jī)iPhone11 iOS13.5.1
? 操作:列表首次加載完成猜谚,幀率顯示60fps后败砂,快速向上滑動(dòng)至底部
本案通過(guò)YYFPSLabel觀察幀率大致均值變化,以及內(nèi)存/CPU變化截圖如下:
1)未開(kāi)啟異步前:
穩(wěn)定60fps后開(kāi)始快速滑動(dòng)至列表底部的前后對(duì)比(幀率最低到1fps魏铅,滑動(dòng)過(guò)程異巢蹋卡頓,cpu未超過(guò)40%览芳,內(nèi)存占用也不多祭隔,但非常耗電):
2)開(kāi)啟異步后:
穩(wěn)定60fps后開(kāi)始快速滑動(dòng)至列表底部的前后對(duì)比(幀率穩(wěn)定在60fps,滑動(dòng)過(guò)程非常流暢路操,cpu最高超過(guò)90%疾渴,內(nèi)存占用到達(dá)200MB,耗電型驼獭)
通過(guò)以上對(duì)比得出的結(jié)論是:未開(kāi)啟“異步繪制和異步下載渲染”搞坝,雖然cpu、內(nèi)存未見(jiàn)異常魁袜,但列表滑動(dòng)卡頓桩撮,非常耗電敦第;開(kāi)啟后,雖然內(nèi)存占用翻倍店量、cpu也達(dá)到過(guò)90%芜果,但相對(duì)于4G內(nèi)存和6核CPU的iPhone11來(lái)說(shuō)影響不大,流暢性和耗電得到保障融师。由此得出結(jié)論右钾,UITableView性能優(yōu)化的關(guān)鍵在于“系統(tǒng)資源充分滿足調(diào)配的前提下,能異步的盡量異步”旱爆,否則主線程壓力大引起卡頓舀射,丟幀和耗電在所難免。
補(bǔ)充說(shuō)明:當(dāng)打開(kāi)kOnlyShowText開(kāi)關(guān)怀伦,僅顯示文本內(nèi)容進(jìn)行測(cè)試時(shí)脆烟,在未打開(kāi)kAsyncDraw開(kāi)關(guān)前快速滑動(dòng)列表,幀率出現(xiàn)40~50fps房待,可感知快速滑動(dòng)下并不流暢邢羔。雖然UITableView性能優(yōu)化主要體現(xiàn)在大圖異步下載渲染的優(yōu)化,文本高度的緩存對(duì)于多核CPU設(shè)備性能提升效果確實(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