iOS的异步绘制--YYAsyncLayer源码分析

iOS的异步渲染

最近看了YYAsyncLayer在这里总结一下。YYAsyncLayer是整个YYKit异步渲染的基础。整个项目的Github地址在这里。你可以先下载了一睹为快,也可以跟着我一步一步的了解它是怎么实现异步绘制的。

如何实现异步

两种方式可以实现异步。一种是使用另外的一个线程,一种是使用RunLoop。另外开一个线程的方法有很多,但是现在最方便的就是GCD了。

GCD

这里介绍一些GCD里常用的方法,为了后面阅读的需要。还有YYAsyncLayer中用到的更加高级的用法会在下文中深入介绍。

创建一个queue

dispatch_queue_t queue;
if ([UIDevice currentDevice].systemVersion.floatValue >= 8.0) {
  dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0);
  queue = dispatch_queue_create("com.ibireme.yykit.render", attr);
} else {
  queue = dispatch_queue_create("com.ibireme.yykit.render", DISPATCH_QUEUE_SERIAL);
  dispatch_set_target_queue(queue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
}

如果iOS 8和以上版本的话,创建queue的方法和之前的版本的不太太一样。在iOS 8和以上的版本中创建queue需要先创建一个dispatch_queue_attr_t类型的实例。并作为参数传入到queue的生成方法里。

DISPATCH_QUEUE_SERIAL说明在这个queue内部的task是串行执行的。

dispatch_set_target_queue

dispatch_set_target_queue 有两个作用:

  1. 设定创建的queue的优先级。
  2. 让多个serial的queue的任务由并行的变为在target queue内是串行的。

这里主要的作用是第一个。也就是把dispatch_queue_create创建的queue的优先级设置为和dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)为同一优先级。

苹果的文档在这里

dispatch_once

使用dispatch_oncedispatch_once_t的组合可以实现其中的task只被执行一次。但是有一个前提条件,看代码:

static dispatch_once_t onceToken; // 1

// 2
dispatch_once(&onceToken, ^{
  // 这里的task只被执行一次
});
  1. 这里的dispatch_once_t必须是静态的。也就是要有APP一样长的生存期来保证这段时间内task只被执行一次。如果不是static的,那么只被执行一次是保证不了的。
  2. dispatch_once方法在这里执行,onceToken在这里有一个取地址的操作。也就是onceToken把地址传入方法内部被初始化和赋值。

RunLoop

CFRunLoopRef runloop = CFRunLoopGetMain();  // 1
CFRunLoopObserverRef observer;
// 2
observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                    kCFRunLoopBeforeWaiting | kCFRunLoopExit,
                                    true,      // repeat
                                    0xFFFFFF,  // after CATransaction(2000000)
                                    YYRunLoopObserverCallBack, NULL);
// 3
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes); 
CFRelease(observer);

我们来分析一下这段代码

  1. CFRunLoopGetMain方法返回主线程的RunLoop引用。后面用这个引用来添加回调。
  2. 使用系统内置的c方法创建一个RunLoop的观察者,在创建这个观察者的时候回同时指定回调方法。
  3. RunLoop实例添加观察者,之后减少一个观察者的引用。

在第二步创建观察者的时候,还指定了观察者观察的事件:kCFRunLoopBeforeWaiting | kCFRunLoopExit,在
RunLoop进入等待或者即将要退出的时候开始执行观察者。指定了观察者是否重复(true)。指定了观察者的优先级:0xFFFFFF,这个优先级比CATransaction优先级为2000000的优先级更低。这是为了确保系统的动画优先执行,之后再执行异步渲染。

YYRunLoopObserverCallBack就是观察者收到通知的时候要执行的回调方法。这个方法的声明是这样的:

static void YYRunLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info);

渲染是怎么回事

渲染就是把我们代码里设置的代码的视图和数据结合,最后绘制成一张图呈现在用户的面前。每秒绘制60张图,用户看着就是流畅的界面呈现,如果不到60帧,那么用户看到的帧数越少就会越卡。

CALayer

在iOS中,最终我们看到的视图都是在CALayer里呈现的,在CALayer有一个属性叫做contents,这里不放别的,放的就是显示用的一张图。

我们来看看YYAsyncLayer类的代码:

  // 类声明
  @interface YYAsyncLayer : CALayer // 1
  /// Whether the render code is executed in background. Default is YES.
  @property BOOL displaysAsynchronously;
  @end

  //类实现的一部分代码
  UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
  UIGraphicsEndImageContext();  // 2
  // ...
  dispatch_async(dispatch_get_main_queue(), ^{
      self.contents = (__bridge id)(image.CGImage); // 3
  });
  1. YYAsyncLayer继承自CALayer
  2. UIGraphicsGetImageFromCurrentImageContext这是一个CoreGraphics的调用,是在一些绘制之后返回组成的图片。
  3. 在2>中生成的图片,最终被赋值给了CALahyer#contents属性。

CoreGraphics

如果说CALayer是一个绘制结果的展示,那么绘制的过程就要用到CoreGraphics了。

在正式开始以前,首先需要了解一个方法的实现。这个方法会用来绘制具体的界面上的内容:

task.display = ^(CGContextRef context, CGSize size, BOOL(^isCancelled)(void)) {
    if (isCancelled()) return;
    NSArray *lines = CreateCTLines(text, font, size.width);
    if (isCancelled()) return;
    
    for (int i = 0; i < lines.count; i++) {
        CTLineRef line = line[i];
        CGContextSetTextPosition(context, 0, i * font.pointSize * 1.5);
        CTLineDraw(line, context);
        if (isCancelled()) return;
    }
};

你也看到了,这其实不是一个方法而是一个block。这个block会使用传入的CGContextRef context参数来绘制文字。

目前了解这么多就足够了,后面会有详细的介绍。

YYAsyncLayer#_displayAsync方法是如何绘制的,_displayAsync是一个“私有方法”。

//这里我们只讨论异步的情况
// 1
CGSize size = self.bounds.size;
BOOL opaque = self.opaque;
CGFloat scale = self.contentsScale;
CGColorRef backgroundColor = (opaque && self.backgroundColor) 
  ? CGColorRetain(self.backgroundColor) : NULL;

dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{  // 2
  UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
  CGContextRef context = UIGraphicsGetCurrentContext();
  // 3
  if (opaque) {
    CGContextSaveGState(context); {
      if (!backgroundColor || CGColorGetAlpha(backgroundColor) < 1) {
        CGContextSetFillColorWithColor(context, [UIColor whiteColor].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);
  }
  task.display(context, size, isCancelled);   // 4

  // 5
  UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
  UIGraphicsEndImageContext();

  // 6
  dispatch_async(dispatch_get_main_queue(), ^{
    self.contents = (__bridge id)(image.CGImage);
  });
});

解释如下:

  1. 准备工作,获取size, opaque, scalebackgroundColor这个四个值。这些在获取绘制的取悦的时候用到。背景色另外有处理。
  2. YYAsyncLayerGetDisplayQueue()方法返回一个dispatch_queue_t实例,并在其中开始异步操作。
  3. 判断opaque的值,如果是非透明的话处理背景色。这个时候就会用到第一步里获取到的backgroundColor变量的值。
  4. CoreGraphics一节开始的时候讲到的绘制具体内容的block。
  5. 绘制完毕,获取到UIImage实例。
  6. 返回主线程,并给contents属性设置绘制的成果图片。至此异步绘制全部结束。

为了让读者更加关注异步绘制这个主题,所以省略了部分代码。生路的代码中很多事检查是否取消的。异步的绘制,尤其是在一个滚动的UITableView或者UICollectionView中随时都可能会取消,所以即使的检查是否取消并终止正在进行的绘制很有必要。这些,你会在完整的代码中看到。

不能无限的开辟线程

我们都知道,把阻塞主线程执行的代码放入另外的线程里保证APP可以及时的响应用户的操作。但是线程的切换也是需要额外的开销的。也就是说,线程不能无限度的开辟下去。

那么,dispatch_queue_t的实例也不能一直增加下去。有人会说可以用dispatch_get_global_queue()来获取系统的队列。没错,但是这个情况只适用于少量的任务分配。因为,系统本身也会往这个queue里添加任务的。

所以,我们需要用自己的queue,但是是有限个的。在YY里给这个数量指定的最大值是16。它会首先判断CPU的核数(int)[NSProcessInfo processInfo].activeProcessorCount。如果核数大于给定的最大值则使用最大值。

开辟线程的时候使用的是YYKit里自己的一套“线程池”工具来控制开辟的线程数量的。

设计,把点连成线

YYAsyncLayer异步绘制的过程就是一个观察者执行的过程。所谓的观察者就是你设置了一个机关,当它被触发的时候可以执行你预设的东西。比如你走到一扇门前,它感应到了你的红外辐射就会打开。

async layer也是一样,它会把“感应器”放在run loop里。当run loop要闲下来的时候“感应器”的回调开始执行,告诉async layer可以开始异步渲染了。

但是异步渲染要干什么呢?我们现在就来说说异步渲染的内容从哪里来?一个需要异步渲染的view会在定义的时候就把需要异步渲染的内容通过layer保存在view的代理发送给layer。

CALayer和UIView的关系

UIView是显示层,而显示在屏幕上的内容是由CALayer来管理的。CALayer的一个代理方法可以在UIView宿主里实现。

YYAsyncLayer用的就是这个方式。代理为:

@protocol YYAsyncLayerDelegate <NSObject>
@required
/// This method is called to return a new display task when the layer's contents need update.
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask;
@end

在实现的时候是这样的:

#pragma mark - YYTextAsyncLayerDelegate

- (YYTextAsyncLayerDisplayTask *)newAsyncDisplayTask {
  // 1
  YYAsyncLayerDisplayTask *task = [YYAsyncLayerDisplayTask new]; 

  // 2 
  task.willDisplay = ^(CALayer *layer) {
    // ...
  }

  // 3 
  task.display = ^(CGContextRef context, CGSize size, BOOL (^isCancelled)(void)) {
    // ...
  }

  // 4 
  task.didDisplay = ^(CALayer *layer, BOOL finished) {
    // ...
  }

  return task;
}
  1. 创建了YYAsyncLayerDisplayTask对象
  2. 设置task的willDisplayblock回调。 3. 4.分别设置了其他的display回调block。

可见YYAsyncLayer的代理的实现会创建一个YYAsyncLayerDisplayTask的实例并返回。在这个实例中包含了layer显示顺序的回调:willDisplaydisplaydidDisplay

setNeedsDisplay

CALayer实例调用setNeedsDisplay方法之后CALayerdisplay方法就会被调用。YYAsyncLayer重写了display方法:

- (void)display {
  super.contents = super.contents;
  [self _displayAsync:_displaysAsynchronously];
}

最终会调用YYAsyncLayer实例的display方法。display方法又会调用到_displayAsync:方法,开始异步绘制的过程。

总结

最后,我们把整个异步渲染的过程来串联起来。

对一个包含了YYAsyncLayer的view,比如YYLable就像文档里的一样。重写layoutSubviews方法添加对layer的setNeedsDisplay方法的调用。

这样一个调用链就形成了:用户操作->RunLoop(Waiting | Exiting)->调用observer的回调->[view layoutSubviews]->[view.layer setNeedsDisplay]->[layer display]->[layer _displayAsync]异步绘制开始(准确的说是_displayAsync方法的参数为true**的时候开始异步绘制)。

但是这并没有用到RunLoop。所以代码会修改为每次调用layoutSubviews的时候给RunLoop提交一个异步绘制的任务:

- (void)layoutSubviews {
    [super layoutSubviews];
    [[YYTransaction transactionWithTarget:self selector:@selector(contentsNeedUpdated)] commit];
}

- (void)contentsNeedUpdated {
    // do update
    [self.layer setNeedsDisplay];
}

这样每次RunLoop要进入休眠或者即将退出的时候会开始异步的绘制。这个任务是从[layer setNeedsDisplay]开始的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容