XiaoboTalk

Core Graphics 使用技巧


在 Apple 平台 (iOS/macOS) 上绘图,有三套方案:
1: Core GraphicsCG 开头的 API
2: Core AnimationCA 开头的 API
3: Metal (苹果已经不在更新 OpenGL ES)

Core Graphics


如果是一些简单的图形绘制,一般考虑 Core Graphics,具体就是 drawRect(UIView) 和 drawLayer inContext (CALayer 的方法),优点是方便快捷,drawRect 中绘制,容易和业务结合。但缺点是 Core Graphics 绘制基本都是 CPU 绘制,很容易就会增加 CPU 的占用率。

Core Animation


相比之下,我更推荐使用 Core Animation 系进行绘制,虽然名字看上去像是专业做动画的,但其实绘图也是一把好手,且底层是基于 GPU 绘制(部分场景底层基于 Metal),再者,A 系列和 M 系列芯片,GPU 算力都极强,放着不用可惜了。使用 CAShapeLayer + UIBezierPath 组合,基本就能满足大部分的绘图场景。另外相比 Core GraphicsCore Animation 还无需手动管理内存。
实际开发中,可以结合使用 Core GraphicsCore Animation 让 CPU 和 GPU 配合,达到平衡。

Metal


最快捷的是使用 MetalKit,提供了 MTKView,可以想普通 View 一样使用。如果需要做一些更为密集的图形效果,则还需要使用底层的 Metal,例如短视频中的各种实时表情效果,可以基于 Metal 的支持的 MSL 开发着色器,供客户端动态下载。MSL 是基于 C++14 开发的,MSL 文档:https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf。Metal 虽然暂时没有受到太多开发者关注,但预计未来会是发展重点,CPU 的单核性能逐渐达到瓶颈,大家逐渐都在向 GPU 上发力。特别是基于 Metal 的机器学习与计算机视觉。

Core Graphics 绘制的几个重要技巧


日常开发,简单的图形绘制 Core Graphics 仍是重要且便捷的工具,但几个难理解的点:
1: CGContext 和 Graphics States
2: 非归零绕组规则和奇、偶规则
3: 如何使用 context clip

CGContext 和 Graphics States


上下文 context 很形象,就像现实中的画布,我需要给这个画布提供颜料、粗细不同的笔等等,这些属性都会被当做 Graphics States的一部分,被收集起来,当调用 drawPath 的时候,就会用当前画布上的这些属性。例如:
UIColor *fColor = [[UIColor blueColor] colorWithAlphaComponent:0.5]; // 设置上下文的填充颜色属性 CGContextSetFillColorWithColor(myContext, [fColor CGColor]); // 绘制 path1,会被 fColor 颜色值填充 CGContextAddPath(myContext, path1]); CGContextDrawPath(myContext, kCGPathFill); // 绘制 path2,也会被 fColor 颜色值填充 CGContextAddPath(myContext, path2]); CGContextDrawPath(myContext, kCGPathFill); // 绘制 path3,也会被 fColor 颜色值填充 CGContextAddPath(myContext, path3]); CGContextDrawPath(myContext, kCGPathFill);
上边绘制的 path1 、path2、path3 都会被同一个颜色 fColor 填充。但如果 path2 想用自己的颜色进行填充绘制,同时又不影响 path1 和 path3,这时就需要用到两个函数:CGContextSaveGStateCGContextRestoreGState:
UIColor *fColor = [[UIColor blueColor] colorWithAlphaComponent:0.5]; // 设置上下文的填充颜色属性 CGContextSetFillColorWithColor(myContext, [fColor CGColor]); // 绘制 path1,会被 fColor 颜色值填充 CGContextAddPath(myContext, path1]); CGContextDrawPath(myContext, kCGPathFill); // 绘制 path2,颜色单独填充, 将当前上下文属性(fillColor)暂存进栈中 CGContextSaveGState(myContext); UIColor *path2Color = [UIColor blueColor]; // 设置 path2 自己的上下文的填充颜色属性 CGContextSetFillColorWithColor(myContext, [path2Color CGColor]); CGContextAddPath(myContext, path2]); CGContextDrawPath(myContext, kCGPathFill); // 从栈中恢复上下文属性,fillColor 重新赋值为 fColor CGContextRestoreGState(myContext); // 绘制 path3,也会被 fColor 颜色值填充 CGContextAddPath(myContext, path3]); CGContextDrawPath(myContext, kCGPathFill);
CGContextSaveGState会将当前上下文的所有绘制属性进行压栈保存,好比将画布上的所有颜色盒和笔刷,暂时拿开,path2 要用自己的颜色盒与笔刷进行绘制。等到绘制完后通过 CGContextRestoreGState 恢复上次的上下文属性。好比 path2 画完后,把自己的颜色盒与笔刷都扔了,将之前的颜色盒与笔刷复原。这样就不会影响到 path3 继续使用 fColor 进行颜色填充。
当然,也可以在 path2 开始绘制的时候将直接覆盖(再次设置),path3 开始的时候再覆盖一次颜色。但这样做并不好,有两点。第一,如果两条 path 在两个不同的方法中,或者两个不同的文件中进行绘制,那么我们可能很难获取之前颜色值。第二,这里只是为了说明问题,所以只有一个填充颜色属性,实际的 context 上下文属性很多,参考苹果的文档,如下图:
notion image
 
所以,最好的实践是,一次绘制最好用 CGContextSaveGStateCGContextRestoreGState 将绘制代码包裹起来,中间可以随便设置属性进行自由绘制,不会造成属性串扰:
CGContextSaveGState(myContext); // 当前的绘制代码 CGContextRestoreGState(myContext);

非零绕组和奇偶规则


非零绕组(nonzero winding number rule)和奇偶(even-odd,常简写为 EO)规则,主要是用来确定,平面绘图中,如果两条路径存在重叠部分,那么重叠部分的点是否在区域内部。Core Graphics 在区域裁剪(Clip)和绘制模式上都提供了对应的支持:
CGContextClip(myContext); // 非零绕组裁剪 CGContextEOClip(myContext); // 奇偶裁剪 kCGPathFill, // 非零绕组填充绘制 kCGPathEOFill, // 奇偶填充绘制
非零绕组的判定规则:在重叠的区域内,找一点 P ,同时用一个计数为 0 初始值做记录,从 P 做一条射线,每当和 path 相交一次时,如果 path 的路径段从左到右穿过射线时,计数 +1,如果 path 的路径段从右到左穿过射线时,计数 -1。最终,如果计数值归 0,则判定 P 点所在的重叠区域不在路径内部,不进行绘制,所以 path 的方向会对结果产生影响:
notion image
 
奇偶判定规则,在重叠的区域内,找一点 P ,同时用一个计数为 0 初始值做记录,从 P 做一条射线,每当和 path 相交一次时,计数 +1,如果最终结果为奇数,则不绘制,为偶数,则进行绘制。奇偶判定规则只和相遇的路径条数有关,和方向无关。
notion image
 

利用路径裁剪 Clip,进行指定区域的遮罩绘图


有时候,我们需要对某些绘制进行遮罩(或者反向遮罩)处理,比如下图,左边为正常的遮罩,右侧为反向遮罩:
notion image
 
要实现这样的效果,可以使用 Clip 属性;思路是,用两条 path,先裁剪出要绘制区域,然后,在当前裁剪后的上下文上进行绘制图片:
notion image
 
我们需要画两条 path,一条是外边框的矩形 path,另一条是内部的圆形 path:
CGContextSaveGState(myContext); CGContextAddPath(myContext, rectPath); CGContextAddPath(myContext, circlePath); CGContextClosePath(myContext); CGContextEOClip(myContext); // 或者 CGContextClip(myContext); // ... context 已经裁剪好,从这里开始进行你的绘制 CGContextRestoreGState(myContext);
裁剪时,可以用 奇偶规则 CGContextEOClip(myContext),裁剪得到外围区域的遮罩,即上图的 Clip Area 2。当然也可以只用非零绕组规则 CGContextClip(myContext) 裁剪;非零绕组规则 与路径的方向有关,所以只用非零绕组规则,通过改变路径的方向,就可以实现上述的两种遮罩方案。CGContext 在绘制圆时,可以指定顺时针(clockwise)或者逆时针方向。而绘制矩形不能指定,但其实只需要指定圆形 path 的方向就够了。对于矩形,我们可以通过逐边绘制来控制绘制的方向,即AddLineToPoint(myContext, point);
最后 Clip 也属于上下文的属性,绘制完成后,不要忘记 CGContextRestoreGState(myContext);

下一步


Core Graphics 还有很多绘制技巧,篇幅有限,暂时记录到这里。图形绘制本身和 GPU 以及数学有比较大的连续,下一篇幅,我想重点放在数学上,介绍,如何通过编码快速解出两条线段的交点,以及一些矩阵空间的知识