YYAsyncLayer 的异步绘制之道

status
category
date
summary
slug
icon
tags
password
文本的宽高计算和文本的内容渲染都属于阻塞主线程的耗时任务,当一个界面中包含大量文本,尤其是文本内容影响了其他布局计算和渲染时,例如微博、朋友圈等 Feed,为了避免卡顿,需要尽量异步执行这些耗时任务。YYAsyncLayer 是从 YYText 中提取出来的异步绘制与显示的工具类,利用 RunLoop 和异步绘制解决了这个问题。

YYLebel 的实践

来看看 YYLabel 是如何通过 YYAsyncLayer 实现异步绘制的(不需要看详细的绘制逻辑):
可以看到 YYAsyncLayer 相当于一个 Layer 工具类,需要上层的控件自己实现绘制逻辑,提取出来的 YYAsyncLayer 代码只有几百行,且只有三个组件文件,在上面的代码都有用到:YYTransaction、YYAsyncLayerDisplayTask、YYAsyncLayer,接下来一一分析其内容。

YYSentinel——线程安全的计时器

注释讲的非常清楚,YYSentinel 相当于一个线程安全的计数器,实现如下:
这里通过 OSAtomicIncrement32() 保证了自增是线程安全的。

YYTransaction——绘制任务的 wrapper

最初第一行注释中的once before让我感到困惑,once后面接的事情应该是先发生,这里又加了个before。但从上下文来看,作者想表达的意思应该是:在当前 RunLoop 即将休眠之前执行 selector,这样的话应该用just before或者right before才对。为此还特地请教了英语专业的 MC赵同学,结果确实是自己才疏学浅。
YYTransaction 实现如下:
commit 方法执行了初始化操作(指YYTransactionSetup,而不是init,稍后介绍)并且将自己加入了全局 set 中。这里还重写了 hash 和 isEqual 方法,为 set 的重复判断提供了依据。hash 返回的是 selector 和 target 地址的异或,意味着 YYTransaction 类其实只是一个 wrapper,YYTransaction 实例并没有实际作用,我们只关心 selector 和 target,例如:
  • 同一个 YYTransaction 实例,selector 和 target 只要有一个地址不同,那么在 set 中就体现为两个值
  • 不同的 YYTransaction 实例,selector 和 target 如果地址都相同,那么在 set 中就体现为一个值
相同的对象(target)在一个 RunLoop 周期内多次执行同一个方法(selector),在这里被认为是重复调用,上面这种 hash 的做法这样做可以避免方法的重复调用。
来看看 YYTransaction 初始化的实现:
YYTransactionSetup 初始化了 transactionSet 实例,并且为主线程的 RunLoop 添加了一个 Observer,并标记为 Common,监听的事件为 RunLoop 即将休眠以及 RunLoop 即将退出。并且它的优先级比 CATransaction 要低,这样基本可以认为 YYTransaction 是 RunLoop 休眠或退出前执行的最后一个任务,也就是作者说的once before 。关于 RunLoop 和 Observer 的介绍可以看看 iOS RunLoop(一)概念与构成iOS RunLoop(二)生命周期与运行逻辑
Observer 执行的YYRunLoopObserverCallBack回调定义如下:
回调会重置 transactionSet,然后遍历之前添加到 set 中的的元素,逐一执行对应的方法。另外,这里关于 clang 的操作是为了忽略内存泄漏的警告。

YYAsyncLayerDisplayTask——绘制任务

同样可以将 YYAsyncLayerDisplayTask 理解为一个 wrapper,它只包装了三个 block,执行的时机分别是绘制前、绘制中(实际上就是这个 block 执行了绘制操作)和绘制后,具体的逻辑体现在 YYAsyncLayer 中,下面会提到。

YYAsyncLayer——绘制的操作者

YYAsyncLayer 是 CALayer 的子类,绘制的逻辑是通过 YYAsyncLayerDelegate 创建 YYAsyncLayerDisplayTask 实现的。

基础方法

YYAsyncLayer 的基础方法和初始化实现如下:
display中有一句super.contents = super.contents;,在 这个 issue 有人回复说:给 contents 赋值可以让 layer 使用自定义绘制的背景而不是再创建另一个单独的背景,但暂时没有验证。

绘制队列

YYAsyncLayer 将绘制任务拿出了主线程,并自己实现了一个串行队列::
首先定义了最大队列数量 MAX_QUEUE_COUNT,16 只是一个上限值,并没有什么特殊含义,接着根据处理器数量 activeProcessorCount 确定了队列数量:
  • 该值小于 1,则队列数量为 1
  • 该值大于 16,则队列数量为 16
  • 该值在 1 和 16 之间,则队列数量就是该值
然后根据队列数量创建串行队列,并保存在 queues 数组中。其队列优先级仅次于用户交互(QOS_CLASS_USER_INTERACTIVE),保证绘制在其他任务之前。前面提到的线程安全计数器在这里用于分配渲染任务,首先计算出计数器的值对队列数量取余的结果,相当于是最久没有使用过的队列,然后将渲染任务添加到这个队列。另外还创建了一个队列,专门用于释放资源。

绘制逻辑

接下来就是最主要的绘制方法:
异步绘制:
可以看到异步绘制的过程中,多次对是否取消当前任务进行了判断,由于是异步绘制,很有可能当前 Layer 还没有绘制完成,又提交了新的绘制任务。因此需要通过计数器来判断是否继续进行当前绘制任务,从代码可以看到,计数器会在以下三种情况自增:
  • YYAsyncLayer dealloc
  • YYAsyncLayer setNeedsDisplay
  • 同步绘制任务开始
异步绘制任务开始时会保存当前计数,如果该值和计数器实时更新的值不相同,则代表当前正在绘制的任务已经过时了或者不需要了,可以直接丢弃。
同步绘制的核心绘制过程和异步是相同的:
由于同步绘制过程中不需要考虑新添加的绘制任务,所以同步绘制的过程相对简单。但也正是由于同步绘制任务没有及时取消,或者过于复杂,导致了文章开头说的绘制卡顿,也正是为了解决这个问题 YYAsyncLayer 才诞生的。

总结

YYAsyncLayer 是提供异步绘制能力的 CALayer,用法可以参看文章开头的 YYLabel:
  1. 外部实现代理方法,在代理方法中编写绘制逻辑,然后通过返回 YYAsyncLayerDisplayTask 传递给 YYAsyncLayer;
  1. 通过 YYTransaction 传递方法接受者(target)和方法(selector),监听 RunLoop 即将休眠或退出,在空闲时间执行前面保存的方法,例如 setNeedsDisplay;
  1. 最终的 display 绘制过程会执行前面传递进来的 YYAsyncLayerDisplayTask,只不过异步绘制是在新创建的队列中完成的。异步绘制的过程中,还会通过线程安全的计数器,检查现有绘制任务是否过期,过期则及时取消以节省资源。
总的来说, YYAsyncLayer 在性能上的优异表现以及缓解卡顿现象的原因,主要有两点:
  1. 在 RunLoop 空闲时进行绘制更新
  1. 绘制时充分利用多核处理器,在多个队列执行绘制任务
Loading...

© 刘口子 2018-2025