山雨欲來
“砰砰砰、砰砰砰、砰砰砰”
“大師,大師,江湖救急啊”
“不知少俠,著急讓老夫出關所為何事?”
“大師之前授與我的iOS性能優化(初級)和iOS性能優化(中級),我已熟悉研讀多日,且勤學苦練,至今已能解決大部分滑動卡頓問題。”
“少俠,果然聰慧過人”
“但是,最近依然遇到了問題,小師妹想做一個類似于微博主頁的頁面,有很多feed,每個feed里面,有話題,鏈接、圖片、表情、圓角頭像等,這么多元素雜在一起,縱然我使出畢生所學,卻依然會有卡頓,達不到小師妹對流暢性的要求,所以很是苦惱,懇求大師指點。”
“原來是這樣,老夫這就來助你突破瓶頸,更上一層樓。”
異步繪制
在iOS性能優化(初級)和iOS性能優化(中級)中,為了屏幕流暢我們做了很多,也取得了不錯的成果。但無論怎么做,最后的繪制是提交給系統的,系統默認是在主線程做這一切,當需要繪制的元素過多,過于頻繁,那么依然會造成卡頓。
那么我們可不可以像處理復雜數據一樣,把繪制過程放在后臺線程執行呢?
很高興,答案是可以的。
iOS里面的視圖UIView
中有一個CALayer *layer
的屬性,UIView
的內容,其實是layer
顯示的,layer
中有一個屬性id contents
,contents
的內容就是要顯示的具體內容,大多數情況下,contents
的值是一張圖片。我們常用的無論是 UILabel
還是 UIImageView
里面顯示的內容,其實都是繪制在一張畫布上,繪制完成從畫布中導出圖片,再把圖片賦值給layer.contents
就完成了顯示。
異步繪制,就是異步在畫布上繪制內容。
小試鋒芒
Talk is cheap. Show me the code
首先來新建一個AsyncLabel類,然后重寫- (void)displayLayer:(CALayer *)layer
方法,在其中進行異步繪制。
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface AsyncLabel : UIView
//設置文字內容
@property(nonatomic, copy) NSString *text;
//設置字體
@property(nonatomic, strong) UIFont *font;
@end
NS_ASSUME_NONNULL_END
#import "AsyncLabel.h"
#import <CoreText/CoreText.h>
@implementation AsyncLabel
- (void)displayLayer:(CALayer *)layer
{
NSLog(@"是不是主線程 %d", [[NSThread currentThread] isMainThread]);
//輸出 1 代表是主線程
//異步繪制,所以我們在使用了全局子隊列,實際使用中,最好自創隊列
dispatch_async(dispatch_get_global_queue(0, 0), ^{
__block CGSize size = CGSizeZero;
__block CGFloat scale = 1.0;
dispatch_sync(dispatch_get_main_queue(), ^{
size = self.bounds.size;
scale = [UIScreen mainScreen].scale;
});
UIGraphicsBeginImageContextWithOptions(size, NO, scale);
CGContextRef context = UIGraphicsGetCurrentContext();
[self draw:context size:size];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
self.layer.contents = (__bridge id)(image.CGImage);
});
});
}
@end
- (void)draw:(CGContextRef)context size:(CGSize)size
{
//將坐標系上下翻轉。因為底層坐標系和UIKit的坐標系原點位置不同。
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, size.height);
CGContextScaleCTM(context, 1.0,-1.0);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, size.width, size.height));
//設置內容
NSMutableAttributedString * attString = [[NSMutableAttributedString alloc] initWithString:self.text];
//設置字體
[attString addAttribute:NSFontAttributeName value:self.font range:NSMakeRange(0, self.text.length)];
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attString.length), path, NULL);
//把frame繪制到context里
CTFrameDraw(frame, context);
}
這樣就完成了一個簡單的繪制。在- (void)displayLayer:(CALayer *)layer
方法中,在異步線程里,創建一個畫布并把繪制的結果在主線程中傳給layer.contents
。
繪制過程使用了CoreText
,這里只簡單的把文字繪制上去,實際使用過程中,根據需要可能會有很多的地方需要設置,還請少俠自行學習CoreText
。
調用一下看一下結果:
AsyncLabel *label = [[AsyncLabel alloc] initWithFrame:CGRectMake(50, 200, [UIScreen mainScreen].bounds.size.width - 2 * 50, 100)];
label.backgroundColor = [UIColor lightGrayColor];
label.text = @"今天是個好日子啊,心想的事兒都能成,今天是個好日子啊,啊,安心,太平";
label.font = [UIFont systemFontOfSize:20];
[self.view addSubview:label];
[label.layer setNeedsDisplay];
顯示效果達到。
“多謝大師指點,大師一番操作,讓我茅塞頓開。”
耳目一新
上面的操作是非常常規的操作,在實際使用中還有幾個問題需要解決:
- 當AsyncLabel使用在cell中,數量較多,不斷重繪時,要處理好子線程問題,不能放在全局隊列(因為全局隊列中可能有系統提交的任務)。
- 對不同類型如文字、圖片的封裝性問題。
下面老夫來給少俠介紹一種,全新的解決方式,刷新常規想法,且封裝優秀。
它的主要處理流程如下:
- 在主線程的runLoop中注冊一個
observer
,它的優先級要比系統的CATransaction
要低,保證系統先做完必須的工作。 - 把需要異步繪制的操作集中起來。比如設置字體、顏色、背景這些,不是設置一個就繪制一個,把他們都收集起來,
runloop
會在observer
需要的時機通知統一處理。 - 處理時機到時,執行異步繪制,并在主線程中把繪制結果傳遞給
layer.contents
。
大概了解了原理,我們來使用一下YYAsyncLayer
刪除之前在AsyncLabel.m
中使用原始方式異步繪制的代碼加入下列代碼
- (void)setText:(NSString *)text {
_text = text.copy;
[[YYTransaction transactionWithTarget:self selector:@selector(contentsNeedUpdated)] commit];
}
- (void)setFont:(UIFont *)font {
_font = font;
[[YYTransaction transactionWithTarget:self selector:@selector(contentsNeedUpdated)] commit];
}
- (void)layoutSubviews {
[super layoutSubviews];
[[YYTransaction transactionWithTarget:self selector:@selector(contentsNeedUpdated)] commit];
}
- (void)contentsNeedUpdated {
// do update
[self.layer setNeedsDisplay];
}
這一些代碼,執行了處理流程中的1、2,注冊了observer,并收集了要統一處理的操作。
+ (Class)layerClass
{
return [YYAsyncLayer class];
}
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask {
YYAsyncLayerDisplayTask *task = [YYAsyncLayerDisplayTask new];
task.willDisplay = ^(CALayer *layer) {
//...
};
task.display = ^(CGContextRef context, CGSize size, BOOL(^isCancelled)(void)) {
if (isCancelled()) {
return;
}
if (!self.text.length) {
return;
}
[self draw:context size:size];
};
task.didDisplay = ^(CALayer *layer, BOOL finished) {
if (finished) {
// finished
} else {
// cancelled
}
};
return task;
}
這些代碼實現了流程中的3,異步繪制,并提供給使用者willDisplay
、display
、didDisplay
幾個block。
有一點需要注意,必須重寫+ (Class)layerClass
,才會進入自定義的subLayer執行方法。相當于打UIView的layer,從默認layer指到subLayer。
駕輕就熟
上述招式,老夫只是簡單演示,但少俠遇到的事要比老夫復雜的多。少俠天資聰慧,切不可傲嬌,還需好生練習并配合runloop
、CoreText
使用,方能駕輕就熟。快去答復小師妹去罷。