iOS性能優化(中級+): 異步繪制

山雨欲來

“砰砰砰、砰砰砰、砰砰砰”

“大師,大師,江湖救急啊”

“不知少俠,著急讓老夫出關所為何事?”

“大師之前授與我的iOS性能優化(初級)iOS性能優化(中級),我已熟悉研讀多日,且勤學苦練,至今已能解決大部分滑動卡頓問題。”

“少俠,果然聰慧過人”

“但是,最近依然遇到了問題,小師妹想做一個類似于微博主頁的頁面,有很多feed,每個feed里面,有話題,鏈接、圖片、表情、圓角頭像等,這么多元素雜在一起,縱然我使出畢生所學,卻依然會有卡頓,達不到小師妹對流暢性的要求,所以很是苦惱,懇求大師指點。”

“原來是這樣,老夫這就來助你突破瓶頸,更上一層樓。”

異步繪制

iOS性能優化(初級)iOS性能優化(中級)中,為了屏幕流暢我們做了很多,也取得了不錯的成果。但無論怎么做,最后的繪制是提交給系統的,系統默認是在主線程做這一切,當需要繪制的元素過多,過于頻繁,那么依然會造成卡頓。

那么我們可不可以像處理復雜數據一樣,把繪制過程放在后臺線程執行呢?

很高興,答案是可以的。

iOS里面的視圖UIView中有一個CALayer *layer的屬性,UIView的內容,其實是layer顯示的,layer中有一個屬性id contentscontents的內容就是要顯示的具體內容,大多數情況下,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];
繪制結果

顯示效果達到。

“多謝大師指點,大師一番操作,讓我茅塞頓開。”

耳目一新

上面的操作是非常常規的操作,在實際使用中還有幾個問題需要解決:

  1. 當AsyncLabel使用在cell中,數量較多,不斷重繪時,要處理好子線程問題,不能放在全局隊列(因為全局隊列中可能有系統提交的任務)。
  2. 對不同類型如文字、圖片的封裝性問題。

下面老夫來給少俠介紹一種,全新的解決方式,刷新常規想法,且封裝優秀。

YYAsyncLayer

它的主要處理流程如下:

  1. 在主線程的runLoop中注冊一個observer,它的優先級要比系統的CATransaction要低,保證系統先做完必須的工作。
  2. 把需要異步繪制的操作集中起來。比如設置字體、顏色、背景這些,不是設置一個就繪制一個,把他們都收集起來,runloop會在observer需要的時機通知統一處理。
  3. 處理時機到時,執行異步繪制,并在主線程中把繪制結果傳遞給layer.contents
YYAsyncLayer主要流程

大概了解了原理,我們來使用一下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];
}

這一些代碼,執行了處理流程中的12,注冊了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,異步繪制,并提供給使用者willDisplaydisplaydidDisplay幾個block。

有一點需要注意,必須重寫+ (Class)layerClass,才會進入自定義的subLayer執行方法。相當于打UIView的layer,從默認layer指到subLayer。

繪制結果
駕輕就熟

上述招式,老夫只是簡單演示,但少俠遇到的事要比老夫復雜的多。少俠天資聰慧,切不可傲嬌,還需好生練習并配合runloopCoreText使用,方能駕輕就熟。快去答復小師妹去罷。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。