YYAsyncLayer 源码剖析:异步绘制

YYKit 系列源码剖析文章:ios

引言

性能优化一直是 iOS 开发中的一个重头戏,其中界面流畅度的优化是相当重要的,由于它直接关系到用户体验。从最熟悉和简单的 UIKit 框架到 CoreAnimation、CoreGraphics、CoreText 甚至是 OpenGL,优化彷佛是无穷无尽,也很是考验开发者的水平。git

YYAsyncLayer 是 ibireme 写的一个异步绘制的轮子,虽然代码加起来才 300 行左右,但质量比较高,涉及到不少优化思惟,值得学习。github

可能不少人学习优秀源码陷入了一个误区,仅仅是阅读而不理解。算法

咱们应该多思考做者为何这样写,而不是仅仅看懂代码的表面意思。由于看懂 API 很简单,这不该该是阅读源码最关注的东西,关注的层次不一样天然决定了开发者的高度。编程

源码基于 1.0.0 版本。数组

1、框架概述

YYAsyncLayer 库代码很清晰,就几个文件:缓存

YYAsyncLayer.h (.m)
YYSentinel.h (.m)
YYTransaction.h (.m)
复制代码
  • YYAsyncLayer 类继承自 CALayer ,不一样的是做者封装了异步绘制的逻辑便于使用。
  • YYSentinel 类是一个计数的类,是为了记录最新的布局请求标识,便于及时的放弃多余的绘制逻辑以减小开销。
  • YYTransaction 类是事务类,捕获主线程 runloop 的某个时机回调,用于处理异步绘制事件。

可能有些读者会迷糊,不过不要紧,后文会详细剖析代码细节,这里只须要对框架有个大体的认识就能够了。安全

浏览一下源码即可以知道,该框架的用法不过是使用一个 CALayer 的子类 —— YYAsyncLayer。(须要实现 YYAsyncLayer 类指定的代理方法,对整个绘制流程作管理,详细使用方法能够看看框架的 README性能优化

2、为何须要异步绘制?

一、界面卡顿的实质

iOS 设备显示器每绘制完一帧画面,复位时就会发送一个 VSync (垂直同步信号) ,而且此时切换帧缓冲区 (iOS 设备是双缓存+垂直同步);在读取经 GPU 渲染完成的帧缓冲区数据进行绘制的同时,还会经过 CADisplayLink 等机制通知 APP 内部能够提交结果到另外一个空闲的帧缓冲区了;接着 CPU 计算 APP 布局,计算完成交由 GPU 渲染,渲染完成提交到帧缓冲区;当 VSync 再一次到来的时候,切换帧缓冲区...... (ps: 上面这段描述是笔者的理解,参考 iOS 保持界面流畅的技巧bash

当 VSync 到来准备切换帧缓冲区时,若空闲的帧缓存区并未收到来自 GPU 的提交,这次切换就会做罢,设备显示系统会放弃这次绘制,从而引发掉帧。

由此可知,无论是 CPU 仍是 GPU 哪个出现问题致使不能及时的提交渲染结果到帧缓冲区,都会致使掉帧。优化界面流畅程度,实际上就是减小掉帧(iOS设备上大体是 60 FPS),也就是减少 CPU 和 GPU 的压力提升性能。

二、UIKit 性能瓶颈

大部分 UIKit 组件的绘制是在主线程进行,须要 CPU 来进行绘制,当同一时刻过多组件须要绘制或者组件元素过于复杂时,必然会给 CPU 带来压力,这个时候就很容易掉帧(主要是文本控件,大量文本内容的计算和绘制过程都至关繁琐)。

三、UIKit 替代方案:CoreAnimation 或 CoreGraphics

固然,首选优化方案是 CoreAnimation 框架。CALayer 的大部分属性都是由 GPU 绘制的 (硬件层面),不须要 CPU (软件层面) 作任何绘制。CA 框架下的 CAShapeLayer (多边形绘制)、CATextLayer(文本绘制)、CAGradientLayer (渐变绘制) 等都有较高的效率,很是实用。

再来看一下 CoreGraphics 框架,实际上它是依托于 CPU 的软件绘制。在实现CALayerDelegate 协议的 -drawLayer:inContext: 方法时(等同于UIView 二次封装的 -drawRect:方法),须要分配一个内存占用较高的上下文context,与此同时,CALayer 或者其子类须要建立一个等大的寄宿图contents。当基于 CPU 的软件绘制完成,还须要经过 IPC (进程间通讯) 传递给设备显示系统。值得注意的是:当重绘时须要抹除这个上下文从新分配内存。

无论是建立上下文、重绘带来的内存从新分配、IPC 都会带来性能上的较大开销。因此 CoreGraphics 的性能比较差,平常开发中要尽可能避免直接在主线程使用。一般状况下,直接给 CALayercontents 赋值 CGImage 图片或者使用 CALayer 的衍生类就能实现大部分需求,还能充分利用硬件支持,图像处理交给 GPU 固然更加放心。

四、多核设备带来的可能性

经过以上说明,能够了解 CoreGraphics 较为糟糕的性能。然而可喜的是,市面上的设备都已经不是单核了,这就意味着能够经过后台线程处理耗时任务,主线程只须要负责调度显示。

ps:关于多核设备的线程性能问题,后面分析源码会讲到

CoreGraphics 框架能够经过图片上下文将绘制内容制做为一张位图,而且这个操做能够在非主线程执行。那么,当有 n 个绘制任务时,能够开辟多个线程在后台异步绘制,绘制成功拿到位图回到主线程赋值给 CALayer 的寄宿图属性。

这就是 YYAsyncLayer 框架的核心思想,该框架还有其余的亮点后文慢慢阐述。

虽然多个线程异步绘制会消耗大量的内存,可是对于性能敏感界面来讲,只要工程师控制好内存峰值,能够极大的提升交互流畅度。优化不少时候就是空间换时间,所谓鱼和熊掌不可兼得。这也说明了一个问题,实际开发中要作有针对性的优化,不可盲目跟风。

3、YYSentinel

该类很是简单:

.h
@interface YYSentinel : NSObject
@property (readonly) int32_t value;
- (int32_t)increase;
@end

.m
@implementation YYSentinel { int32_t _value; }
- (int32_t)value { return _value; }
- (int32_t)increase { return OSAtomicIncrement32(&_value); }
@end
复制代码

一看便知,该类扮演的是计数的角色,值得注意的是,-increase方法是使用 OSAtomicIncrement32() 方法来对value执行自增。

OSAtomicIncrement32()是原子自增方法,线程安全。在平常开发中,若须要保证整形数值变量的线程安全,可使用 OSAtomic 框架下的方法,它每每性能比使用各类“锁”更为优越,而且代码优雅。

至于该类的实际做用后文会解释。

4、YYTransaction

YYTransaction 貌似和系统的 CATransaction 很像,他们同为“事务”,但实际上很不同。经过 CATransaction 的嵌套用法猜想 CATransaction 对任务的管理是使用的一个栈结构,而 YYTransaction 是使用的集合来管理任务。

YYTransaction 作的事情就是记录一系列事件,而且在合适的时机调用这些事件。至于为何这么作,须要先了解 YYTransaction 作了些什么,最终你会恍然大悟😁。

一、提交任务

YYTransaction 有两个属性:

@interface YYTransaction()
@property (nonatomic, strong) id target;
@property (nonatomic, assign) SEL selector;
@end
static NSMutableSet *transactionSet = nil;
复制代码

很简单,方法接收者 (target) 和方法 (selector),实际上一个 YYTransaction 就是一个任务,而全局区的 transactionSet 集合就是用来存储这些任务。提交方法-commit 不过是初始配置而且将任务装入集合。

二、合适的回调时机

static void YYTransactionSetup() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        transactionSet = [NSMutableSet new];
        CFRunLoopRef runloop = CFRunLoopGetMain();
        CFRunLoopObserverRef observer;
        observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                           kCFRunLoopBeforeWaiting | kCFRunLoopExit,
                                           true,      // repeat
                                           0xFFFFFF,  // after CATransaction(2000000)
                                           YYRunLoopObserverCallBack, NULL);
        CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
        CFRelease(observer);
    });
}
复制代码

这里在主线程的 RunLoop 中添加了一个 oberver 监听,回调的时机是 kCFRunLoopBeforeWaitingkCFRunLoopExit ,便是主线程 RunLoop 循环即将进入休眠或者即将退出的时候。而该 oberver 的优先级是 0xFFFFFF,优先级在 CATransaction 的后面(至于 CATransaction 的优先级为何是 2000000,应该在主线程 RunLoop 启动的源代码中能够查到,笔者并无找到暴露出来的信息)。

从这里能够看出,做者使用一个“低姿态”侵入主线程 RunLoop,在处理完重要逻辑(即 CATransaction 管理的绘制任务)以后作异步绘制的事情,这也是做者对优先级的权衡考虑。

下面看看回调里面作了些什么:

static void YYRunLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    if (transactionSet.count == 0) return;
    NSSet *currentSet = transactionSet;
    transactionSet = [NSMutableSet new];
    [currentSet enumerateObjectsUsingBlock:^(YYTransaction *transaction, BOOL *stop) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [transaction.target performSelector:transaction.selector];
#pragma clang diagnostic pop
    }];
}
复制代码

一目了然,只是将集合中的任务分别执行。

三、自定义 hash 算法

YYTransaction 类重写了 hash 算法:

- (NSUInteger)hash {
    long v1 = (long)((void *)_selector);
    long v2 = (long)_target;
    return v1 ^ v2;
}
复制代码

NSObject 类默认的 hash 值为 10 进制的内存地址,这里做者将_selector_target的内存地址进行一个位异或处理,意味着只要_selector_target地址都相同时,hash 值就相同。

这么作的意义是什么呢?

上面有提到一个集合:

static NSMutableSet *transactionSet = nil;
复制代码

和其余编程语言同样 NSSet 是基于 hash 的集合,它是不能有重复元素的,而判断是否重复毫无疑问是使用 hash。这里将 YYTransaction 的 hash 值依托于_selector_target的内存地址,那就意味着两点:

  1. 同一个 YYTransaction 实例,_selector_target只要有一个内存地址不一样,就会在集合中体现为两个值。
  2. 不一样的 YYTransaction 实例,_selector_target的内存地址都相同,在集合中的体现为一个值。

熟悉 hash 的读者应该一点即通,那么这么作对于业务的目的是什么呢?

很简单,这样能够避免重复的方法调用。加入transactionSet中的事件会在 Runloop 即将进入休眠或者即将退出时遍历执行,相同的方法接收者 (_target) 和相同的方法 (_selector) 在一个 Runloop 周期内能够视为重复调用。

举个例子:

在 YYText 的YYTextView中,主要是为了将自定义的绘制逻辑装入transactionSet,而后在 Runloop 要结束时统一执行,Runloop 回调的优先级避免与系统绘制逻辑竞争资源,使用NSSet合并了一次 Runloop 周期屡次的绘制请求为一个。

5、YYAsyncLayer

@interface YYAsyncLayer : CALayer
@property BOOL displaysAsynchronously;
@end
复制代码

YYAsyncLayer 继承自 CALayer,对外暴露了一个方法可开闭是否异步绘制。

一、初始化配置

- (instancetype)init {
    self = [super init];
    static CGFloat scale; //global
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        scale = [UIScreen mainScreen].scale;
    });
    self.contentsScale = scale;
    _sentinel = [YYSentinel new];
    _displaysAsynchronously = YES;
    return self;
}
复制代码

这里设置了YYAsyncLayercontentsScale为屏幕的scale,该属性是 物理像素 / 逻辑像素,这样能够充分利用不一样设备的显示器分辨率,绘制更清晰的图像。可是若contentsGravity设置了可拉伸的类型,CoreAnimation 将会优先知足,而忽略掉contentsScale

同时还建立了一个YYSentinel实例。

@2x和@3x图

实际上 iPhone4 及其以上的 iPhone 设备scale都是 2 及以上,也就是说至少都是每一个逻辑像素长度对应两个物理像素长度。因此不少美工会只切 @2x 和 @3x 图给你,而不切一倍图。

@2x和@3x图是苹果一个优化显示效果的机制,当 iPhone 设备scale为 2 时会优先读取 @2x 图,当scale为 3 时会优先读取 @3x 图,这就意味着,CALayercontentsScale要和设备的scale对应才能达到预期的效果(不一样设备显示相同的逻辑像素大小)。

幸运的是,UIViewUIImageView默认处理了它们内部CALayercontentsScale,因此除非是直接使用CALayer及其衍生类,都不用显式的配置contentsScale

重写绘制方法

- (void)setNeedsDisplay {
    [self _cancelAsyncDisplay];
    [super setNeedsDisplay];
}
- (void)display {
    super.contents = super.contents;
    [self _displayAsync:_displaysAsynchronously];
}
复制代码

能够看到两个方法,-_cancelAsyncDisplay是取消绘制,稍后解析实现逻辑;-_displayAsync是异步绘制的核心方法。

二、YYAsyncLayerDelegate 代理

@protocol YYAsyncLayerDelegate <NSObject>
@required
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask;
@end
复制代码
@interface YYAsyncLayerDisplayTask : NSObject
@property (nullable, nonatomic, copy) void (^willDisplay)(CALayer *layer);
@property (nullable, nonatomic, copy) void (^display)(CGContextRef context, CGSize size, BOOL(^isCancelled)(void));
@property (nullable, nonatomic, copy) void (^didDisplay)(CALayer *layer, BOOL finished);
@end
复制代码

YYAsyncLayerDisplayTask是绘制任务管理类,能够经过willDisplaydidDisplay回调将要绘制和结束绘制时机,最重要的是display,须要实现这个代码块,在代码块里面写业务绘制逻辑。

这个代理实际上就是框架和业务交互的桥梁,不过这个设计笔者我的认为有一些冗余,这里若是直接经过代理方法与业务交互而不使用中间类可能看起来更舒服。

三、异步绘制的核心逻辑

删减了部分代码:

- (void)_displayAsync:(BOOL)async {
    __strong id<YYAsyncLayerDelegate> delegate = self.delegate;
    YYAsyncLayerDisplayTask *task = [delegate newAsyncDisplayTask];
    ...
        dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
            if (isCancelled()) return;
            UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
            CGContextRef context = UIGraphicsGetCurrentContext();
            task.display(context, size, isCancelled);
            if (isCancelled()) {
                UIGraphicsEndImageContext();
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (task.didDisplay) task.didDisplay(self, NO);
                });
                return;
            }
            UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
            if (isCancelled()) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (task.didDisplay) task.didDisplay(self, NO);
                });
                return;
            }
            dispatch_async(dispatch_get_main_queue(), ^{
                if (isCancelled()) {
                    if (task.didDisplay) task.didDisplay(self, NO);
                } else {
                    self.contents = (__bridge id)(image.CGImage);
                    if (task.didDisplay) task.didDisplay(self, YES);
                }
            });
        });
    ...
}
复制代码

先不用管 YYAsyncLayerGetDisplayQueue()方法如何获取的异步队列,也先不用管isCancelled()判断作的一些提早结束绘制的逻辑,这些后面会讲。

那么,实际上核心代码能够更少:

- (void)_displayAsync:(BOOL)async {
    ...
    dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
        UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
        CGContextRef context = UIGraphicsGetCurrentContext();
        task.display(context, size, isCancelled);
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        dispatch_async(dispatch_get_main_queue(), ^{
            self.contents = (__bridge id)(image.CGImage);
        });
    }];
    ...
}
复制代码

此时就很清晰了,在异步线程建立一个位图上下文,调用taskdisplay代码块进行绘制(业务代码),而后生成一个位图,最终进入主队列给YYAsyncLayercontents赋值CGImage由 GPU 渲染事后提交到显示系统。

四、及时的结束无用的绘制

针对同一个YYAsyncLayer,颇有可能新的绘制请求到来时,当前的绘制任务还未完成,而当前的绘制任务是无用的,会继续消耗过多的 CPU (GPU) 资源。固然,这种场景主要是出如今列表界面快速滚动时,因为视图的复用机制,致使从新绘制的请求很是频繁。

为了解决这个问题,做者使用了大量的判断来及时的结束无用的绘制,能够看看源码或者是上文贴出的异步绘制核心逻辑代码,会发现一个频繁的操做:

if (isCancelled()) {...}
复制代码

看看这个代码块的实现:

YYSentinel *sentinel = _sentinel;
int32_t value = sentinel.value;
BOOL (^isCancelled)(void) = ^BOOL() {
  return value != sentinel.value;
};
复制代码

这就是YYSentinel计数类起做用的时候了,这里用一个局部变量value来保持当前绘制逻辑的计数值,保证其余线程改变了全局变量_sentinel的值也不会影响当前的value;若当前value不等于最新的_sentinel .value时,说明当前绘制任务已经被放弃,就须要及时的作返回逻辑。

那么,什么时候改变这个计数?

- (void)setNeedsDisplay {
    [self _cancelAsyncDisplay];
    [super setNeedsDisplay];
}
- (void)_cancelAsyncDisplay {
    [_sentinel increase];
}
复制代码

很明显,在提交重绘请求时,计数器加一。

😁不得不说,这确实是一个使人兴奋的优化技巧。

五、异步线程的管理

笔者去除了判断 YYDispatchQueuePool 库是否存在的代码,实际上那就是做者提取的队列管理封装,思想和如下代码同样。

static dispatch_queue_t YYAsyncLayerGetDisplayQueue() {
//最大队列数量
#define MAX_QUEUE_COUNT 16
//队列数量
    static int queueCount;
//使用栈区的数组存储队列
    static dispatch_queue_t queues[MAX_QUEUE_COUNT];
    static dispatch_once_t onceToken;
    static int32_t counter = 0;
    dispatch_once(&onceToken, ^{
//要点 1 :串行队列数量和处理器数量相同
        queueCount = (int)[NSProcessInfo processInfo].activeProcessorCount;
        queueCount = queueCount < 1 ? 1 : queueCount > MAX_QUEUE_COUNT ? MAX_QUEUE_COUNT : queueCount;
//要点 2 :建立串行队列,设置优先级
        if ([UIDevice currentDevice].systemVersion.floatValue >= 8.0) {
            for (NSUInteger i = 0; i < queueCount; i++) {
                dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0);
                queues[i] = dispatch_queue_create("com.ibireme.yykit.render", attr);
            }
        } else {
            for (NSUInteger i = 0; i < queueCount; i++) {
                queues[i] = dispatch_queue_create("com.ibireme.yykit.render", DISPATCH_QUEUE_SERIAL);
                dispatch_set_target_queue(queues[i], dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
            }
        }
    });
//要点 3 :轮询返回队列
    int32_t cur = OSAtomicIncrement32(&counter);
    if (cur < 0) cur = -cur;
    return queues[(cur) % queueCount];
#undef MAX_QUEUE_COUNT
}
复制代码
要点 1 :串行队列数量和处理器数量相同

首先要明白,并发并行 的区别: 并行必定并发,并发不必定并行。在单核设备上,CPU经过频繁的切换上下文来运行不一样的线程,速度足够快以致于咱们看起来它是‘并行’处理的,然而咱们只能说这种状况是并发而非并行。例如:你和两我的一块儿百米赛跑,你一直在不停的切换跑道,而其余两人就在本身的跑道上,最终,大家三人同时到达了终点。咱们把跑道看作任务,那么,其余两人就是并行执行任务的,而你只能的说是并发执行任务。

因此,实际上一个 n 核设备同一时刻最多能 并行 执行 n 个任务,也就是最多有 n 个线程是相互不竞争 CPU 资源的。

当你开辟的线程过多,超过了处理器核心数量,实际上某些并行的线程之间就可能竞争同一个处理器的资源,频繁的切换上下文也会消耗处理器资源。

因此,笔者认为:超过处理器核心数量的线程没有处理速度上的优点,只是在业务上便于管理,而且能最大化的利用处理器资源。

而串行队列中只有一个线程,该框架中,做者使用和处理器核心相同数量的串行队列来轮询处理异步任务,有效的减小了线程调度操做。

要点 2 :建立串行队列,设置优先级

在 8.0 以上的系统,队列的优先级为 QOS_CLASS_USER_INITIATED,低于用户交互相关的QOS_CLASS_USER_INTERACTIVE

在 8.0 如下的系统,经过dispatch_set_target_queue()函数设置优先级为DISPATCH_QUEUE_PRIORITY_DEFAULT(第二个参数若是使用串行队列会强行将咱们建立的全部线程串行执行任务)。

能够猜想主队列的优先级是大于或等于QOS_CLASS_USER_INTERACTIVE的,让这些串行队列的优先级低于主队列,避免框架建立的线程和主线程竞争资源。

关于两种类型优先级的对应关系是这样的:

*  - DISPATCH_QUEUE_PRIORITY_HIGH:         QOS_CLASS_USER_INITIATED
 *  - DISPATCH_QUEUE_PRIORITY_DEFAULT:      QOS_CLASS_DEFAULT
 *  - DISPATCH_QUEUE_PRIORITY_LOW:          QOS_CLASS_UTILITY
 *  - DISPATCH_QUEUE_PRIORITY_BACKGROUND:   QOS_CLASS_BACKGROUND
复制代码
要点 3 :轮询返回队列

使用原子自增函数OSAtomicIncrement32()对局部静态变量counter进行自增,而后经过取模运算轮询返回队列。

注意这里使用了一个判断:if (cur < 0) cur = -cur;,当cur自增越界时就会变为负数最大值(在二进制层面,是用正整数的反码加一来表示其负数的)。

为何要使用 n 个串行队列实现并发

可能有人会有疑惑,为何这里须要使用 n 个串行队列来调度,而不用一个并行队列。

主要是由于并行队列没法精确的控制线程数量,颇有可能建立过多的线程,致使 CPU 线程调度过于频繁,影响交互性能。

可能会想到用信号量 (dispatch_semaphore_t) 来控制并发,然而这样只能控制并发的任务数量,而不能控制线程数量,而且使用起来不是很优雅。而使用串行队列就很简单了,咱们能够很明确的知道本身建立的线程数量,一切皆在掌控之中。

以上就是 YYKit 对线程处理的核心思想。

结语

不知道读者朋友有没有感觉到 YYAsyncLayer 的 300 行左右代码所涵盖的东西。实际上学习一份优秀源码须要在过程当中去了解和学习源码以外的其它不少知识,这也是优秀源码的价值所在。

沉下心来感觉代码的艺术。