Core Animation 与 SwiftUI 动画

从渲染树讲清 Core Animation 的工作机制,梳理 CALayer 与 UIView 的分工、事务与隐式动画、CAAnimation 显式动画、CADisplayLink 帧同步,再对照 SwiftUI 的动画模型讲 withAnimation、transition、matchedGeometryEffect 与自定义 Animatable,并给出掉帧与离屏渲染的排查方法。

开篇

动画在 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 负责绘制与动画。

关注点UIViewCALayer
布局Auto Layout / frameframe / 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,它由屏幕刷新率驱动(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 重新计算产生的属性差异用动画插值。它有四个关键约束:

  1. value 必须变化,否则动画不触发。省略 value 的旧版 .animation(_:) 在 iOS 15 后废弃,因为它会让整个子树被隐式动画污染。
  2. .animation 修饰符的作用范围是它「上游」的所有视图。放在 VStack 上会让整个 VStack 的属性变化都动,包括你不想要的部分。应该尽量靠近需要动的那个视图。
  3. 只有可插值的属性会动。frame、offset、opacity、scale、rotation、color 可插值;font 的族名变化、if/else 分支切换(不同泛型类型)不可插值。
  4. 布局变化与绘制变化走不同路径。改 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 } }
            }
        }
    }
}

三条规则:

  1. 同一时刻只能有一个视图持有某个 id。所以列表项要用 if selected?.id != photo.id 把自己排除掉,否则会「两个源」冲突,表现为跳变。
  2. 必须共享同一个 @Namespace,且两边的 id 类型一致。
  3. 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 + animatableDataShape/自定义类型的唯一入口

离屏渲染的取舍原则是:先测量再优化。不是所有离屏都掉帧——静态内容上的离屏渲染在渲染服务里有缓存,开销很小;真正的问题是「动画中每帧都触发离屏」。开 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 或预渲染。

相关阅读

小结

Core Animation 的核心机制可以浓缩成三句话:逻辑树立刻变、呈现树慢慢变、渲染树由系统合成。理解了这一点,「动画跳回」「命中区域不对」「连续动画叠加」这些问题的成因就都清楚了。事务是动画的边界,隐式动画是事务的副产物,显式动画是你对时序的精确控制,CADisplayLink 是逐帧驱动的唯一正确工具。

SwiftUI 侧的核心则是动画绑定在状态变化上。.animation(_:value:) 与 withAnimation 的差别只在作用范围,transition 管插入移除,matchedGeometryEffect 管跨视图形变,Animatable 管自定义插值。性能问题上,先分清是主线程忙还是 GPU 忙,离屏渲染只在「动画中每帧触发」时才真正致命。下一步建议把动画与手势、转场结合,处理「可中断动画」这类真实交互场景。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. iOS 安全:Keychain、生物识别与传输安全
  3. iOS 后台任务与 APNs 推送