YYAsyncLayer 研究
ChenghuiBai Lv3

[TOC]

引言

异步绘制是界面流畅度提升的思路,YYAsyncLayer 是 ibireme 写的一个异步绘制的轮子。质量比较高,涉及到很多优化思维,值得学习。

为什么要异步绘制

屏幕显示图像的原理

9d500c2270433ab8e444083d5cf650f2.png

CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

eb92699d5650217ec7357a49d5789ca2.png

过去的CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。

双缓冲机制

显示系统通常会引入两个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。

双缓冲虽然能解决效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。

为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

界面卡顿的实质

5f16df9fe174ef3a6879c2bb1f066d27.png

在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

UIKit 性能瓶颈

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

UIKit 替代方案:CoreAnimation 或 CoreGraphics
CoreAnimation

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

CoreGraphics

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

不管是创建上下文、重绘带来的内存重新分配、IPC 都会带来性能上的较大开销。所以 CoreGraphics 的性能比较差,日常开发中要尽量避免直接在主线程使用。通常情况下,直接给 CALayer 的 contents 赋值 CGImage 图片或者使用 CALayer 的衍生类就能实现大部分需求,还能充分利用硬件支持,图像处理交给 GPU 当然更加放心。

多核设备带来的可能性

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

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

这就是 YYAsyncLayer 框架的核心思想。

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

框架概述

类文件

YYSentinel.h (.m)
YYTransaction.h (.m)
YYAsyncLayer.h (.m)
YYSentinel

计数的类,自增int32_t类型变量value,是为了记录最新的布局请求标识,便于及时的放弃多余的绘制逻辑以减少开销。

在框架中的应用是,异步绘制操作之前取出value作为局部变量保存

int32_t value = sentinel.value;

然后定义isCancelled block判断是否取消绘制操作了

BOOL (^isCancelled)() = ^BOOL() {
//value产生变化说明取消绘制操作
return value != sentinel.value;
};

取消绘制操作

- (void)_cancelAsyncDisplay {
//对 value 进行自增,这样value就产生了变化
[_sentinel increase];
}
YYTransaction

事务类,捕获主线程 runloop 的某个时机回调,用于处理异步绘制事件。

YYAsyncLayer

继承自 CALayer ,封装了异步绘制的逻辑便于使用。

源码分析

YYSentinel

.h

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

.m
#import "YYSentinel.h"

// 若需要保证整形数值变量的线程安全,可以使用 OSAtomic 框架下的方法,它往往性能比使用各种“锁”更为优越,并且代码优雅。
#import <libkern/OSAtomic.h>

@implementation YYSentinel {
int32_t _value;
}
- (int32_t)value {
return _value;
}

// 使用 OSAtomicIncrement32() 方法来对 value 执行自增。
// OSAtomicIncrement32() 是原子自增方法,线程安全。
- (int32_t)increase {
return OSAtomicIncrement32(&_value);
}
@end

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 中添加了一个 observer 监听,回调的时机是 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit ,即是主线程 RunLoop 循环即将进入休眠或者即将退出的时候。

observer 的优先级是 0xFFFFFF,优先级在 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
}];
}

只是将集合中的任务遍历执行。

方法重写

重写 isEqual: 方法

- (BOOL)isEqual:(id)object {
if (self == object) return YES;
if (![object isMemberOfClass:self.class]) return NO;
YYTransaction *other = object;
return other.selector == _selector && other.target == _target;
}

重写 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;,transactionSet是一个集合。

这么做的意义,是因为这里和其他编程语言一样 NSSet 是基于 hash 的集合,它是不能有重复元素的,而判断是否重复毫无疑问是使用 hash。作者通过重写hash算法来重新定义重复元素。

将 YYTransaction 的 hash值依托于 _selector 和 _target 的内存地址,那就意味着两点:
1、同一个 YYTransaction 实例,_selector 和 _target 只要有一个内存地址不同,就会在集合中体现为两个值。
2、不同的 YYTransaction 实例,_selector 和 _target 的内存地址都相同,在集合中的体现为一个值。

在这可以避免重复的方法调用。加入 transactionSet 中的事件会在 Runloop 即将进入休眠或者即将退出时遍历执行,相同的方法接收者 (_target) 和相同的方法 (_selector),可以视为重复调用(这里的主要场景是避免重复绘制影响性能)。

举一个实际的例子:

当使用绘制来制作一个文本时,Font、Text等属性的改变都意味着要重绘,使用 YYTransaction 延迟了绘制的调用时机,并且它们在同一个 RunLoop 循环中,装入 NSSet 将直接合并为一个绘制任务,避免了重复的绘制。

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;
}

YYAsyncLayer 的 contentsScale 为屏幕的 scale,该属性是 物理像素 / 逻辑像素,这样可以充分利用不同设备的显示器分辨率,绘制更清晰的图像。但是若 contentsGravity 设置了可拉伸的类型,CoreAnimation 将会优先满足,而忽略掉 contentsScale。UIView 和 UIImageView 默认处理了它们内部 CALayer 的 contentsScale,所以除非是直接使用 CALayer 及其衍生类,都不用显式的配置 contentsScale。

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

默认开启异步绘制。

重写绘制方法:
- (void)setNeedsDisplay {
[self _cancelAsyncDisplay];
[super setNeedsDisplay];
}

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

标记需要绘制时,会先取消目前的异步绘制。待下一轮runloop到来绘制时,通过 _displayAsync: 方法来进行异步绘制操作。_displayAsync: 方法后面分析。

YYAsyncLayerDisplayTask
@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 在代码块里面写业务绘制逻辑。

YYAsyncLayerDelegate
@protocol YYAsyncLayerDelegate <NSObject>
@required
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask;
@end

通过协议提供数据源的方式,获取异步绘制的任务。

异步绘制的核心逻辑

去掉一些优化代码,主要做的事情如下:

dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
CGContextRef context = UIGraphicsGetCurrentContext();
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
self.contents = (__bridge id)(image.CGImage);
});
});

其实就是通过 CoreGraphics 在子线程中生成一个位图,然后进入主队列给 YYAsyncLayer 的 contents 赋值 CGImage 由 GPU 渲染过后提交到显示系统。

及时的结束无用的绘制

前面提到的 YYSentinel 的作用已经说明。

在适当的地方进行自增value,然后在绘制过程中对比value是否发生变化来判断是否取消绘制操作。

很巧妙的实现。

异步线程的管理
static dispatch_queue_t YYAsyncLayerGetDisplayQueue() {
#ifdef YYDispatchQueuePool_h
return YYDispatchQueueGetForQOS(NSQualityOfServiceUserInitiated);
#else
#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, ^{
queueCount = (int)[NSProcessInfo processInfo].activeProcessorCount;
queueCount = queueCount < 1 ? 1 : queueCount > MAX_QUEUE_COUNT ? MAX_QUEUE_COUNT : queueCount;
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));
}
}
});
int32_t cur = OSAtomicIncrement32(&counter);
if (cur < 0) cur = -cur;
return queues[(cur) % queueCount];
#undef MAX_QUEUE_COUNT
#endif
}
要点 1 :串行队列数量和处理器数量相同

一个 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的,让这些串行队列的优先级低于主队列,避免框架创建的线程和主线程竞争资源。

要点 3 :轮询返回队列

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

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

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

为什么这里需要使用 n 个串行队列来调度,而不用一个并行队列。

主要是因为并行队列无法精确的控制线程数量,很有可能创建过多的线程,导致 CPU 线程调度过于频繁,影响交互性能。

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

学习

YYAsyncLayer 源码剖析:异步绘制

  • Post title:YYAsyncLayer 研究
  • Post author:ChenghuiBai
  • Create time:2017-11-01 08:21:04
  • Post link:https://baichenghui.github.io/2017/11/01/YYAsyncLayer-研究/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.