一、底层核心:CATransaction 与 RunLoop 的协作
很多人以为调用 UIView.animate 后动画是立刻执行的,但实际上,Core Animation 的底层是基于事务(Transaction)的延迟提交机制。
1. 隐式事务与 RunLoop
当你修改一个 Layer 的可动画属性(如 layer.opacity = 0.5)时,系统会自动开启一个隐式事务(Implicit Transaction)。这个事务并不会立刻执行,而是被注册到当前线程的 RunLoop 中。
- 当 RunLoop 即将进入休眠(
kCFRunLoopBeforeWaiting)或退出时,所有未提交的隐式事务会被统一打包。 - 打包后的事务被序列化,通过 IPC(进程间通信)发送给 Render Server(一个独立的私有进程)。
- Render Server 接收到指令后,才真正驱动 GPU 开始渲染。
2. 显式事务(CATransaction)
如果你需要精细控制多个属性的动画时长或回调,必须使用显式事务:
CATransaction.begin()
CATransaction.setAnimationDuration(2.0)
CATransaction.setCompletionBlock {
print("动画彻底结束")
}
layer.opacity = 0.5
CATransaction.commit()
坑点:如果在显式事务中嵌套了隐式事务,内层事务的属性会覆盖外层。
二、三棵树模型与状态同步
Core Animation 内部维护了三棵树,这是理解所有动画状态异常的钥匙:
- 模型树(Model Tree / Logical Tree) :存储属性的最终目标值。你在代码里直接读取
layer.position拿到的永远是它。 - 呈现树(Presentation Tree):存储动画进行时的中间插值。它反映了 GPU 当前正在渲染的真实状态。
- 渲染树(Render Tree):Render Server 内部的树,负责实际的像素合成。
如何获取动画过程中的真实位置?
错误做法 :在 Timer 或 DisplayLink 中读取 layer.position。
正确做法 :必须通过 layer.presentation()?.position 获取。
底层原理 :presentation() 方法会向 Render Server 发起一次同步查询,获取当前帧的插值状态。如果在动画未开始时调用,返回 nil。
动画结束后的"回弹"问题
使用 CABasicAnimation 时,动画结束后图层会瞬间回到原位。
-
原因 :显式动画只作用于呈现树 ,动画结束后
removedOnCompletion默认为true,呈现树被移除,模型树的值从未改变,所以瞬间回弹。 -
标准解法 :在添加动画前,先修改模型树的值,再添加动画。
// 1. 先更新模型树
layer.position = CGPoint(x: 200, y: 200)// 2. 再添加显式动画
let anim = CABasicAnimation(keyPath: "position")
anim.fromValue = CGPoint(x: 0, y: 0) // 从旧位置开始
anim.toValue = CGPoint(x: 200, y: 200)
layer.add(anim, forKey: nil)
三、GPU 硬件级剖析:离屏渲染(Off-Screen Rendering)
为什么有些动画极其卡顿?因为触发了离屏渲染。
1. 什么是离屏渲染?
正常情况下,GPU 的渲染管线是:图元组装 -> 光栅化 -> 帧缓冲区(Frame Buffer) -> 屏幕。
当遇到无法在当前帧缓冲区直接合成的特效时,GPU 必须开辟一块新的临时内存缓冲区(Off-Screen Buffer) ,把特效画完后,再作为纹理贴回主帧缓冲区。这涉及昂贵的上下文切换(Context Switch) 和内存拷贝。
2. 触发离屏渲染的底层条件
- 圆角 + 裁剪 :同时设置
cornerRadius和masksToBounds = true(或clipsToBounds)。GPU 无法直接裁剪带圆角的矩形,必须借助 Mask。 - 阴影 :设置了
shadowColor/Offset/Opacity,但未设置shadowPath。GPU 必须对视图的整个 Alpha 通道进行实时卷积计算来生成阴影轮廓。 - 光栅化(shouldRasterize):将复杂图层缓存为位图。如果缓存的位图超过屏幕像素的 2.5 倍,或者图层内容频繁变化,反而会导致严重的内存暴涨和掉帧。
3. 终极优化方案
-
阴影优化 :永远手动指定
shadowPath。layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath -
圆角优化 :如果是纯色背景,直接设置
cornerRadius即可(不触发离屏渲染);如果是图片,使用 Core Graphics 在后台线程提前切好圆角再赋值。
四、高频代码级考点:Spring 动画与 UIViewPropertyAnimator
1. 弹簧动画的底层参数
UIView.animate(withDuration:delay:usingSpringWithDamping:...) 中的 damping(阻尼)和 initialSpringVelocity(初始速度)是如何工作的?
- 底层:UIKit 内部使用了一个简化的弹簧物理模型(类似 UISpringTimingParameters)。
- Damping Ratio:范围 0.0 到 1.0。1.0 表示临界阻尼(无回弹,平滑到达);小于 1.0 会产生过冲(Overshoot)和回弹。
- Initial Velocity:单位是 points/second。在手势驱动动画(如滑动返回)时,必须将手势的瞬时速度传入此参数,才能实现"跟手"效果。
2. UIViewPropertyAnimator 的核心优势
iOS 10 引入的 UIViewPropertyAnimator 解决了 UIView.animate 无法交互的痛点:
- 可逆与 Scrubbing :支持
fractionComplete属性,可以直接将动画进度与手势(如 UIPanGestureRecognizer)绑定,实现拖拽进度控制。 - 运行时修改 :可以在动画执行过程中动态修改
duration或timingParameters。 - 状态机管理 :内置了
isActive,isRunning,state等状态,避免了复杂的 completion block 嵌套和竞态条件。
五、如何排查动画卡顿?
当面试官问:"你的动画掉帧了,怎么排查?" 满分回答应包含以下链路:
- 使用 Instruments 的 Core Animation 模板 :
- 开启 Color Offscreen-Rendered Yellow:检查是否有大面积黄色(离屏渲染)。
- 开启 Color Blended Layers:检查是否有大面积红色(图层混合,通常是因为未设置背景色导致透明图层叠加)。
- 检查 CPU 瓶颈 :使用 Time Profiler,看是否在
layoutSubviews或draw(_:)中耗时过长。 - 检查 Render Server 耗时:如果 CPU 占用不高但依然掉帧,说明是 GPU 合成压力过大。检查是否创建了过多的 Layer(单屏 Layer 数量建议控制在 200 以内),或者是否有未优化的阴影/遮罩。
- 检查主线程阻塞:确认是否有图片解码、JSON 解析等耗时操作阻塞了主线程的 RunLoop,导致事务无法按时提交。