圆角是一种很常见的视图效果,相比于直角,它更加柔和优美,易于接受。设置圆角会带来一定的性能损耗,如何提高性能是一个需要重点讨论的话题。

大家常见的圆角代码x.layer.cornerRadius = xx; x.clipsToBounds = YES;这两行确实实现了圆角视觉效果。其实使用x.layer.cornerRadius = xx;已经实现了圆角,只不过在某些控件是不生效的,因为某些图层在被切割圆角图层之上而被显示出来了。而x.clipsToBounds = YES;带来的后果就是产生离屏渲染。可以使用instruments中的CoreAnimation工具,打开Color Offscren-Rednered Yellow选项,可见黄色区域部分即是离屏渲染部分。

那么离屏渲染会带来什么?当然后资源损耗,可能产生卡顿。因为在iPhone设备的硬件资源有差异,当离屏渲染不多时,并不是很明显感觉到它的缺点。

什么是像素

像素,为视频显示的基本单位,译自英文“pixel”,pix是英语单词picture的常用简写,加上英语单词“元素”element,就得到pixel,故“像素”表示“画像元素”之意,有时亦被称为pel(picture element)。每个这样的消息元素不是一个点或者一个方块,而是一个抽象的取样。像素是由红,绿,蓝三种颜色组件构成的。因此,位图数据有时也被叫做 RGB 数据。

显示机制

一个像素是如何绘制到屏幕上去的?有很多种方式将一些东西映射到显示屏上,他们需要调用不同的框架、许多功能和方法的结合体。这里我们大概看一下屏幕之后发生的事情。

图像想显示到屏幕上使人肉眼可见都需借助像素的力量。它们密集的排布在手机屏幕上,将任何图形通过不同的色值表现出来。计算机显示的流程大致可以描述为将图像转化为一系列像素点的排列然后打印在屏幕上,由图像转化为像素点的过程又可以称之为光栅化,就是从矢量的点线面的描述,变成像素的描述。

display screen

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

关于卡顿的简单原理解释

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

CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。

渲染机制

当像素映射到屏幕上的时候,后台发生了很多事情。但一旦它们显示到屏幕上,每一个像素均由三个颜色组件构成:红,绿,蓝。三个独立的颜色单元会根据给定的颜色显示到一个像素上。在 iPhoneSE 的显示器上有1,136×640=727,040个像素,因此有2,181,120个颜色单元。在一些Retina屏幕上,这一数字将达到百万以上。所有的图形堆栈一起工作以确保每次正确的显示。当你滚动整个屏幕的时候,数以百万计的颜色单元必须以每秒60次的速度刷新,这就是一个很大的工作量。

简单来说,iOS的显示机制大致如此:
pixels software stack

Display 的上一层便是图形处理单元 GPU,GPU 是一个专门为图形高并发计算而量身定做的处理单元。这也是为什么它能同时更新所有的像素,并呈现到显示器上。它的并发本性让它能高效的将不同纹理合成起来。所以,开发中我们应该尽量让CPU负责主线程的UI调动,把图形显示相关的工作交给GPU来处理。

GPU Driver 是直接和 GPU 交流的代码块。不同的GPU是不同的性能怪兽,但是驱动使它们在下一个层级上显示的更为统一,典型的下一层级有 OpenGL/OpenGL ES.

OpenGL(Open Graphics Library) 是一个提供了 2D 和 3D 图形渲染的 API。GPU 是一块非常特殊的硬件,OpenGL 和 GPU 密切的工作以提高GPU的能力,并实现硬件加速渲染。

OpenGL 之上扩展出很多东西。在 iOS 上,几乎所有的东西都是通过 Core Animation 绘制出来,然而在 OS X 上,绕过 Core Animation 直接使用 Core Graphics 绘制的情况并不少见。对于一些专门的应用,尤其是游戏,程序可能直接和 OpenGL/OpenGL ES 交流。

需要强调的是,GPU 是一个非常强大的图形硬件,并且在显示像素方面起着核心作用。它连接到 CPU。从硬件上讲两者之间存在某种类型的总线,并且有像 OpenGL,Core Animation 和 Core Graphics 这样的框架来在 GPU 和 CPU 之间精心安排数据的传输。为了将像素显示到屏幕上,一些处理将在 CPU 上进行。然后数据将会传送到 GPU,最终像素显示到屏幕上。

pixels hardware

正如上图显示,GPU 需要将每一个 frame 的纹理(位图)合成在一起(一秒60次)。每一个纹理会占用 VRAM(video RAM),所以需要给 GPU 同时保持纹理的数量做一个限制。GPU 在合成方面非常高效,但是某些合成任务却比其他更复杂,并且 GPU在 16.7ms(1/60s)内能做的工作也是有限的。

另外一个问题就是将数据传输到 GPU 上。为了让 GPU 访问数据,需要将数据从 RAM 移动到 VRAM 上。这就是提及到的上传数据到 GPU。这些看起来貌似微不足道,但是一些大型的纹理却会非常耗时。

最终,CPU 开始运行程序。你可能会让 CPU 从 bundle 加载一张 PNG 的图片并且解压它。这所有的事情都在 CPU 上进行。然后当你需要显示解压缩后的图片时,它需要以某种方式上传到 GPU。一些看似平凡的,比如显示文本,对 CPU 来说却是一件非常复杂的事情,这会促使 Core Text 和 Core Graphics 框架更紧密的集成来根据文本生成一个位图。一旦准备好,它将会被作为一个纹理上传到 GPU 并准备显示出来。当你滚动或者在屏幕上移动文本时,同样的纹理能够被复用,CPU 只需简单的告诉 GPU 新的位置就行了,所以 GPU 就可以重用存在的纹理了。CPU 并不需要重新渲染文本,并且位图也不需要重新上传到 GPU。

在图形世界中,合成是一个描述不同位图如何放到一起来创建你最终在屏幕上看到图像的过程。屏幕上一切事物皆纹理。一个纹理就是一个包含 RGBA 值的长方形,比如,每一个像素里面都包含红、绿、蓝和透明度的值。在 Core Animation 世界中这就相当于一个 CALayer。

每一个 layer 是一个纹理,所有的纹理都以某种方式堆叠在彼此的顶部。对于屏幕上的每一个像素,GPU 需要算出怎么混合这些纹理来得到像素 RGB 的值。这就是合成。

如果我们所拥有的是一个和屏幕大小一样并且和屏幕像素对齐的单一纹理,那么屏幕上每一个像素相当于纹理中的一个像素,纹理的最后一个像素也就是屏幕的最后一个像素。

如果我们有第二个纹理放在第一个纹理之上,然后GPU将会把第二个纹理合成到第一个纹理中。有很多种不同的合成方法,但是如果我们假定两个纹理的像素对齐,并且使用正常的混合模式,我们便可以用公式来计算每一个像素:R = S + D * ( 1 – Sa )
结果的颜色是源色彩(顶端纹理)+目标颜色(低一层的纹理)*(1-源颜色的透明度)。在这个公式中所有的颜色都假定已经预先乘以了它们的透明度。

接着我们进行第二个假定,两个纹理都完全不透明,比如 alpha=1。如果目标纹理(低一层的纹理)是蓝色(RGB=0,0,1),并且源纹理(顶层的纹理)颜色是红色(RGB=1,0,0),因为 Sa 为1,所以结果为:R = S
结果是源颜色的红色。这正是我们所期待的(红色覆盖了蓝色)。如果源颜色层为50%的透明,比如 alpha=0.5,既然 alpha 组成部分需要预先乘进 RGB 的值中,那么 S 的 RGB 值为(0.5, 0, 0),公式看起来便会像这样:

1
2
3
                       0.5   0               0.5
R = S + D * (1 - Sa) = 0 + 0 * (1 - 0.5) = 0
0 1 0.5

我们最终得到RGB值为(0.5, 0, 0.5),是一个紫色。这正是我们所期望将透明红色合成到蓝色背景上所得到的。

记住我们刚刚只是将纹理中的一个像素合成到另一个纹理的像素上。当两个纹理覆盖在一起的时候,GPU需要为所有像素做这种操作。正如你所知道的一样,许多程序都有很多层,因此所有的纹理都需要合成到一起。尽管GPU是一块高度优化的硬件来做这种事情,但这还是会让它非常忙碌。

为何图片缩放会增加GPU工作量

当所有的像素是对齐的时候我们得到相对简单的计算公式。每当 GPU 需要计算出屏幕上一个像素是什么颜色的时候,它只需要考虑在这个像素之上的所有 layer 中对应的单个像素,并把这些像素合并到一起。或者,如果最顶层的纹理是不透明的(即图层树的最底层),这时候 GPU 就可以简单的拷贝它的像素到屏幕上。

当一个 layer 上所有的像素和屏幕上的像素完美的对应整齐,那这个 layer 就是像素对齐的。主要有两个原因可能会造成不对齐。第一个便是滚动,当一个纹理上下滚动的时候,纹理的像素便不会和屏幕的像素排列对齐。另一个原因便是当纹理的起点不在一个像素的边界上。

在这两种情况下,GPU 需要再做额外的计算。它需要将源纹理上多个像素混合起来,生成一个用来合成的值。当所有的像素都是对齐的时候,GPU 只剩下很少的工作要做。

Core Animation 工具和模拟器有一个Color Misaligned Images选项,当这些在你的 CALayer 实例中发生的时候,这个功能便可向你展示。

关于iOS设备的一些尺寸限制可以看这里:iOSRes

离屏渲染

On-Screen Rendering意为当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行。
Off-Screen Rendering意为离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。

当图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制时,屏幕外渲染就被唤起了。屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。

离屏渲染可以被 Core Animation 自动触发,或者被应用程序强制触发。屏幕外的渲染会合并/渲染图层树的一部分到一个新的缓冲区,然后该缓冲区被渲染到屏幕上。

特殊的“离屏渲染”:CPU渲染

如果我们重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。
整个渲染过程由CPU在App内同步地完成,渲染得到的bitmap最后再交由GPU用于显示。

离屏渲染的体现

相比于当前屏幕渲染,离屏渲染的代价是很高的,主要体现在两个方面:

  • 1 创建新缓冲区
    要想进行离屏渲染,首先要创建一个新的缓冲区。
  • 2 上下文切换
    离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。

触发离屏渲染

1、drawRect
2、layer.shouldRasterize = true;
3、有mask或者是阴影(layer.masksToBounds, layer.shadow*);
3.1) shouldRasterize(光栅化)
3.2) masks(遮罩)
3.3) shadows(阴影)
3.4) edge antialiasing(抗锯齿)
3.5) group opacity(不透明)
4、Text(UILabel, CATextLayer, Core Text, etc)…
注:layer.cornerRadius,layer.borderWidth,layer.borderColor并不会Offscreen Render,因为这些不需要加入Mask。

圆角优化

前面说了那么多,这里就给上实际可行方案。圆角的优化目前考虑两方面:一是,从图片入手,将图片切割成指定圆角样式。二是,使用贝塞尔曲线,利用CALayer层绘制指定圆角样式的mask遮盖View。

UIImage切割:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
UIGraphicsBeginImageContextWithOptions(self.size, NO, 0);
CGContextRef context = UIGraphicsGetCurrentContext();
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGContextScaleCTM(context, 1, -1);
CGContextTranslateCTM(context, 0, -rect.size.height);

CGFloat minSize = MIN(self.size.width, self.size.height);
if (borderWidth < minSize / 2.0) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(rect, borderWidth, borderWidth) byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
CGContextSaveGState(context);
[path addClip];
CGContextDrawImage(context, rect, self.CGImage);
CGContextRestoreGState(context);
}

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
image = [image dd_imageByCornerRadius:radius borderedColor:borderColor borderWidth:borderWidth corners:corners];
UIGraphicsEndImageContext();

图片绘制:

1
2
3
4
5
6
7
8
9
10
11
UIGraphicsBeginImageContextWithOptions(self.size, NO, 0);
[self drawAtPoint:CGPointZero];
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGFloat strokeInset = borderWidth / 2.0;
CGRect strokeRect = CGRectInset(rect, strokeInset, strokeInset);
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
path.lineWidth = borderWidth;
[borderColor setStroke];
[path stroke];
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

具体源码可以转至github进行star DDCornerRadius 欢迎issue。