CoreAnimation动画实战
# 08.CoreAnimation动画实战
深度篇 | 隐式动画vs显式动画、CABasicAnimation/CAKeyframeAnimation/CATransition、图层树与呈现树、性能调优。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 图层树与呈现树
- 4. 隐式动画机制
- 5. CABasicAnimation 插值引擎
- 6. CAKeyframeAnimation 关键帧系统
- 7. CASpringAnimation 物理弹簧
- 8. CATransition 转场系统
- 9. 性能调优与调试
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段反复横跳的直播间礼物动画
先看一段直播间的礼物流水灯代码。需求:同时飞入 10 个礼物图标,每个从屏幕底部弹入、中间位置放大闪烁 0.5 秒、再飞到右上角缩进消失。高峰期同时在线礼物动画多达 30 个,测试环境流畅,上线后 iOS 15 以下的设备大量反馈"动画卡成 PPT":
// GiftAnimationView.swift —— 直播间礼物动画
class GiftAnimationView: UIView {
private var giftLayers: [CALayer] = []
func showGift(image: UIImage, at index: Int) {
let layer = CALayer()
layer.contents = image.cgImage
layer.frame = CGRect(x: 40 + CGFloat(index) * 30, y: bounds.height, width: 50, height: 50)
self.layer.addSublayer(layer)
giftLayers.append(layer)
// 阶段 1:飞入
let flyIn = CABasicAnimation(keyPath: "position.y")
flyIn.fromValue = bounds.height + 25
flyIn.toValue = bounds.height / 2
flyIn.duration = 0.4
flyIn.timingFunction = CAMediaTimingFunction(name: .easeOut)
// 阶段 2:放大
let scale = CABasicAnimation(keyPath: "transform.scale")
scale.fromValue = 1.0
scale.toValue = 1.5
scale.duration = 0.25
scale.beginTime = CACurrentMediaTime() + 0.4
scale.autoreverses = true
// 阶段 3:飞出右上角
let flyOut = CABasicAnimation(keyPath: "position")
flyOut.fromValue = CGPoint(x: layer.position.x, y: bounds.height / 2)
flyOut.toValue = CGPoint(x: bounds.width - 20, y: 20)
flyOut.duration = 0.3
flyOut.beginTime = CACurrentMediaTime() + 0.9
// 阶段 4:缩进消失
let shrink = CABasicAnimation(keyPath: "transform.scale")
shrink.fromValue = 1.0
shrink.toValue = 0.0
shrink.duration = 0.2
shrink.beginTime = CACurrentMediaTime() + 1.2
// 拼成一个 CAAnimationGroup
let group = CAAnimationGroup()
group.animations = [flyIn, scale, flyOut, shrink]
group.duration = 1.4
group.delegate = self
layer.add(group, forKey: "giftGroup\(index)")
}
}
现象:
- iPhone 11 (iOS 14):10 个礼物同时播放,帧率从 60fps 跌到 22fps
- 更糟的是:动画结束后礼物图标回到初始位置一闪,然后再被手动移除——视觉上有明显的"回跳重影"
- Memory Graph 显示:30 个并发礼物动画期间,离屏渲染触发了 ~18MB 的额外 GPU 帧缓冲
- Instruments 显示每帧有约 6.5ms 花在 Core Animation 的图层合成上
# 1.2 顺藤摸到根因
假设 1:是不是 CAAnimationGroup 里时间计算有问题?—— 打印每个动画的 beginTime、duration,时间线完全正确。排除了时间问题。
假设 2:把 CAAnimationGroup 换成四个独立动画顺序执行呢?—— 回跳重影消失,但 10 个礼物并发仍然卡。说明 Group 的回跳是移除机制的问题,但卡顿是别的原因。
假设 3:是不是 30 个 CALayer 太多了?
// 统计:30 个 giftLayer + 每个 layer 的 contents 是 50×50@3× 的 CGImage
// = 150×150 pixels × 4 bytes × 30 layers × 3 frames(离屏缓冲)≈ 81MB
// 虽然 contents 只有一份 UIImage,但每个 CALayer.contents 持有对同一 CGImage 的引用
// GPU 合成时仍需为每层采样纹理
假设 4:把
transform.scale替换为bounds.size变化 + 直接更新layer.frame呢?—— 卡顿改善有限。说明瓶颈不在 scale 动画本身,而在图层合成阶段。假设 5:看
shouldRasterize:
// giftLayer 被 scale 放大到 1.5 倍 = 75×75pt @3× = 225×225 pixels
// 系统为每个 giftLayer 分配 225×225×4 = 202KB 的离屏缓冲
// 30 个 × 202KB = 6MB 离屏缓冲
// 每帧都要把这份缓冲采样回主帧缓冲
带着这些线索往下挖:
① CAAnimationGroup 动画结束后为什么会有"回跳重影"? → 第 5.3 节
② 为什么改了 modelLayer 的值就能消除回跳? → 第 3.2 节
③ shouldRasterize 对 scale 动画为什么是双刃剑? → 第 9.3 节
④ Core Animation 的"每秒 60 帧的插入值"是怎么算的? → 第 5.1 节
⑤ 如果把多个礼物合并为一个图层,能不能减少合成开销? → 第 9 章
⑥ UIView.animate 和 CABasicAnimation 底层有没有区别? → 第 4 章
⑦ CAMediaTiming 协议里的 beginTime 的绝对时间语义有什么坑? → 第 5.2 节
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号一路追到底,每讲完一段原理,就解开一两个;最后在第 10 章,给出三种动画优化方案和各自代价。
本篇路线:
图层三棵树:modelLayer / presentationLayer (第 3 章) ─→ 动画基础
↓
隐式动画机制:CATransaction 与 actionForKey (第 4 章) ─→ 默认动画
↓
CABasicAnimation:插值引擎与时间函数 (第 5 章) ─→ 显式动画
↓
CAKeyframeAnimation:关键帧路径 (第 6 章) ─→ 复杂动画
↓
CASpringAnimation:物理弹簧 (第 7 章) ─→ 真实感
↓
CATransition:转场动画系统 (第 8 章) ─→ 场景切换
↓
性能调优:合成、光栅化、内存 (第 9 章) ─→ 60fps
↓
综合案例:礼物动画最优方案 (第 10 章)
📌 本篇定位:这是 iOS 专栏的动画地基篇。前面几篇分别覆盖了渲染管线(drawRect → 像素)、布局引擎(约束 → frame),本篇覆盖从图层属性插值到 60fps 逐帧渲染的完整动画链路。读完本篇后,任何"动画为什么卡""动画结束为什么回跳""怎么让动画不卡 60fps"的问题都能从四层模型中找到根因。
# 2. 架构概览
# 2.1 动画系统四层架构
Core Animation 的动画系统从开发者调用的 API 到屏幕上的逐帧变化,整体分为四层:
┌─────────────────────────────────────────────────────────────────┐
│ 第 0 层:开发者 API │
│ UIView.animate / CABasicAnimation / CAKeyframeAnimation / │
│ CASpringAnimation / CATransition / CAAnimationGroup │
│ 职责:描述"什么属性、从哪到哪、多快、什么节奏" │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 1 层:CA::Transaction(事务与插值引擎) │
│ CATransaction 将一组属性变化打包为一个原子事务 │
│ Render Server 内部的插值引擎逐帧计算 presentationLayer 的值 │
│ CAMediaTiming 协议控制时间映射(duration / beginTime / speed) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 2 层:Render Server 逐帧渲染 │
│ backboardd 进程持有 presentationLayer 的完整树 │
│ 每一帧(VSync 16.67ms)计算 presentation 值 │
│ 将计算结果合成到帧缓冲 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 第 3 层:GPU 硬件输出 │
│ 帧缓冲 → 显示控制器 → 屏幕 │
│ 每 16.67ms 刷新一次 VSync 信号 │
└─────────────────────────────────────────────────────────────────┘
第 0~1 层是开发者的主战场。第 2~3 层由系统进程和硬件驱动,开发者只能间接优化(通过减少图层数、减少离屏渲染等方式)。
# 2.2 为什么这么分层
疑惑:为什么 Core Animation 需要独立的 "Render Server 进程"和"呈现树",而不是在主线程就直接算出每帧的 frame 然后丢给 GPU?
论证:
主线程不能被动画渲染阻塞——动画需要每 16.67ms 计算一次每个动画属性的当前值。如果在主线程做,任何一次业务代码卡顿(如数据解析、图片解码)都会导致动画跳帧。把逐帧计算移到独立进程(backboardd),让主线程只负责"提交动画描述",Render Server 负责"逐帧执行"——两者互不阻塞。
呈现树(presentation tree)是动画的实时状态——modelLayer 存的是"动画结束后的最终值"(或动画开始前的初始值)。在动画进行中,modelLayer 的值是不变的。用户触摸到的位置、hitTest 命中的位置,必须是当前正在呈现的值——这就是 presentationLayer 的职责。它不是 modelLayer 的副本,而是"当前帧的瞬时快照"。
事务(CATransaction)保证一致性——多个属性同时修改时(如 frame + alpha + cornerRadius),如果不打包为一个事务,可能出现"frame 已变化但 alpha 还是旧的"的中间状态。事务把一组属性变化原子化提交给 Render Server。
CAMediaTiming 协议的层次化时间——动画的时间不是全局钟表时间,而是分层级的:一个动画组有一个时间轴,组里的每个子动画有自己的 beginTime(相对于组的开始),再往下还有 speed、timeOffset 等参数遮盖。这种设计让"暂停整个动画组""调慢一组动画的速度"等操作成为可能。
反向验证:如果 Core Animation 没有独立进程和呈现树会怎样?参考早期 Android 2.x 的动画(完全在主线程计算属性值)——任何主线程耗时都会导致动画跳帧。iOS 的这个架构在 iPhone 4 时代(单核 800MHz)就能承受 60fps 动画,分层架构功不可没。
结论:四层架构把"描述动画(API)→ 逐帧计算(Render Server)→ 合成(GPU)→ 输出(硬件)"拆成独立阶段。在这个架构下,主线程只需要在动画开始时提交一次动画描述,之后的逐帧计算完全由系统在独立进程中完成——这是 Core Animation 能以 60fps 运行且不阻塞主线程的根本原因。
# 3. 图层树与呈现树
# 3.1 三棵树的本质
CALayer 在整个动画生命周期中同时存在于三棵树中:
三棵树的关系:
modelLayer 树(主线程持有) presentationLayer 树(Render Server 持有)
┌─────────────────────┐ ┌─────────────────────────────────┐
│ UIView → CALayer │ │ CALayer (presentationLayer) │
│ │ │ │
│ • frame = 最终值 │ ─copy→ │ • frame = 当前屏幕上的实际值 │
│ • 动画前后才变化 │ │ • 动画过程中每帧都在变化 │
│ • hitTest 来源 │ │ • 用户看到的、触摸到的值 │
│ • 数据模型 │ │ • 只读快照 │
└─────────────────────┘ └─────────────────────────────────┘
│ │
│ CATransaction::commit() │
│ ──────────────────────► │
│ 提交属性变化 + 动画描述 │
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Render Tree(GPU 渲染用) │
│ • 图层合成顺序树 │
│ • 每个节点的纹理引用 + 变换矩阵 │
│ • 离屏渲染缓冲 │
└─────────────────────────────────────────────────────┘
三棵树的分工:
| 树 | 谁持有 | 读/写 | 用途 |
|---|---|---|---|
| modelLayer 树 | App 进程(主线程) | 可读可写 | 存储动画的最终目标值,hitTest 的入口 |
| presentationLayer 树 | backboardd 进程 | 只读 | 每帧的瞬时中间值,用户看到的就是它 |
| Render Tree | backboardd + GPU | 只读(系统内部) | 合成到帧缓冲的实际数据 |
# 3.2 modelLayer 与 presentationLayer
核心规则:动画进行期间,layer.position(modelLayer)的值等于动画最终结束值或初始值。但 layer.presentation()?.position 才是用户当前看到的"真实位置"。
// 场景:一个 View 正在从左移到右
layer.position = CGPoint(x: 100, y: 200) // model value
let anim = CABasicAnimation(keyPath: "position")
anim.fromValue = CGPoint(x: 100, y: 200)
anim.toValue = CGPoint(x: 300, y: 200)
anim.duration = 2.0
layer.add(anim, forKey: "move")
// 动画开始后 0.5 秒:
print(layer.position) // (300, 200) ← model 值已经是终点!
print(layer.presentation()?.position ?? .zero) // (150, 200) ← 屏幕上的真实位置
model 值何时更新:
- 动画开始时:model 值立刻变为
toValue(默认行为) - 除非设置
fillMode = .forwards+isRemovedOnCompletion = false(见第 5.3 节)
这就是第 1 章"回跳重影"的根因:CAAnimationGroup 动画结束后,group.isRemovedOnCompletion = true(默认),系统移除动画对象 → layer.position 从 toValue 跳回原始的 model 值 → 视觉上的"回跳一闪"。
# 3.3 动画中的"当前真实值"陷阱
// ❌ 常见错误:在动画进行中读 layer.frame 当作当前值
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
let pt = gesture.location(in: self)
// 如果 self 正在动画(如从 A 移到 B),layer.frame 可能已经是 B 的坐标
if layer.frame.contains(pt) {
// ← 用户点的是 presentation 的当前值,但这里用 model 值判断
// → 可能导致"看起来点中了但代码认为没点中"
}
}
// ✅ 正确做法:用 presentationLayer
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
guard let pres = layer.presentation() else { return }
let pt = gesture.location(in: self)
if pres.frame.contains(pt) {
// 点中判断基于用户实际看到的位置
}
}
这一陷阱在最常见的场景中就会触发:拖拽一个正在动画的 View(如抽屉菜单从侧边滑入时用户用手指提前拖它)。
# 3.4 事务(CATransaction)的天平两端
CATransaction 是 Core Animation 把多次属性修改打包为一个原子提交的机制:
// 默认:每次属性修改都是一个独立的事务
layer.position = CGPoint(x: 100, y: 0) // 独立事务 ①
layer.opacity = 0.5 // 独立事务 ②
// → 两个隐式动画独立播放,时间不同步
// 显式事务:多条属性变化属于同一个动画
CATransaction.begin()
CATransaction.setAnimationDuration(0.3)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
layer.position = CGPoint(x: 100, y: 0) // 在事务内
layer.opacity = 0.5 // 在事务内
CATransaction.commit()
// → position 和 opacity 的变化被合并为一个协调的 0.3s 动画
事务的嵌套:每个 UIView.animate 块实际上就是在一个 CATransaction 中执行:
UIView.animate(withDuration: 0.3) {
view.center = newCenter // 底层:CATransaction.begin() → 修改 → commit()
view.alpha = 0.5
}
// UIView.animate 的实现(伪代码):
// CATransaction.begin()
// CATransaction.setAnimationDuration(0.3)
// CATransaction.setDisableActions(false) // 重新开启隐式动画
// closure()
// CATransaction.commit()
# 4. 隐式动画机制
# 4.1 默认0.25秒的魔术
当你直接修改 CALayer 的可动画属性时(不显式创建 CAAnimation),Core Animation 会自动生成一个默认动画:
// UIView 的动画块内部
UIView.animate(withDuration: 0.5) {
view.center = newCenter
}
// 底层等价于:
CATransaction.begin()
CATransaction.setAnimationDuration(0.5)
let anim = CABasicAnimation(keyPath: "position")
anim.fromValue = oldCenter
anim.toValue = newCenter
anim.duration = 0.5
layer.add(anim, forKey: "position") // 隐式动画
layer.position = newCenter // model 值立刻更新
CATransaction.commit()
关键认知:UIView.animate 不是"让 View 动起来"——它只是临时开启了一个 CATransaction,让后续的属性修改附带动画效果。没有这个事务包裹,直接写 view.center = newCenter 不会产生动画(因为 UIView 默认禁用了隐式动画)。
# 4.2 哪些属性有隐式动画
并非所有 CALayer 属性都有隐式动画。规则是:
有隐式动画的属性:
✅ 所有"可动画属性"(animatable properties)
✅ 在 UIView.animate 块内修改的任何 UIView 属性
✅ CALayer 的可动画属性在 CATransaction 内修改
没有隐式动画的属性:
❌ UIView 的 frame / bounds / center(在 UIView.animate 之外)
❌ CALayer 的 contents / mask / sublayers(结构性变化,不做插值)
可动画属性的完整清单(CALayer):
| 类别 | 属性 | 类型 | 隐式动画 |
|---|---|---|---|
| 位置/尺寸 | position, bounds, frame | CGPoint / CGRect | ✅ |
| 变换 | transform, sublayerTransform | CATransform3D | ✅ |
| 视觉 | opacity, backgroundColor, borderColor | Float / CGColor | ✅ |
| 裁剪 | cornerRadius, borderWidth | CGFloat | ✅ |
| 阴影 | shadowColor, shadowOpacity, shadowOffset, shadowRadius | 各种 | ✅ |
| 内容 | contents, contentsRect | CGImage / CGRect | ⚠️ 有限 |
| 结构 | sublayers, mask | 数组 / CALayer | ❌ 无 |
# 4.3 actionForKey 的查找链
当一句 layer.position = newValue 执行时,Core Animation 按以下顺序寻找"这个属性变化应该附带什么动画":
layer.position = newValue
│
▼
1. layer.delegate?.action(for: layer, forKey: "position") // delegate 优先
│ → 如果返回 NSNull → 无动画 → 直接更新 model 值
│ → 如果返回 CAAnimation → 使用该动画
│ → 如果返回 nil → 继续往下
│
▼
2. layer.actions?["position"] // actions 字典
│ → 如果返回 NSNull → 无动画
│ → 如果返回 CAAnimation → 使用该动画
│ → 如果没有这个 key → 继续往下
│
▼
3. layer.style?["position"] // style 字典(较少用)
│ → 同上
│
▼
4. CALayer.defaultAction(forKey: "position") // 默认动画
│ → 返回一个默认 CABasicAnimation(duration = 0.25,timing = easeInEaseOut)
│ → 如果 key 不在可动画列表中 → 返回 NSNull(无动画)
这就是 UIView 为什么没有隐式动画 的原因——UIView 作为 CALayer 的 delegate,在 action(for:forKey:) 中返回了 NSNull:
// UIView 内部实现的伪代码
func action(for layer: CALayer, forKey key: String) -> CAAction? {
// UIView 默认禁用其 backing layer 的所有隐式动画
guard UIView.areAnimationsEnabled else { return NSNull() }
// 如果当前在一个 UIView.animate 块内 → 从 CATransaction 获取动画参数
if let pendingAnimation = CATransaction.value(forKey: "animationDuration") {
// 构造相应的 CAAnimation 返回
}
return NSNull() // 不在动画块内 → 无动画
}
# 4.4 禁用隐式动画的三种姿势
// 姿势 1:CATransaction(推荐用于单次操作)
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.position = newPosition
layer.opacity = 0.5
CATransaction.commit()
// → 两个属性修改立即生效,无动画
// 姿势 2:UIView.performWithoutAnimation
UIView.performWithoutAnimation {
view.center = newCenter
view.alpha = 0.5
}
// → UIView 属性的修改无动画
// 姿势 3:直接操作 layer 时注意
// CALayer 在 UIView.animate 之外有隐式动画(因为 UIView 不在事务中)
// 所以直接写 layer.position = ... 会触发隐式 0.25s 动画
// 必须包裹在 CATransaction.setDisableActions(true) 中
# 5. CABasicAnimation 插值引擎
# 5.1 从 fromValue 到 toValue 的插值算法
CABasicAnimation 是 Core Animation 最基础的动画类型。它的职责是在 fromValue 和 toValue 之间,按比例(progress)计算每一帧的中间值:
插值公式(标量属性如 opacity):
currentValue = fromValue + (toValue - fromValue) × progress(t)
其中 progress(t) 由 timingFunction 计算,t = elapsedTime / duration
插值公式(CGPoint 属性如 position):
currentValue.x = fromValue.x + (toValue.x - fromValue.x) × progress(t)
currentValue.y = fromValue.y + (toValue.y - fromValue.y) × progress(t)
插值公式(CATransform3D):
对矩阵的每个元素做独立线性插值(16 个标量并行插值)
插值公式(CGColor):
在颜色空间中对每个分量独立插值
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = 1.0
anim.toValue = 0.0
anim.duration = 2.0
// Render Server 每帧计算(伪代码):
// frame 0 (0.0s): t=0.000, progress=0.000, opacity = 1.000
// frame 1 (0.017s): t=0.017, progress=0.068, opacity = 0.932
// frame 30 (0.5s): t=0.500, progress=0.500, opacity = 0.500
// frame 60 (1.0s): t=1.000, progress=1.000, opacity = 0.000
// ... 直到 duration 结束
三个值之间的优先级:
| 如果设置了 | 行为 |
|---|---|
只设 toValue | 从当前 model 值插值到 toValue |
只设 fromValue | 从 fromValue 插值到当前 model 值 |
设了 fromValue + toValue | 严格从 fromValue 插值到 toValue(忽略当前 model 值) |
只设 byValue | 从当前值变化 byValue |
| 都没设 | 使用上一个动画结束时的值 |
# 5.2 timingFunction 的四条贝塞尔曲线
CAMediaTimingFunction 控制动画的节奏——即 progress(t) 的形状。它用一条三次贝塞尔曲线在 [0,1]×[0,1] 的方框中定义:
CAMediaTimingFunction 的时间曲线(控制点 x1,y1,x2,y2 在 [0,1] 内):
progress(t)
1.0 ┤ ● (1,1)
│ ╱
│ ╱
│ ╱ ← easeIn(先慢后快)
│ ╱ 控制点: (0.42, 0.0, 1.0, 1.0)
│ ╱
0.5 ┤ ╱
│ ╱
│ ╱
│ ╱
0.0 ●──────────────────────────────► t
0.0 0.5 1.0
progress(t)
1.0 ┤ ●
│ ╲
│ ╲
│ ╲ ← easeOut(先快后慢)
│ ╲ 控制点: (0.0, 0.0, 0.58, 1.0)
│ ╲
0.5 ┤ ╲
│ ╲
│ ╲
│ ╲
0.0 ┤────────────────────●───────────► t
0.0 0.5 1.0
progress(t)
1.0 ┤ ● (1,1)
│ ╱
│ ╱
│ ╱ ← easeInEaseOut(两端慢中间快)
│ ╱ 控制点: (0.42, 0.0, 0.58, 1.0)
│ ╱ (也是 CALayer 默认隐式动画的节奏)
0.5 ┤ ╱
│╱
0.0 ●──────────────────────────────► t
0.0 0.5 1.0
四个预定义时间函数的细节:
| 函数名 | 控制点 (x1,y1,x2,y2) | 速度曲线 | 最佳场景 |
|---|---|---|---|
.linear | (0,0,1,1) | 恒定匀速 | 进度条、无限旋转 |
.easeIn | (0.42,0,1,1) | 慢→快 | 元素飞入画布 |
.easeOut | (0,0,0.58,1) | 快→慢 | 减速停止、弹出菜单 |
.easeInEaseOut | (0.42,0,0.58,1) | 慢→快→慢 | 大多数自然运动(默认) |
.default | 同 easeInEaseOut | 同上 | 默认 |
自定义时间函数:
// 回弹效果(控制点可超出 [0,1] 范围来模拟弹性)
let bounce = CAMediaTimingFunction(controlPoints: 0.5, 1.8, 0.8, 0.2)
// → progress 在回弹后超过 1.0,然后回落到 1.0
// → 视觉上就是"冲过头再弹回来"
# 5.3 fillMode 与 removedOnCompletion 的暗坑
这两个属性是 CAAnimation 中最容易出错的组合:
// 属性 1:isRemovedOnCompletion(默认 true)
// true → 动画结束后从 layer 上移除动画对象
// 移除后 layer 恢复为 model 值(或上一个动画的残余值)
// false → 动画结束后保留在 layer 上
// 但保留 ≠ 保持不变:如果不设 fillMode,视觉上仍可能回跳
// 属性 2:fillMode(默认 .removed)
// .removed → 动画不在活跃状态时不对 layer 施加影响
// .forwards → 动画结束后保持最后一个关键帧的值
// .backwards → 动画开始前(delay 期间)保持第一个关键帧的值
// .both → forwards + backwards
四种组合的效果表:
| isRemovedOnCompletion | fillMode | 动画结束后的视觉状态 |
|---|---|---|
| true | .removed | 回跳到 model 值(最常见的 bug!) |
| true | .forwards | 回跳到 model 值(fillMode 被 removed 覆盖) |
| false | .removed | 回跳到 model 值(未填充) |
| false | .forwards | 保持 toValue ✅ |
| false | .both | 保持 toValue ✅ |
最佳实践:
// ✅ 正确做法 1:设 fillMode + removedOnCompletion,同时更新 model 值
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = 1.0
anim.toValue = 0.0
anim.duration = 0.5
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
layer.opacity = 0.0 // ← 关键:同时更新 model 值!
layer.add(anim, forKey: "fadeOut")
// ✅ 正确做法 2:不设 fillMode,完全依赖 model 值
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = layer.presentation()?.opacity ?? layer.opacity
anim.toValue = 0.0
anim.duration = 0.5
layer.opacity = 0.0 // ← model 值更新到终点
layer.add(anim, forKey: "fadeOut")
// 动画结束后 layer.opacity 已经是 0.0,无需 fillMode
# 5.4 可动画属性的完整清单
Core Animation 对不同类型的属性有不同的插值策略:
| 属性类型 | keyPath 示例 | 插值策略 | 注意事项 |
|---|---|---|---|
| 标量 | opacity, cornerRadius, borderWidth | 线性插值 | 最简单的 |
| CGPoint | position, anchorPoint | 分量线性插值 | — |
| CGSize | bounds.size | 分量线性插值 | 如果是等比缩放建议用 transform.scale |
| CGRect | bounds, frame | 四个分量分别插值 | 注意:直接对 frame 做动画会导致子视图每帧重新布局 |
| CGFloat | zPosition, shadowOpacity | 线性插值 | — |
| CGColor | backgroundColor, borderColor | 颜色空间插值 | 不同颜色空间之间插值可能产生意外中间色 |
| CATransform3D | transform, sublayerTransform | 矩阵分量插值 | transform 的插值可能不直观——旋转 180° 可能绕反方向 |
| CGPath | shadowPath | 路径形状插值 | 仅 shapeLayer 的 path 属性有插值 |
| contents | contents | 无标准插值 | 通常用 CATransition 代替 |
transform 的专用 keyPath(比直接对整个 matrix 插值更可控):
// ✅ 推荐:用专用 keyPath 对一个维度做动画
"transform.rotation" // 旋转(等价于 transform.rotation.z)
"transform.rotation.x" // 绕 X 轴旋转
"transform.rotation.y" // 绕 Y 轴旋转
"transform.rotation.z" // 绕 Z 轴旋转(最常用)
"transform.scale" // 等比缩放
"transform.scale.x" // X 方向缩放
"transform.scale.y" // Y 方向缩放
"transform.translation" // 平移 X+Y
"transform.translation.x" // X 方向平移
"transform.translation.y" // Y 方向平移
// ❌ 避免:对整个 transform 做动画
"transform" // 对 4×4 矩阵的所有 16 个分量做独立插值
// → 可能导致意外的旋转方向或缩放路径
# 6. CAKeyframeAnimation 关键帧系统
# 6.1 values vs path:两种驱动方式
CAKeyframeAnimation 不是做 from-to 插值,而是在一个预定义的离散点序列之间做插值:
// 方式 1:values 数组
let anim = CAKeyframeAnimation(keyPath: "position")
anim.values = [
CGPoint(x: 0, y: 0),
CGPoint(x: 100, y: 0),
CGPoint(x: 100, y: 100),
CGPoint(x: 0, y: 100),
CGPoint(x: 0, y: 0)
]
anim.duration = 2.0
layer.add(anim, forKey: "squarePath")
// 方式 2:CGPath(适合连续路径如圆形、贝塞尔曲线)
let path = UIBezierPath(ovalIn: CGRect(x: 50, y: 50, width: 200, height: 200))
anim.path = path.cgPath
// → layer 沿着椭圆轨迹运动
values vs path 的差异:
| 维度 | values 数组 | CGPath |
|---|---|---|
| 插值方式 | 相邻两点间线性/样条插值 | 沿路径均匀采样 |
| 可控性 | 逐点精确控制 | 路径形状精确控制 |
| 适用场景 | 少数几个关键位置 | 连续曲线(圆形、贝塞尔) |
| rotationMode | 支持 | 支持(自动沿路径切线旋转) |
| calculationMode | 必须指定 | paced 自动均匀采样 |
# 6.2 keyTimes 与 timingFunctions 的逐段控制
keyTimes 让你控制每个关键帧在时间轴上的位置(以相对时间的比例表示):
anim.keyTimes = [0.0, 0.25, 0.5, 0.75, 1.0] // 默认均匀分布
// → 在总 duration 的 0%、25%、50%、75%、100% 处采样关键帧值
// 自定义时间分布:前两个关键帧之间走 60% 的时间,后两个密集地走 40%
anim.keyTimes = [0.0, 0.6, 0.8, 1.0]
// 对应 values = [P0, P1, P2, P3]
// → P0→P1 花了 60% 时间(慢),P1→P2 花了 20%(快),P2→P3 花了 20%(快)
timingFunctions 为每段区间指定插值节奏:
// values = [A, B, C, D] → 有三段:A→B, B→C, C→D
anim.timingFunctions = [
CAMediaTimingFunction(name: .easeIn), // A→B: 慢入
CAMediaTimingFunction(name: .linear), // B→C: 匀速
CAMediaTimingFunction(name: .easeOut) // C→D: 慢出
]
# 6.3 calculationMode 的五种模式
calculationMode 控制关键帧之间不发生过渡的中间值如何计算:
| 模式 | 行为 | 适合场景 |
|---|---|---|
.linear | 相邻值之间线性插值 | 颜色渐变速、透明度变化 |
.discrete | **不做插值!**直接跳到下一个值 | 幻灯片切换、精灵帧动画 |
.paced | 自动均匀分配 keyTimes,使速度恒定 | 沿 path 的匀速运动 |
.cubic | 三次样条插值(平滑通过每个点) | 自然运动路径 |
.cubicMonotonic | 单调三次样条(不会过冲) | 数据曲线(确保 monotonic) |
.cubicPaced | 结合 cubic 和 paced | path 上的平滑匀速运动 |
calculationMode 的视觉效果(5 个关键点):
.linear: ●━━●━━●━━●━━● (折线,拐角尖锐)
.discrete: ● ● ● ● ● (跳跃,无过渡)
.cubic: ●╱‾‾●‾‾╲●╱‾‾‾●‾‾‾╲● (平滑曲线,经过所有点)
.paced: 沿路径匀速移动,忽略 keyTimes
# 6.4 rotationMode 的自动定向
当 layer 沿路径运动时,rotationMode 控制图层是否自动旋转以对齐运动方向:
anim.path = circlePath
anim.rotationMode = .rotateAuto // 自动旋转,layer 的 X 轴对齐路径切线
// → 适合:飞机图标沿圆形轨迹飞,机头始终朝前
anim.rotationMode = .rotateAutoReverse // 自动旋转,但 Y 轴对齐切线(反转 90°)
// → 适合:某些图标的朝向约定不同时
注意:rotationMode 会覆盖你手动设置的 transform.rotation——两者不可兼得。如果需要同时旋转和沿路径运动,需要用 CAAnimationGroup 分别控制 position 和 transform.rotation。
# 7. CASpringAnimation 物理弹簧
# 7.1 阻尼振动方程
CASpringAnimation 模拟的是一个欠阻尼弹簧-质量系统的物理振动:
弹簧振子的运动方程(二阶线性常微分方程):
m·x″ + c·x′ + k·x = 0
其中:
m = mass(质量)
c = damping(阻尼系数)
k = stiffness(刚度)
重写为标准阻尼振动形式:
x″ + 2ζ·ω₀·x′ + ω₀²·x = 0
其中:
ω₀ = √(k/m) ← 无阻尼固有频率(角频率)
ζ = c / (2√(km)) ← 阻尼比
ζ < 1:欠阻尼(会振荡,iOS 的弹簧动画总是欠阻尼)
ζ = 1:临界阻尼(刚好无振荡,最快回到平衡点)
ζ > 1:过阻尼(缓慢逼近平衡点,无振荡)
iOS 弹簧动画的视觉特征:
x(t)
1.0 ┤●
│ ╲
│ ╲ ← 第一次过冲(overshoot)
│ ╲ ╱╲
│ ╲ ╱ ╲ ← 小幅度回弹
0.5 ┤ ╳
│ ╱ ╲ ╲
│ ╱ ╲ ╲___ ← 逐渐稳定在目标值
│ ╱ ╲
│ ╱ ╲
0.0 ┤●─────────────────────────────────────► t
0.0 1.0
← settlingDuration →
# 7.2 四个物理参数的含义
let spring = CASpringAnimation(keyPath: "position.y")
spring.mass = 1.0 // 质量:越大 → 惯性越大 → 过冲越剧烈
spring.stiffness = 100.0 // 刚度:越大 → 回弹越快 → 振荡频率越高
spring.damping = 10.0 // 阻尼:越大 → 振荡衰减越快 → 越少回弹次数
spring.initialVelocity = 0.0 // 初始速度(正值=与运动同向,负值=反向)
spring.fromValue = 0
spring.toValue = 300
spring.duration = spring.settlingDuration // 系统根据物理参数自动计算
四个参数的调参直觉:
| 你想实现的效果 | mass | stiffness | damping |
|---|---|---|---|
| 果冻弹跳(大幅度过冲,多次回弹) | ↑ 增大 | ↑ 增大 | ↓ 减小 |
| 稳重落地(少量过冲,快速稳定) | 1.0 | 150 | 15 |
| iOS 默认橡皮筋效果 | 1.0 | 100 | 10 |
| 临界阻尼(无回弹的最快停止) | 1.0 | 100 | 20+ |
| 弹簧门(快速到位,一次过冲) | 0.5 | 200 | 20 |
# 7.3 UIView 弹簧动画的底层实现
UIView.animate(withDuration:delay:usingSpringWithDamping:...) 底层就是 CASpringAnimation——但参数名有所不同:
// UIView 的弹簧动画 API
UIView.animate(withDuration: 0.5, delay: 0,
usingSpringWithDamping: 0.6, // ← damping(阻尼比 ζ)
initialSpringVelocity: 0.8, // ← initialVelocity
options: [], animations: {
view.center = newCenter
}, completion: nil)
// 底层等价转换(近似):
// damping → ζ = 0.6
// velocity → initialVelocity = 0.8
// duration → settlingDuration 由系统计算
// mass, stiffness 由系统内部设定(mass=1, stiffness≈100 为基础调整)
UIView 弹簧动画的关键差异:usingSpringWithDamping 是阻尼比 ζ(0~1),不是绝对阻尼系数 c。ζ 越接近 0,回弹越强;ζ = 1 是临界阻尼(无回弹)。
# 7.4 settlingDuration 的预计算
sollingDuration 是系统根据 mass、stiffness、damping 计算出的振动幅度衰减到可忽略不计(<0.1%)所需的时间:
// settlingDuration 的内部计算公式(工程近似):
// 当振幅衰减到 0.001(即 0.1%)时:
// settlingDuration ≈ -log(0.001) / (damping / (2 * mass))
// ≈ 6.9078 / (damping / (2 * mass))
// ≈ 13.8 * mass / damping
// 例子:mass=1, damping=10
// settlingDuration ≈ 13.8 / 10 ≈ 1.38 秒
实战建议:不要手动覆盖 spring.duration——系统计算的 settlingDuration 保证了动画的完整性。如果嫌太长,增大 damping 或减小 mass。
# 8. CATransition 转场系统
# 8.1 type 与 subtype 的矩阵
CATransition 是专为"两个 CALayer contents 之间的切换"设计的动画:
let transition = CATransition()
transition.type = .push // 推入/推出
transition.subtype = .fromRight // 从右侧推入
transition.duration = 0.3
transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
layer.add(transition, forKey: "transition")
// 在 transition 添加到 layer 后,更换 layer.contents 就会触发转场动画
layer.contents = newImage.cgImage
type × subtype 的效果矩阵:
| type | 视觉 | 可用 subtype |
|---|---|---|
.fade | 内容交叉淡入淡出 | 无(不分方向) |
.push | 新内容把旧内容推出屏幕 | .fromLeft, .fromRight, .fromTop, .fromBottom |
.moveIn | 新内容覆盖到旧内容上方 | 同上 |
.reveal | 旧内容移开露出新内容 | 同上 |
"cube" | 3D 立方体翻转 | 同上 |
"suckEffect" | 旧内容被吸入一个点(macOS 独有) | 无 |
"oglFlip" | 整页翻转(仅水平方向) | .fromLeft, .fromRight |
"rippleEffect" | 水波纹扩散(已不推荐) | 无 |
"pageCurl" | 翻页效果 | 需要额外参数 |
"pageUnCurl" | 反向翻页 | 同上 |
⚠️ 带引号的 type(如
"cube"、"oglFlip")是私有 API,App 中可以用但在 App Store 审核中可能被拒。
# 8.2 自定义转场过滤器
CATransition 支持 filter 属性——这是 Core Image 的 CIFilter,可以创造独特的转场效果:
let transition = CATransition()
transition.type = .fade
transition.duration = 1.0
// 使用 Core Image 的转场滤镜
let filter = CIFilter(name: "CIDissolveTransition")!
filter.setValue(0.0, forKey: "inputTime")
transition.filter = filter
layer.add(transition, forKey: nil)
layer.contents = newImage.cgImage
可用的 CIFilter 转场包括 CIDissolveTransition, CIPageCurlTransition, CIBarsSwipeTransition, CICopyMachineTransition 等约 20 种——但性能因较复杂而更重,不建议在列表中使用。
# 8.3 startProgress 与 endProgress 的区间动画
CATransition 允许你只在动画的中间一段区间内发生转场:
transition.startProgress = 0.2 // 动画前 20% 什么都不发生
transition.endProgress = 0.8 // 后 20% 也保持静止
// → 转场只在时间的 20%~80% 之间发生
这个特性在组合动画中特别有用——多个 layer 在同一时刻开始转场,但各走不同的区间,形成交错的节奏。
# 8.4 CATransition 与 UIViewController 转场的异同
CATransition 的特点:
• 仅在同一个 layer 内做 contents 切换
• 不能做到交互式(不可中断、不可手势驱动)
• 不需要实现 UIViewControllerAnimatedTransitioning 协议
• 适合简单场景:图片切换、Label 文本替换
UIViewController 自定义转场:
• 两个完整的 ViewController 之间的场景切换
• 支持交互式转场(UIPercentDrivenInteractiveTransition)
• 需要实现完整协议栈
• 适合:Push/Pop、Present/Dismiss 自定义动画
// CATransition 用在 NavigationController 上(全局 push 动画替换)
navigationController?.view.layer.add(cubeTransition, forKey: nil)
navigationController?.pushViewController(vc, animated: false)
// → 整个 push 动画被 cube 翻页效果取代
# 9. 性能调优与调试
# 9.1 动画瓶颈的三种典型症状
症状 1:动画卡顿 → 帧率不稳(30~45fps 波动)
根因:每帧合成时间 > 16.67ms
定位:Instruments → Core Animation → FPS
常见原因:
├── 图层数过多(>100 个活跃层)
├── 离屏渲染触发(cornerRadius + masksToBounds)
├── 大图缩放(每帧重新采样 1024×1024 纹理)
└── shouldRasterize 在高频更新中反复失效
症状 2:动画结束回跳 → model 值未更新
根因:removedOnCompletion = true + 未同步 model 值
定位:看第 5.3 节的四种组合表
症状 3:动画内存飙升 → GPU 纹理缓存爆满
根因:大量 shouldRasterize 的离屏缓冲 + 大 contents 图像
定位:Instruments → Allocations → VM Tracker → IOAccelerator
# 9.2 CADisplayLink 与动画帧率
CADisplayLink 是一个与屏幕刷新率同步的定时器——每帧触发一次,是逐帧动画的首选:
class FrameAnimator {
private var displayLink: CADisplayLink?
private var startTime: CFTimeInterval = 0
private var duration: CFTimeInterval = 0
func start(duration: CFTimeInterval, update: @escaping (CGFloat) -> Void) {
self.duration = duration
startTime = CACurrentMediaTime()
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func tick() {
let elapsed = CACurrentMediaTime() - startTime
let progress = min(CGFloat(elapsed / duration), 1.0)
// 在此计算当前帧的动画参数
update(progress)
if progress >= 1.0 {
displayLink?.invalidate()
}
}
}
CADisplayLink vs CAAnimation 的选型:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 标准属性动画(opacity, position, scale) | CAAnimation | 不占用主线程 |
| 自定义逐帧计算(如物理模拟、粒子) | CADisplayLink | 需要每帧执行自定义逻辑 |
| 吸附/拖拽实时跟随 | CADisplayLink + 手势 | 手势回调不在 Render Server |
| 高性能动画列表 | CAAnimation | 主线程不参与逐帧计算 |
# 9.3 shouldRasterize 在动画中的双刃剑效应
回到第 1 章的礼物动画:shouldRasterize = true 在动画中到底是加速还是拖累?
shouldRasterize 在动画中的正面效应:
当 layer 内容不变、无子 layer 变化:
系统一次性光栅化 → 后续帧直接采样缓存纹理 → 省去每帧的离屏合成
shouldRasterize 在动画中的负面效应:
当 layer 在做 scale/transform 动画:
每帧 scale 值不同 → 缓存纹理在 1/60 秒后失效 → 下一帧重新光栅化
→ "缓存→失效→重建→缓存→失效" 循环
→ 比不缓存更慢!
决策规则:
| 场景 | shouldRasterize | 原因 |
|---|---|---|
| 静态复杂层(阴影+圆角+子层) | ✅ 开 | 真正缓存一次性光栅化 |
| scale/transform 动画中的层 | ❌ 关 | 每帧缓存失效,越帮越忙 |
| 仅有 opacity 动画的复杂层 | ✅ 开 | 缓存只在 alpha 变化时仍有效 |
| 列表 Cell(频繁重用) | ❌ 关 | 重用导致 "缓存→丢弃→重建" 反复 |
| 超过屏幕 2.5% 面积的大层 | ❌ 关 | 系统自动丢弃大缓存 |
# 9.4 动画内存剖析
// 每个动画对象的轻量数据(CAAnimation 对象本身):
CABasicAnimation 对象 ~200 字节 (fromValue + toValue + metadata)
CAKeyframeAnimation 50 个点 ~1.2 KB (50 × 2 × CGFloat × 2 + header)
CASpringAnimation ~300 字节
// 重量数据(shouldRasterize 的离屏缓冲):
离屏缓冲 100×100pt @3× ≈ 351 KB (300×300×4)
离屏缓冲 200×200pt @3× ≈ 1.4 MB (600×600×4)
// contents 纹理(UIImage → CGImage → GPU 纹理):
50×50pt 图标 @3× ≈ 88 KB (150×150×4)
200×200pt 头像 @3× ≈ 1.4 MB (600×600×4)
第 1 章礼物动画的内存快照:
30 个 giftLayer:
动画对象:30 × 200B = 6 KB ← 可忽略
contents 纹理:30 × 88KB = 2.6 MB ← 中等(都引用同一份)
shouldRasterize 离屏缓冲(如果有):
30 × 202KB(75×75pt @3×,被 scale 1.5× 放大后)
= 6 MB ← 大!
GPU 帧缓冲中的合成中间结果:
~8 MB ← 额外
总计:~16.6 MB GPU 相关内存在一次动画中
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的直播间礼物动画,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① CAAnimationGroup 结束后为什么会回跳? | 第 5.3:默认 isRemovedOnCompletion = true + fillMode = .removed → 动画对象被移除,layer 属性回到 model 值 |
| ② 为什么改 model 值能消除回跳? | 第 3.2:在动画开始前/同时把 model 值设为 toValue,动画结束时 model 值已是终点 |
| ③ shouldRasterize 对 scale 动画为什么是双刃剑? | 第 9.3:scale 动画每帧值变化 → 光栅化缓存每帧失效 → 反复重建 > 缓存收益 |
| ④ 每帧的插值是怎么算的? | 第 5.1:Render Server 按 timingFunction 算 progress(t),fromValue + (toValue-fromValue)×progress(t) |
| ⑤ 合并礼物为单图层能减少开销吗? | 第 9 章:可以——如果所有图标同时做相同动画,用 CAReplicatorLayer 副本机制 |
| ⑥ UIView.animate 和 CABasicAnimation 底层区别? | 第 4.1:UIView.animate 只是开启一个 CATransaction 包裹,底层仍是 CAAnimation |
| ⑦ beginTime 的绝对时间坑? | CACurrentMediaTime() 是绝对时间戳,CAAnimationGroup 内的 beginTime 是相对于 group 开始时间——混用导致动画不播放 |
三种优化方案:
方案 A:关闭 shouldRasterize + 同步 model 值(最小改动)
// 关键改动
layer.shouldRasterize = false // ← 去掉离屏缓存
layer.opacity = 0.0 // ← 动画结束后 model 值设为目标
// 消除回跳
group.fillMode = .forwards
group.isRemovedOnCompletion = false
收益:帧率从 22fps 恢复到 ~42fps,回跳消除。代价:离屏渲染开销解除,但 30 个独立 layer 的合成开销仍存。
方案 B:CAReplicatorLayer 替代 30 个独立 layer(推荐)
// CAReplicatorLayer 自动复制子 layer,每个副本可以有独立的 transform 偏移
let replicator = CAReplicatorLayer()
replicator.instanceCount = 10
replicator.instanceDelay = 0.05 // 每个副本延迟 0.05s → 涟漪效果
replicator.instanceTransform = CATransform3DMakeTranslation(0, -60, 0)
let gift = CALayer()
gift.contents = giftImage.cgImage
gift.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
replicator.addSublayer(gift)
// 只需给 gift 添加一次动画,所有副本自动复制动画
let anim = CABasicAnimation(keyPath: "position.y")
anim.fromValue = bounds.height
anim.toValue = bounds.height / 2
anim.duration = 0.4
gift.add(anim, forKey: "fly")
self.layer.addSublayer(replicator)
收益:图层数从 30 降到 1,合成开销降 30 倍,帧率恢复到 58~60fps。代价:所有图标动画必须相同,无法独立控制每个图标的方向/速度。
方案 C:逐个管理 + 对象池(灵活性最高)
class GiftAnimationPool {
private var pool: [CALayer] = []
private let maxActive = 8 // 同时最多 8 个动画
func play(gift: Gift) {
if activeCount >= maxActive {
enqueue(gift) // 超过上限时排队
return
}
let layer = pool.popLast() ?? createLayer()
configure(layer, with: gift)
}
}
收益:帧率保持 55fps+,同时支持独立动画控制。代价:开发复杂度最高。
最终推荐:方案 B(CAReplicatorLayer)用于批量相同动画的礼物;方案 A 用于偶尔出现的特殊礼物(如豪华礼物需要独立动画路径)。
# 10.2 一次动画的全生命周期
把"一个 View 从 A 移动到 B,时长 0.3s"的全过程串成一棵树:
UIView.animate(withDuration: 0.3) { view.center = B }
│
├─ 主线程(第 0ms)
│ ├─ CATransaction.begin() → 开启事务
│ ├─ CATransaction.setAnimationDuration(0.3)
│ ├─ 修改 layer.position = B ← model 值立即变为 B
│ ├─ CATransaction 内部生成 CABasicAnimation(keyPath: "position")
│ │ ├─ fromValue = presentation 的当前值(A)
│ │ └─ toValue = B
│ ├─ layer.add(animation, forKey: "position")
│ └─ CATransaction.commit()
│ ─── 第 4 章
│
├─ 事务提交到 Render Server(第 0~1ms)
│ └─ 动画描述序列化后通过 mach port 发送到 backboardd
│ ─── 第 2 章
│
├─ Render Server 开始执行动画(第 1~300ms)
│ ├─ 每 16.67ms(60fps):
│ │ ├─ 计算 progress(t) = timingFunction(t/duration)
│ │ │ └─ t = CACurrentMediaTime() - beginTime ─── 第 5.1
│ │ ├─ 插值:position = A + (B-A) × progress(t)
│ │ ├─ 更新 presentationLayer.position
│ │ ├─ 检查是否有 shouldRasterize 缓存可供复用 ─── 第 9.3
│ │ └─ 合成到当前帧缓冲
│ └─ 最后一帧:progress(0.3) = 1.0
│ ├─ position = B(到达终点)
│ ├─ 移除动画对象(如果 isRemovedOnCompletion=true)
│ └─ presentationLayer.position = modelLayer.position = B
│ ─── 第 3 章
│
└─ GPU 输出(持续进行)
├─ 显示控制器扫描帧缓冲
└─ 屏幕逐行刷新 → 用户看到平滑移动
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:分离关注——主线程只管"描述",Render Server 负责"执行"
Core Animation 最重要的架构决策:把动画的逐帧计算从主线程剥离到独立进程。主线程只需要在 CATransaction.commit() 时刻提交一次动画描述(fromValue, toValue, duration, timingFunction),之后的 18 帧(0.3s × 60fps)全部在 backboardd 中计算,主线程完全空闲。这是 iOS 动画系统以 60fps 流畅运行且不影响业务逻辑的根基。
哲学 2:呈现与模型分离——presentationLayer 是"现在",modelLayer 是"最终"
任何进行中的动画,用户看到的永远是 presentationLayer,开发者读写属性拿到的是 modelLayer。这层分离让"动画中也能读当前值(用于 hitTest 等)"成为可能,也让"动画开始前就确定了结束后的目标值"成为可能。代价是开发者必须时刻意识到这一分离——它是回跳重影、hitTest 偏差等绝大多数动画 bug 的根因。
哲学 3:时间分层——beginTime 是相对于父时间轴的偏移
CAMediaTiming 协议让时间成为一个可嵌套的概念:世界时间 → 组的开始时间 → 子动画的 beginTime(相对于组)。speed、timeOffset、repeatCount 在每个层级独立生效。这让"暂停整组动画""加速组内一个子动画"等操作只需改一个属性——如果时间是全局扁平化的,这些操作需要遍历所有动画逐一修改。
哲学 4:声明式动画优先于命令式——不让开发者管每一帧
CAAnimation 是一个声明式系统:你声明"opacity 从 1 变成 0,花 0.5 秒,用 easeOut 节奏",不是"在第 1 帧设 opacity=0.98,第 2 帧设 0.95,……第 30 帧设 0.00"。声明式让 Core Animation 有空间做全局优化(如合并同节奏动画、跳过不可见 View 的帧计算),也把开发者从帧级管理的繁琐中解放出来。只有在确实需要帧级控制时(物理模拟、自定义缓动算法),才降级到 CADisplayLink 命令式方案。
# 10.4 动画速查表
| 场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 简单属性过渡(opacity, position, scale) | CABasicAnimation 或 UIView.animate | 优先用 UIView.animate(代码更少) |
| 沿路径运动 | CAKeyframeAnimation + path | 配合 rotationMode = .rotateAuto 自动转向 |
| 复杂关键帧(多个离散位置) | CAKeyframeAnimation + values 数组 | 注意 keyTimes 与 timingFunctions 的配对数 |
| 弹性/回弹效果 | CASpringAnimation 或 UIView 弹簧动画 | 用 settlingDuration 不要手动设 duration |
| 图层内容切换(图片/文本替换) | CATransition | 用在 push/pop 时注意私有 type |
| 批量重复动画(如礼物刷屏) | CAReplicatorLayer | 所有副本共享同一个动画 |
| 逐帧自定义计算(物理模拟) | CADisplayLink | 注意在 invalidate 时释放 |
| 动画组合(多个属性同时动画) | CAAnimationGroup | 同步 model 值来消除回跳 |
| 动画结束不留痕迹 | 设 model 值为终点 + 不依赖 fillMode | 第 5.3 节的最佳实践 |
| 高性能列表中的动画 | CAAnimation(非 CADisplayLink) | 不占用主线程 |
动画问题三问:
1. 动画结束后是否回跳了?
→ 检查 isRemovedOnCompletion + fillMode + model 值是否更新
2. 动画是否卡顿(<60fps)?
→ Instruments Core Animation FPS → 查离屏渲染 + 图层数 + shouldRasterize
3. 动画中的 hitTest 是否不准?
→ 是否读了 modelLayer 而不是 presentationLayer 的 frame/position
CALayer 可动画 keyPath 速查:
位置/尺寸: position, position.x, position.y, bounds, bounds.size, bounds.size.width,
bounds.size.height, anchorPoint, anchorPoint.z
变换: transform, transform.rotation(.x/.y/.z), transform.scale(.x/.y),
transform.translation(.x/.y/.z)
视觉: opacity, backgroundColor, borderColor, borderWidth,
cornerRadius, shadowColor, shadowOpacity, shadowOffset, shadowRadius
内容: contents, contentsRect
下一篇:理解了 Core Animation 的图层动画引擎和性能调优,下一步进入 09.ViewController容器管理 (opens new window)——把子控制器嵌入、生命周期传递、转场协调的完整原理打通。