浅谈 iOS 渲染与动画的艺术

作者:Enum,百度高级 iOS 工程师,Swift服务端爱好者, 博客:enumsblog.com

本文的主题是渲染和动画。

本文将通过简单的例子来展现 iOS 系统中的各个渲染框架能做什么,适合做什么。之后会对 Core Animation 的图层原理以及一些日常开发中遇到的奇怪的现象背后的原因做一个简单的讲解。看完这一章节,相信读者会对 iOS 的渲染和动画会进一步的了解。

iOS 中的渲染框架

iOS 包含了多套渲染框架。从开发者们接触得最多的UIKitCore Animation,到今年 iOS 11 最新推出的Metal 2Metal for VR。它们各自的职责大不相同。作为开发者,我们或许并不能各个框架都精通,至少应该知道它们大概是用来做什么的,遇到具体需求的时候,才会有一个大致的方向。

UIKit & AppKit

作为开发者,对 UIKit 和 AppKit 应该是非常熟悉了,实际开发中大量应用到了它们。这不是本章节的重点,因此不再展开。

Core Animation

Core Animation 是常用的框架之一。它比 UIKit 和 AppKit 更底层。正如我们所知,UIView底下封装了一层CALayer树,Core Animation 层是真正的渲染层,我们之所以能在屏幕上看到内容,真正的渲染工作是在 Core Animation 层的。关于CALayer的内容,会在下文中展开。

Core Graphics

Core Graphics 也是常用的框架之一,相信开发者们都不陌生。它用于运行时绘制图像。开发者们可以通过 Core Graphics 绘制路径、颜色。当开发者需要在运行时创建图像时,可以使用 Core Graphics 去绘制。与之相对的是运行前创建图像,例如用 Photoshop 提前做好图片素材直接导入应用。相比之下,我们更需要 Core Graphics 去在运行时实时计算、绘制一系列图像帧来实现动画。

Core Image

Core Image 与 Core Graphics 恰恰相反,Core Image 用于在运行时创建图像,而 Core Image 是用来处理已经创建的图像的。Core Image 框架拥有一系列现成的图像过滤器,能对已存在的图像进行高效的处理。

它的优点在于十分高效。大部分情况下,它会在 GPU 中完成工作,但如果 GPU 忙,会使用 CPU 进行处理。如果设备支持 Metal,那么会使用 Metal 处理。这些操作会在底层完成,Apple 的工程师们已经帮助开发者们完成这些操作了。

Core Image 的过滤器支持管道式串联,在下文中会有一个具体的使用 Core Image 框架的例子。

SceneKit & SpriteKit

普通开发者可能会对 SceneKit 和 SpriteKit 感到陌生,SceneKit 主要用于某些 3D 场景需求,而 SpriteKit 更多的用于游戏开发。SceneKit 和 SpriteKit 都包含了粒子系统、物理引擎等,看上去似乎是专门为游戏准备的,但事实上在普通应用中开发者们一样也可以使用它们来完成一些比较炫酷的特效和物理模拟。

Metal

Metal 存在于以上渲染框架的最底层。Metal 对于大多数开发者来说都比较神秘,大多数开发者都没有直接使用过 Metal,但其实所有开发者都在间接地使用 Metal。Core Animation、Core Image、SceneKit、SpriteKit等等渲染框架都是构建于 Metal 之上的。

细心的开发者会发现,当在真机上调试 OpenGL 程序时,控制台会打印出启用 Metal 的日志。根据这点,笔者猜测,Apple 已经实现了一套机制将 OpenGL 命令无缝桥接到 Metal 上,由 Metal 担任真正于硬件交互的工作。

小结

以上是本文中提到的渲染框架。正如上文所说,开发者们并不一定要对这些框架样样精通,真正的目的是让开发者在遇到具体某个需求时能想起来似乎有某个框架是专门做这个任务的,能有个大致的方向。

下面有一些具体的例子能更形象地展现这些框架的功能。

一些例子

这里将有一些简单的 Demo,代码可以从 这里 下载。例子使用 Swift 4 编写,因此需要最新的 Xcode 9 编译。

UIKit Customization

此范例将UISlider的滑轨替换成了自定义的图片,效果如下图所示:

自定义图片经过trackImage方法,被重新渲染成了目标大小的图片:

func trackImage(_ image: UIImage, width: CGFloat, resizingMode: UIImageResizingMode) -> UIImage {
    let capInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
    let bounds = CGRect(x: 0, y: 0, width: width, height: image.size.height)
    let renderer = UIGraphicsImageRenderer(bounds: bounds)
    return renderer.image { _ in
        image.resizableImage(withCapInsets: capInsets, resizingMode: .stretch).draw(in: bounds)
    }.resizableImage(withCapInsets: capInsets, resizingMode: resizingMode)
}

之后通过设置UISlider的以下方法修改滑轨图片。

tintSlider.setMinimumTrackImage(tintMinTrackImage, for: .normal)
tintSlider.setMaximumTrackImage(tintMaxTrackImage, for: .normal)
temperatureSlider.setMinimumTrackImage(temperatureMinTrackImage, for: .normal)
temperatureSlider.setMaximumTrackImage(temperatureMaxTrackImage, for: .normal)

这是一个抛砖引玉的范例,我们接着看。

Core Image

此范例将通过 Core Image,对图片进行实时滤镜处理。

上文中提到,Core Image 拥有大量线程的图像过滤器用于图像处理,过滤器称为CIFilter。但事实上 CIFilter 并不是真正处理任务的对象,而真正处理任务的对象为CIContext。CIContext 是底层硬件渲染的入口,因此在使用 Core Image 框架时,必须先要创建它。

let ciContext = CIContext()

Core Image 框架接收的图像为CIImage对象,它可以通过UIImageCGImage去构建。

let originalCIImage = CIImage(cgImage: originalCGImage)

关于 CIFilter,构造方法一定是值得吐槽的一点。它通过接收一个字符串类型的 name 参数和一个参数字典来构造对象,使得它无法脱离文档使用,期待在将来, CIFilter 的构造上能有进一步的改进。

public /*not inherited*/ init?(name: String, withInputParameters params: [String : Any]?)
public /*not inherited*/ init?(name: String)

范例使用一个名为CITemperatureAndTint的过滤器,其参数接收一个CIImage原始图像和两个CIVector二维向量参数,它的两个维度是色温和色彩。通过界面上的滚动条,用户可以任意更改其中一个向量,以达到调整色彩的作用。

let neutralTemperature = CGFloat(temperatureSlider.value)
let neutralTint = CGFloat(tintSlider.value)
let neutral = CIVector(x: neutralTemperature, y: neutralTint)

let targetNeutralTemperature = CGFloat(6500)
let targetNeutralTint = CGFloat(0)
let targetNeutral = CIVector(x: targetNeutralTemperature, y: targetNeutralTint)

let parameters = [ "inputImage": originalCIImage,
                   "inputNeutral": neutral,
                   "inputTargetNeutral": targetNeutral ]

let filter = CIFilter(name: "CITemperatureAndTint", withInputParameters: parameters)

准备好了这一切以后,就可以进行真正的图像处理了:

let filteredImage = filter.outputImage
// CIContext 创建 CGImage 的过程会比较耗时,可以在放在子线程执行。
let filteredCGImage = self.ciContext.createCGImage(filteredImage, from: filteredImage.extent)
self.imageView.image = UIImage(cgImage: filteredCGImage)

以上就是 Core Image 的图像滤镜处理流程。

由于图像处理是一项耗时的操作,如果你想你的应用对图像操作响应如飞,请参考接下来的Core Graphics

Core Graphics

此范例将在运行时绘制一个张图像。

在去年 iOS 10 发布时,UIGraphicsImageRenderer诞生了,它能帮助我们更方便地使用绘图 API。

let renderer = UIGraphicsImageRenderer(size: size)

UIGraphicsImageRenderer 的 image 方法接收一个闭包,传入参数为UIGraphicsImageRendererContext。这就意味着, renderer 已经帮我们管理好了绘图上下文,我们只需在这个闭包内写入绘图内容即可。

let image = renderer.image { rendererContext in
    let cgContext = rendererContext.cgContext
    // 绘图
}

Core Graphics 的绘图十分形象。你可以想象手中有一根画笔,绘图时,调用CGContextmove方法将画笔移动到一个点,之后调用addLine方法画一条线到另一个点,就画好了一条线了。

public func move(to point: CGPoint)
public func addLine(to point: CGPoint)

对于范例中的五角星,我们只要画出封闭的五角星外轮廓,之后填充黄色即可。

let image = renderer.image { rendererContext in
    let cgContext = rendererContext.cgContext
    // 绘制轮廓
    ......
    //设置填充颜色
    cgContext.setFillColor(fillColor.cgColor)
    //填充
    cgContext.fillPath()
}

至此就是 Core Graphics 绘制图片的过程。

Core Animation

Core Animation 是本文的重点。在这里只是一个简单的例子,在下文中会有更深层的剖析和一个更加精彩的例子。

此范例将实现一个基于 Core Animation 的动画。点击中间的按钮,将会有一圈星星散开,逐渐变小,最后消失。

正如图中所示,点击按钮后会出现很多星星。其实每一个星星都是一个CALayer,通过控制每一个星星的动画,即可实现整体的效果。

获得其中的一个五角星图像:

let layer = CALayer()
// 设置五角星,通过上面例子中的 Core Graphics 绘制五角星。
layer.contents = ...
// 设置位置和大小
layer.position = ...
layer.bounds = ...
// 加入屏幕
button.layer.addSublayer(layer)

获得了目标 Layer 后,设置动画的最终变换状态:

// 设置终点位置
let finalPosition = ...
// 设置终点的平移量
var finalTransform = CATransform3DMakeTranslation(finalPosition.x - initialPosition.x, finalPosition.y - initialPosition.y, 0)
// 设置旋转,这里的 spinRadians 值是4π,转两圈。但由于转两圈后的状态和不转的状态是一模一样的,因此这里不会有任何旋转。
finalTransform = CATransform3DRotate(finalTransform, spinRadians, 0, 0, 1)
// 设置缩放
finalTransform = CATransform3DScale(finalTransform, finalScale, finalScale, finalScale)

完成了以上工作后,使用CABasicAnimation创建动画。

let animation = CABasicAnimation(keyPath: "transform")
// 当前变换状态
animation.fromValue = layer.transform
// 目标变换状态
animation.toValue = finalTransform
// 动画时长
animation.duration = animationDuration
// 动画时间函数,刚开始慢,最后变快。
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
layer.add(animation, forKey: "pew pew")
// 将变换设置为最终的状态,以防动画结束后回到原点。    
layer.transform = finalTransform

// 在动画结束后移除Layer
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + animationDuration, execute: {
    layer.removeFromSuperlayer()
})

这样,一个五角星的动画就设定好了。关于例子中防止状态回弹的代码,读者不妨思考一下,为什么要这样写?五角星已经移动过去了,为何状态会回弹?五角星是真的移动过去了还是看上去移动过去了?这些问题与 Core Animation 的图层结构有关,将在下文中探讨。

SpriteKit

此范例实现了一个简单的粒子效果。

top Created with Sketch.