iOS开发-如何实现异步绘制

个人博客地址:hxhxt.cn

异步绘制是提升页面流畅度的一个非常重要的点,本文将研究如何实现异步绘制。

方案探索

普通绘制可以通过重写View的DrawRact方法来实现,在该方法中使用UIBezierPath或者CoreGraphics来进行绘图。但是异步绘制我们则需要对CALayer来进行操作,通过参考YYAsyncLayer的实现方式,我们先来实现一个简单的异步绘制。

先创建一个CALayer子类,重写display方法,添加displayAsync方法。这样只要外部创建添加了该layer后调用setNeedsDisplay方法,就会运行display方法。

在displayAsync方法中,我们创建了一个串行队列,添加了一个异步任务,通过开启画布->CTM变换->创建路径和CTFrameRef->绘图并取得图片这些步骤完成绘制,再回到主线程将图片赋值给layer.contents这样就完成了一个简单的异步绘制

- (void)display {
    super.contents = super.contents;
    [self displayAsync];
}

- (void)displayAsync {
    CGFloat height = self.bounds.size.height;
    CGFloat width = self.bounds.size.width;
    
    dispatch_queue_t q = dispatch_queue_create("testQueue", DISPATCH_QUEUE_SERIAL);
    dispatch_async(q, ^{
        // 开启一个image画布
        UIGraphicsBeginImageContext(CGSizeMake(width, height));
        // 获取画布
        CGContextRef context = UIGraphicsGetCurrentContext();
        // CTM变换
        CGContextSetTextMatrix(context, CGAffineTransformIdentity);
        CGContextTranslateCTM(context, 0, height);
        CGContextScaleCTM(context, 1.0, -1.0);
        // 路径,决定了画布上的位置
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathAddRect(path, NULL, CGRectMake(10, -10, height, width));
        // NSAttributedString
        NSAttributedString *attString = [[NSAttributedString alloc] initWithString:@"Hello World!"];
        // 创建CTFrameRef
        CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString);
        CTFrameRef frame = CTFramesetterCreateFrame(framesetter,
                                                    CFRangeMake(0, [attString length]), path, NULL);
        // 绘制
        CTFrameDraw(frame, context);
        // 获取图像
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        
        // 设置图像
        dispatch_async(dispatch_get_main_queue(), ^{
            self.contents = (__bridge id)(image.CGImage);
        });
        
        // 结束
        UIGraphicsEndImageContext();
        CFRelease(frame);
        CFRelease(path);
        CFRelease(framesetter);
    });
}

在viewController的viewDidLoad方法中创建这个layer

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.view.backgroundColor = [UIColor colorWithRed:220/255.0 green:220/255.0  blue:220/255.0  alpha:1];
    
    DrawLayer *layer = [[DrawLayer alloc] init];
    layer.frame = CGRectMake(50, 400, 300, 300);
    [self.view.layer addSublayer:layer];
    layer.backgroundColor = [UIColor whiteColor].CGColor;
    [layer setNeedsDisplay];
}

YYAsyncLayer实现流程

YYKit作者原文地址:iOS 保持界面流畅的技巧

通过阅读源码可以了解YYKit中实现异步绘制的功能主要为以下4个类

YYLabel // 负责操作YYAsyncLayer,提供回调
YYTextLayout // 负责计算样式,创建绘图对象,绘图
YYAsyncLayer // 负责创建画布,获取回调设置给layer
YYDispatchQueuePool:// 负责管理线程

主要流程和几个关键方法个人总结如下:

// 1.获取到数据后开始利用多线程创建YYTextLayout对象。在下面方法中创建了CTRunDelegate来实现图文混排。
- (NSAttributedString *)_attachmentWithFontSize:(CGFloat)fontSize image:(UIImage *)image shrink:(BOOL)shrink // WBStatusLayout

// 2.YYTextLayout调用layoutWithContainer方法,计算布局,获取CTFrameRef->CTLineRef->CTRunRef
+ (YYTextLayout *)layoutWithContainer:(YYTextContainer *)container
                                 text:(NSAttributedString *)text
                                range:(NSRange)range // YYTextLayout

// 3.Cell创建YYLabel对象,YYLabel需要绘制时,调用YYAsyncLayer的setNeedsDisplay方法。然后调用_displayAsync方法。
- (void)_displayAsync:(BOOL)async; // YYAsyncLayer

// 4._displayAsync方法中先调用自己代理也就是YYLabel的newAsyncDisplayTask方法,在其中设置display回调。
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask; // YYLabel

// 5._displayAsync方法中通过YYDispatchQueuePool获取线程,创建异步任务,获取CGContextRef上下文对象,通过display回调传给YYLabel。
task.display = ^(CGContextRef context, CGSize size, BOOL (^isCancelled)(void)) {...} // YYLabel

// 6.YYLabel回调中,调用YYTextLayout对象的drawInContext方法。
- (void)drawInContext:(CGContextRef)context
                 size:(CGSize)size
                point:(CGPoint)point
                 view:(UIView *)view
                layer:(CALayer *)layer
                debug:(YYTextDebugOption *)debug
                cancel:(BOOL (^)(void))cancel; // YYTextLayout

// 7.在drawInContext方法中,先调用YYTextDrawText方法,在其中再使用for循环调用YYTextDrawRun方法,使用CTRunDraw完成绘制。
static void YYTextDrawRun(YYTextLine *line,
                          CTRunRef run,
                          CGContextRef context,
                          CGSize size,
                          BOOL isVertical,
                          NSArray *runRanges,
                          CGFloat verticalOffset) // YYTextLayout
CTRunDraw(run, context, CFRangeMake(0, 0)); // YYTextLayout

// 8.绘制完毕后,继续回到YYAsyncLayer的_displayAsync方法中,获取绘图片,回到主线程设置给self.contents,完成流程。
self.contents = (__bridge id)(image.CGImage); // YYAsyncLayer

整个流程比较清晰,各个类按照单一职责原则分工明确,比较复杂的地方集中在YYTextLayout,这个类承担了大量的布局计算工作以及最后的绘制任务。


图文混排

我们来实现简单的图文混排。首先需要理解CTFrameRef,CTLineRef,CTRunRef之间的关系。CTFrameRef可以理解为一整块画布,一个CTFrameRef可以拆分成多行,每一行是一个CTLineRef, 而一个CTLineRef又可以根据显示内容的不同拆分成多个CTRunRef。

我们可以把最开始的的例子中的绘制代码:

// 绘制
CTFrameDraw(frame, context);

替换成以下内容:

// 绘制
//CTFrameDraw(frame, context);
for (NSUInteger i = 0; i < lineCount; i++) {
    CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
    CGPoint ctLineOrigin = lineOrigins[i];
    CGContextSetTextPosition(context, ctLineOrigin.x, ctLineOrigin.y);
//   CTLineDraw(ctLine, context);
    CFArrayRef ctRuns = CTLineGetGlyphRuns(ctLine);
    NSUInteger runCount = CFArrayGetCount(ctRuns);
    for (NSUInteger j = 0; j < runCount; j++) {
        CTRunRef run = CFArrayGetValueAtIndex(ctRuns, j);
        CTRunDraw(run, context, CFRangeMake(0, 0));
    }
}

以上代码把CTFrameDraw方法替换成了CTRunDraw方法(或CTLineDraw方法),将整个画布拆分成一个个的run来绘制。

实现图文混排,主要原理是:添加一个空白的占位run,给这个run设置CTRunDelegateRef来控制大小,然后在绘制run的时候如果遇到有设置代理的run,就计算出图片的大小位置并使用CGContextDrawImage方法来绘制图片。

先添加以下方法:

// 这三个回调方法控制了占位符大小,暂时写死
static CGFloat ascentCallback(void *ref){
    return 20;
}

static CGFloat descentCallback(void *ref){
    return 0;
}

static CGFloat widthCallback(void* ref){
    return 30;
}

- (void)addRunDelegate:(NSMutableAttributedString *)string {
    CTRunDelegateCallbacks callbacks;
    memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks));
    callbacks.version = kCTRunDelegateVersion1;
    callbacks.getAscent = ascentCallback;
    callbacks.getDescent = descentCallback;
    callbacks.getWidth = widthCallback;
    CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, nil);
    
    // 使用0xFFFC作为空白的占位符
    //创建空白字符
    unichar placeHolder = 0xFFFC;
    NSString *placeHolderString = [NSString stringWithCharacters:&placeHolder length:1];
    NSMutableAttributedString *placeHolderAttributedString = [[NSMutableAttributedString alloc]initWithString:placeHolderString];
    
    NSDictionary *attributedDic = [NSDictionary dictionaryWithObjectsAndKeys:(__bridge id)delegate, kCTRunDelegateAttributeName,nil];
    [placeHolderAttributedString setAttributes:attributedDic range:NSMakeRange(0, 1)];
    CFRelease(delegate);
    
    [string appendAttributedString:placeHolderAttributedString];
}

在这个addRunDelegate方法中我们实现提供一个空白的字符作为占位符,并设置好了rundelegate,在三个回调方法里面暂时写死了大小。

将创建NSMutableAttributedString的内容修改为以下内容,调用addRunDelegate方法来添加占位符。

// NSAttributedString
NSMutableAttributedString *attString = [[NSMutableAttributedString alloc] initWithString:@"Hello World!Hello World!Hello World!Hello World!Hello World!Hello World!Hello World!Hello World!Hello World!"];
[attString addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 10)];
[attString addAttribute:NSFontAttributeName value:[UIFont systemFontOfSize:20] range:NSMakeRange(10, 15)];

[self addRunDelegate:attString];
[attString appendAttributedString:[[NSMutableAttributedString alloc]initWithString:@"over"]];

最后将绘制部分改成以下内容。

CFArrayRef ctLines = CTFrameGetLines(frame);
NSUInteger lineCount = CFArrayGetCount(ctLines);

CGPoint *lineOrigins = malloc(lineCount * sizeof(CGPoint));
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);

// 绘制
for (NSUInteger i = 0; i < lineCount; i++) {
    CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
    CGPoint ctLineOrigin = lineOrigins[i];
    CGContextSetTextPosition(context, ctLineOrigin.x, ctLineOrigin.y);
    CFArrayRef ctRuns = CTLineGetGlyphRuns(ctLine);
    NSUInteger runCount = CFArrayGetCount(ctRuns);
    for (NSUInteger j = 0; j < runCount; j++) {
        CTRunRef run = CFArrayGetValueAtIndex(ctRuns, j);
        CTRunDraw(run, context, CFRangeMake(0, 0));
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        [self saveImage:image directory:@"/test2"];
        
        // 画图
        NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);
        CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];
        if (delegate) {
            CGRect runBounds;
            CGFloat ascent;
            CGFloat descent;
            runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
            runBounds.size.height = ascent + descent;
            
            // 对于X轴的偏移
            CGFloat xOffset = CTLineGetOffsetForStringIndex(ctLine, CTRunGetStringRange(run).location, NULL);
            runBounds.origin.x = lineOrigins[i].x + xOffset;
            runBounds.origin.y = lineOrigins[i].y;
            runBounds.origin.y -= descent;
            CGPathRef pathRef = CTFrameGetPath(frame);
            CGRect colRect = CGPathGetBoundingBox(pathRef);
            CGRect delegateBcounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y);
            //绘图
            UIImage *image1 = [UIImage imageNamed:@"image1"];
            CGRect imageRect = delegateBcounds;
            CGContextDrawImage(context, imageRect, image1.CGImage);
        }
    }
}

以上代码在循环处理run的时候,如果存在CTRunDelegateRef,则会计算大小和偏移量,来确定图片的位置,最后使用CGContextDrawImage方法绘制图片。

以上我们就实现了一个简单的图文混排,整个过程都是在子线程中完成,主要目的是了解实现的流程,后续就可以将其拆分封装成更加完整的功能。