开篇
动画在 iOS 上看起来是「给属性赋值,系统自动补间」,实际上背后是一套跨越 App 进程与渲染进程的流水线:Core Animation 提交事务,渲染服务(Render Server)合成图层,GPU 逐帧绘制。理解这条流水线,才能解释两个最常见的现象——为什么改一个约束会让动画卡顿,以及为什么「加了圆角 + 阴影」的列表一滚动就掉帧。
SwiftUI 的动画又叠加了一层抽象。withAnimation 看起来只是包了一个闭包,但它背后是「状态变化 → 视图树 diff → 属性插值」的完整链路。很多人在 SwiftUI 里写动画时遇到的「不生效」「闪一下」「转场错位」,根因都不在 SwiftUI 层,而在 Core Animation 的语义上。
本文按「渲染树 → CALayer → 事务 → CAAnimation → 帧同步 → SwiftUI 动画模型 → 转场 → Animatable → 性能」的顺序展开,基于 iOS 17/18。结论先给:UIKit 侧关注「哪些属性可动画、哪些操作触发离屏渲染」,SwiftUI 侧关注「动画绑定在状态上、而不是绑定在视图上」。
一、Core Animation 的渲染树
一次屏幕刷新涉及三层树结构:
- 逻辑树(Layer Tree):你的
CALayer对象,App 进程持有,可以读写属性。 - 呈现树(Presentation Tree):动画进行中的中间状态,
layer.presentation()拿到的是它。它只读,反映了当前这一帧实际显示的值。 - 渲染树(Render Tree):渲染服务进程持有的私有结构,App 无法访问,是真正被 GPU 合成的数据。
关键认识:动画期间,逻辑树的值立刻变成终值,而屏幕显示的是呈现树的值。
let anim = CABasicAnimation(keyPath: "position.x")
anim.fromValue = 0
anim.toValue = 300
anim.duration = 2
layer.add(anim, forKey: "move")
layer.position.x = 300 // 逻辑树立刻是 300
print(layer.position.x) // 300
print(layer.presentation()?.position.x ?? 0) // 当前帧的真实值,如 87
这个「逻辑树立刻变、呈现树慢慢变」的机制解释了大量坑:
- 动画结束后视图跳回原位:没有设置
anim.isRemovedOnCompletion = false和anim.fillMode = .forwards,动画被移除后回落到逻辑树——而如果逻辑树的值是旧值,就会跳回去。 - 动画期间点击命中区域不对:
hitTest用的是逻辑树,不是呈现树。想要「跟着动画走的点击区域」,必须手动用presentation()做命中测试。 - 连续动画叠加时数值跳变:新动画从逻辑树的当前值开始,而不是从呈现树的值开始。
二、CALayer 与 UIView 的分工
UIView 是 CALayer 的包装。一个 UIView 默认持有一个 layer,负责布局、响应链、事件处理;CALayer 负责绘制与动画。
| 关注点 | UIView | CALayer |
|---|---|---|
| 布局 | Auto Layout / frame | frame / bounds / anchorPoint |
| 事件 | 响应链、手势 | 无事件能力 |
| 动画 | 隐式(属性变化) | 显式(CAAnimation) |
| 内容 | draw(_:) | contents / draw(in:) |
| 属性 | backgroundColor 等 | backgroundColor(CGColor) |
必须理解的是坐标模型。layer.position 是锚点(anchorPoint,默认 (0.5, 0.5))在父图层坐标系中的位置,而不是左上角。旋转、缩放都围绕锚点进行:
// 围绕左上角旋转
layer.anchorPoint = CGPoint(x: 0, y: 0)
layer.position = CGPoint(x: 0, y: 0) // anchorPoint 变了,position 要跟着调
改 anchorPoint 会改变 position 的语义,但不会自动调整 frame,导致视图「跳一下」。这是改锚点时的经典坑:改完要重新设置 position。
masksToBounds(UIView 的 clipsToBounds)与 cornerRadius 的组合决定了是否触发离屏渲染,后面会展开。
三、事务与隐式动画
Core Animation 的每次提交都发生在一个**事务(CATransaction)**里。你改变 layer 属性时,Core Animation 会把这次改变记录到当前事务,在事务提交时(通常是当前 runloop 结束时)一次性打包发给渲染服务。
隐式动画就是这个机制的副产物:当属性变化发生在事务内、且属性是「可动画属性」时,Core Animation 自动创建一个 0.25 秒的动画。
// 隐式动画:直接改属性就会动
layer.opacity = 0.3 // 自动 0.25 秒淡出
// 关掉隐式动画
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.opacity = 0.3 // 立刻生效,无动画
CATransaction.commit()
// 自定义隐式动画参数
CATransaction.begin()
CATransaction.setAnimationDuration(0.5)
CATransaction.setAnimationTimingFunction(.init(name: .easeInEaseOut))
layer.opacity = 0.3
CATransaction.commit()
在 UIView 层面,隐式动画被 UIView.areAnimationsEnabled 与 UIViewAnimationOptions 接管。这就是为什么 UIView.animate(withDuration:) 闭包里的 layer 属性变化会动、而闭包外的不会——本质上是事务的开启与关闭。
一个实用的技巧:UIView.performWithoutAnimation 与 CATransaction.setDisableActions(true) 等价,用在「批量更新时不想让每个属性都动」的场景。
CATransaction 还有一个高频用法是完成回调:
CATransaction.begin()
CATransaction.setCompletionBlock { print("动画结束") }
layer.opacity = 0
CATransaction.commit()
它比 CAAnimationDelegate 简洁,但只在隐式动画路径有效。
四、显式动画与 CAAnimation
需要精确控制时用 CAAnimation 的子类。三类最常用:
CABasicAnimation:单属性、起止值。
let anim = CABasicAnimation(keyPath: "transform.scale")
anim.fromValue = 1.0
anim.toValue = 1.2
anim.duration = 0.2
anim.autoreverses = true
anim.repeatCount = 1
anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
anim.isRemovedOnCompletion = false
anim.fillMode = .forwards
layer.add(anim, forKey: "pulse")
CAKeyframeAnimation:多关键帧,可带路径。
let anim = CAKeyframeAnimation(keyPath: "position")
anim.path = UIBezierPath(arcCenter: center, radius: 120,
startAngle: 0, endAngle: .pi * 2,
clockwise: true).cgPath
anim.duration = 2
anim.repeatCount = .infinity
anim.calculationMode = .paced
layer.add(anim, forKey: "orbit")
CAAnimationGroup:多个动画同步执行。
let group = CAAnimationGroup()
group.animations = [moveAnim, fadeAnim, scaleAnim]
group.duration = 0.4
group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
layer.add(group, forKey: "entrance")
关键属性 keyPath 是 KVC 路径,写错不会报错只是不生效,这是排查显式动画「没反应」的第一检查项。常用的 keyPath:
| keyPath | 作用 | 可否在 SwiftUI 中找到对应 |
|---|---|---|
position / position.x | 位移 | .offset |
opacity | 透明度 | .opacity |
transform.scale | 缩放 | .scaleEffect |
transform.rotation.z | 旋转 | .rotationEffect |
cornerRadius | 圆角 | .clipShape |
bounds.size | 尺寸 | .frame |
backgroundColor | 背景色 | .background |
strokeEnd | 描边进度 | 自定义 |
注意 transform.scale 这类复合 keyPath 的写法:transform 是 CATransform3D,用点号访问子字段时 Core Animation 会走特殊处理,但赋值时必须用 NSValue 包装 CGFloat 而不是 CATransform3D。
五、CADisplayLink 与帧同步
需要「每帧做点什么」时用 CADisplayLink,它由屏幕刷新率驱动(iPhone 13 Pro 起可达 120 Hz)。
final class Animator {
private var link: CADisplayLink?
private var startTime: CFTimeInterval = 0
func start() {
startTime = CACurrentMediaTime()
link = CADisplayLink(target: self, selector: #selector(step))
link?.preferredFrameRateRange = CAFrameRateRange(minimum: 30,
maximum: 120,
preferred: 120)
link?.add(to: .main, forMode: .common) // .common 才能在高亮滚动时继续
}
@objc private func step(_ link: CADisplayLink) {
let elapsed = link.targetTimestamp - startTime
let progress = min(elapsed / 2.0, 1.0) // 2 秒动画
// 用 progress 计算当前状态
if progress >= 1.0 { link.invalidate(); self.link = nil }
}
}
四个要点:
- 用
targetTimestamp而不是timestamp计算进度。targetTimestamp是这一帧实际会被显示的时刻,用它能让动画与屏幕严格同步。 - 加到
.common模式,否则用户滑动UIScrollView(tracking 模式)时动画会暂停。 - 必须
invalidate(),否则 display link 持有 target 造成泄漏,且永远在跑。 preferredFrameRateRange是 iOS 15+ 的 API,老的preferredFramesPerSecond在 ProMotion 设备上表现不一致。省电场景下把maximum降到 30 或 60 能显著降低功耗。
CADisplayLink 与 Timer 的关键差别:Timer 基于时间间隔,可能掉帧后追赶;display link 与垂直同步绑定,掉帧时直接跳过。做逐帧动画一律用 display link。
六、SwiftUI 的动画模型
SwiftUI 的动画不是「给视图加动画」,而是「让状态变化被插值」。理解这一点,SwiftUI 动画的大部分困惑都会消失。
struct ScaleCard: View {
@State private var expanded = false
var body: some View {
RoundedRectangle(cornerRadius: 12)
.frame(height: expanded ? 240 : 80)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: expanded)
}
}
.animation(_:value:) 的含义是:当 value 变化时,body 重新计算产生的属性差异用动画插值。它有四个关键约束:
value必须变化,否则动画不触发。省略value的旧版.animation(_:)在 iOS 15 后废弃,因为它会让整个子树被隐式动画污染。.animation修饰符的作用范围是它「上游」的所有视图。放在VStack上会让整个VStack的属性变化都动,包括你不想要的部分。应该尽量靠近需要动的那个视图。- 只有可插值的属性会动。
frame、offset、opacity、scale、rotation、color可插值;font的族名变化、if/else分支切换(不同泛型类型)不可插值。 - 布局变化与绘制变化走不同路径。改
frame会触发布局动画,改opacity只触发绘制,前者的成本高得多。
withAnimation 是命令式的等价物:
Button("展开") {
withAnimation(.easeInOut(duration: 0.3)) { expanded.toggle() }
}
它与 .animation(_:value:) 的区别是作用范围:withAnimation 覆盖这次状态变化引发的全部视图更新,.animation 只覆盖该修饰符上游的子树。一个实用的经验法则是:能用 .animation(_:value:) 就用它,作用范围更可控。
七、transition 与视图插入移除
transition 只在视图插入或移除视图树时生效,这与「属性变化」是两条完全不同的路径:
struct Toast: View {
@State private var visible = false
var body: some View {
VStack {
if visible {
Text("已保存")
.padding()
.background(.green.opacity(0.9), in: Capsule())
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .opacity))
}
Button("显示") { withAnimation { visible.toggle() } }
}
}
}
要点:
transition必须配withAnimation或.animation(_:value:),否则插入移除是瞬时的。.asymmetric可以给插入和移除配不同的效果,这是做「滑入淡出」这类不对称动画的标准手段。.combined(with:)组合多个转场,注意顺序影响语义(先移动还是先淡出)。if分支切换是视图的身份变化。SwiftUI 通过_ConditionalContent表达,if和else是不同类型,切换时旧视图被移除、新视图被插入,因此transition会生效,但onAppear/onDisappear也会触发。- 想要「同一个视图变形」而不是「移除再插入」,不要用
if,而是改属性(比如frame或scaleEffect)。
一个高频陷阱:在 List/ForEach 里用 transition 时,如果 id 不稳定(用了下标),转场会错位。这一点和 SwiftUI 声明式界面与状态管理
里讲的列表身份标识是同一个根因。
八、matchedGeometryEffect 转场
两个不同位置、不同尺寸的视图之间做「形变过渡」用 matchedGeometryEffect。典型场景是列表项 → 详情页的放大转场、缩略图 → 全屏预览。
struct GalleryView: View {
@Namespace private var ns
@State private var selected: Photo?
var body: some View {
ZStack {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 100))]) {
ForEach(photos) { photo in
if selected?.id != photo.id {
Image(photo.name)
.matchedGeometryEffect(id: photo.id, in: ns)
.onTapGesture {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
selected = photo
}
}
}
}
}
}
if let selected {
Image(selected.name)
.matchedGeometryEffect(id: selected.id, in: ns)
.ignoresSafeArea()
.onTapGesture { withAnimation { self.selected = nil } }
}
}
}
}
三条规则:
- 同一时刻只能有一个视图持有某个 id。所以列表项要用
if selected?.id != photo.id把自己排除掉,否则会「两个源」冲突,表现为跳变。 - 必须共享同一个
@Namespace,且两边的id类型一致。 isSource参数控制以谁为基准。默认两边都是 source,出问题时可以显式指定isSource: true/false。
matchedGeometryEffect 的实现依赖于「源视图」与「目标视图」的几何信息匹配,因此它不能跨 NavigationStack 的 push 转场(push 有自己的转场动画),只能用于同一视图层级内的形变。跨导航的形变要用 navigationTransition(.zoom)(iOS 18+)。
性能上要注意:matchedGeometryEffect 会在动画期间同时对两个视图做快照与插值,视图层级深时会明显掉帧。能简化就简化。
九、自定义 Animatable
需要让自定义类型参与插值时,实现 Animatable 协议:
struct WaveShape: Shape {
var progress: CGFloat // 0...1
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let amplitude = rect.height * 0.1 * sin(progress * .pi)
path.move(to: CGPoint(x: 0, y: rect.midY))
for x in stride(from: 0, through: rect.width, by: 2) {
let y = rect.midY + amplitude * sin((x / rect.width) * .pi * 2 + progress * .pi * 2)
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
// 使用
WaveShape(progress: animating ? 1 : 0)
.animation(.easeInOut(duration: 1), value: animating)
animatableData 是插值的入口:SwiftUI 会把 0 → 1 的中间值不断写入 progress,触发 path(in:) 重算。
多参数类型用 AnimatablePair 组合:
struct ProgressRing: Shape {
var progress: CGFloat
var thickness: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(progress, thickness) }
set { progress = newValue.first; thickness = newValue.second }
}
// ...
}
关键约束:animatableData 里改的值必须真的影响 path(in:) 的输出,否则动画看起来「没生效」。另外,自定义 Shape 的 path(in:) 每帧都会被调用,里面不能做昂贵计算,也不能有副作用。
VectorArithmetic 允许自定义更复杂的数据类型参与插值(比如自定义的 Transform 结构体),但绝大多数场景 CGFloat 和 AnimatablePair 就够了。
十、动画性能与离屏渲染
动画掉帧的原因几乎总能归到两类:主线程忙或GPU 忙。
主线程忙的典型来源:布局抖动(动画中改约束导致 layoutSubviews 每帧重跑)、body 里做计算、每帧重新创建视图。诊断方式是在 Instruments 的 Time Profiler 里看动画期间的调用栈,或在 Xcode 里开「Color Blended Layers」。
GPU 忙的典型来源是离屏渲染(offscreen rendering):GPU 需要先渲染到一个临时缓冲区再合成回主缓冲区,产生两次上下文切换。
常见触发条件:
| 操作 | 是否离屏 | 说明 |
|---|---|---|
cornerRadius(无 masksToBounds) | 否 | 纯背景色圆角不触发 |
cornerRadius + masksToBounds | 是 | 需要裁剪子内容 |
shadow(无 shadowPath) | 是 | 需要先算 alpha 通道 |
shadow + shadowPath | 否 | 显式路径可避免 |
mask | 是 | 需要单独渲染 mask |
group opacity | 是 | 且组内有多个图层 |
allowsGroupOpacity 相关 | 是 | 视具体实现 |
优化手段:
// 1. 圆角:用 background + cornerRadius 的 mask 替代
imageView.layer.cornerRadius = 12
imageView.layer.masksToBounds = true // 触发离屏
// 替代方案:预裁剪图片,或用 SwiftUI 的 clipShape(内部用 mask,仍可能离屏)
let renderer = UIGraphicsImageRenderer(size: size)
let rounded = renderer.image { ctx in
UIBezierPath(roundedRect: bounds, cornerRadius: 12).addClip()
image.draw(in: bounds)
}
// 2. 阴影:显式给 shadowPath,避免逐帧计算 alpha
view.layer.shadowPath = UIBezierPath(roundedRect: view.bounds,
cornerRadius: 12).cgPath
// 3. 栅格化:适合内容不变的复杂图层
view.layer.shouldRasterize = true
view.layer.rasterizationScale = UIScreen.main.scale // 必须设,否则模糊
shouldRasterize 是双刃剑:它把图层缓存成位图,适合「内容固定但合成复杂」的场景(如带阴影的静态卡片);如果图层内容每帧都在变(如动画中的文本),栅格化反而每帧都要重新生成缓存,性能更差。另外 rasterizationScale 不设会按 1x 缓存,在 Retina 屏上模糊。
SwiftUI 侧的对应优化:
- 避免在动画中改
frame,优先用.offset和.scaleEffect(走的是 transform,不触发布局)。 .drawingGroup()对应shouldRasterize,把子视图合成到离屏缓冲再渲染,适合复杂图形;但对含文本的视图会降低清晰度。.compositingGroup()用于让opacity作用于整组而不是每个子视图,避免「组内重叠处透出」的问题。- 滚动列表中的阴影与圆角是掉帧重灾区,能预渲染就预渲染。
更完整的帧率剖析流程(Core Animation 模板、Hitches、MetricKit 的掉帧指标)可以参考 iOS 性能调优与 Instruments 剖析 。Web 侧对同一问题的处理思路可以对照 前端动画与渲染性能 ,两边都在解决「合成层、重排重绘、帧预算」这同一组问题。
权衡取舍
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| UIKit 单属性动画 | UIView.animate | 语法最简,自动处理事务 |
| 需要精确控制时序 | CAAnimation | 可控制 timing、fillMode、重复 |
| 逐帧驱动 | CADisplayLink | 与垂直同步对齐,不掉帧追赶 |
| SwiftUI 属性动画 | .animation(_:value:) | 作用范围可控,就近生效 |
| SwiftUI 一次性触发 | withAnimation | 覆盖全部受影响视图 |
| 插入/移除 | transition | 与属性动画是不同路径 |
| 位置形变 | matchedGeometryEffect | 唯一能跨视图做形变的手段 |
| 自定义插值 | Animatable + animatableData | Shape/自定义类型的唯一入口 |
离屏渲染的取舍原则是:先测量再优化。不是所有离屏都掉帧——静态内容上的离屏渲染在渲染服务里有缓存,开销很小;真正的问题是「动画中每帧都触发离屏」。开 Instruments 的 Color Offscreen-Rendered Yellow 确认哪些图层在动,再决定是否优化。
常见坑清单
- 动画结束后视图跳回:
isRemovedOnCompletion默认为true,动画移除后回落到逻辑树。需要停在终值时设false+fillMode = .forwards。 - 改
anchorPoint后视图位移:改锚点不会自动调整position,要同步重设,否则图层会「跳一下」。 - 用
hitTest判断动画中的位置:命中测试走逻辑树,动画中要用presentation()拿真实位置。 .animation(_:)不带value:整个子树被隐式动画污染,iOS 15 后已废弃,必须用.animation(_:value:)。transition不生效:没有配withAnimation,或者视图是属性变化而不是插入移除。matchedGeometryEffect跳变:同一 id 同时被两个视图持有,或没共享@Namespace。CADisplayLink不释放:忘记invalidate(),target 被强引用,且定时器永远在跑。- display link 加到
.default模式:滚动时动画暂停,应该加.common。 shouldRasterize不设rasterizationScale:按 1x 缓存,Retina 屏上文字模糊;且内容每帧变化时反而更慢。- 给列表 cell 加无
shadowPath的阴影:滚动时每帧触发离屏渲染,掉帧明显,应显式给shadowPath或预渲染。
相关阅读
- SwiftUI 声明式界面与状态管理 — 动画绑定在状态变化上的前提是理解状态刷新机制
- UIKit 与 SwiftUI 混合开发 — 跨框架时动画与布局的归属划分
- iOS 性能调优与 Instruments 剖析 — Core Animation 模板、掉帧与启动耗时分析
- 前端动画与渲染性能 — 合成层与帧预算的跨平台对照
小结
Core Animation 的核心机制可以浓缩成三句话:逻辑树立刻变、呈现树慢慢变、渲染树由系统合成。理解了这一点,「动画跳回」「命中区域不对」「连续动画叠加」这些问题的成因就都清楚了。事务是动画的边界,隐式动画是事务的副产物,显式动画是你对时序的精确控制,CADisplayLink 是逐帧驱动的唯一正确工具。
SwiftUI 侧的核心则是动画绑定在状态变化上。.animation(_:value:) 与 withAnimation 的差别只在作用范围,transition 管插入移除,matchedGeometryEffect 管跨视图形变,Animatable 管自定义插值。性能问题上,先分清是主线程忙还是 GPU 忙,离屏渲染只在「动画中每帧触发」时才真正致命。下一步建议把动画与手势、转场结合,处理「可中断动画」这类真实交互场景。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。