编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

    • README
    • ObjC编程入门

    • Swift编程入门

    • 专栏博客

      • iOS系统架构概览
      • Runtime消息转发机制
      • RunLoop运行循环揭秘
      • UIView生命周期与渲染
      • 事件响应与手势识别
      • AutoLayout布局系统
      • 自定义View绘制实践
      • CoreAnimation动画实战
        • 1. 案例引入
          • 1.1 一段反复横跳的直播间礼物动画
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 动画系统四层架构
          • 2.2 为什么这么分层
        • 3. 图层树与呈现树
          • 3.1 三棵树的本质
          • 3.2 modelLayer 与 presentationLayer
          • 3.3 动画中的"当前真实值"陷阱
          • 3.4 事务(CATransaction)的天平两端
        • 4. 隐式动画机制
          • 4.1 默认0.25秒的魔术
          • 4.2 哪些属性有隐式动画
          • 4.3 actionForKey 的查找链
          • 4.4 禁用隐式动画的三种姿势
        • 5. CABasicAnimation 插值引擎
          • 5.1 从 fromValue 到 toValue 的插值算法
          • 5.2 timingFunction 的四条贝塞尔曲线
          • 5.3 fillMode 与 removedOnCompletion 的暗坑
          • 5.4 可动画属性的完整清单
        • 6. CAKeyframeAnimation 关键帧系统
          • 6.1 values vs path:两种驱动方式
          • 6.2 keyTimes 与 timingFunctions 的逐段控制
          • 6.3 calculationMode 的五种模式
          • 6.4 rotationMode 的自动定向
        • 7. CASpringAnimation 物理弹簧
          • 7.1 阻尼振动方程
          • 7.2 四个物理参数的含义
          • 7.3 UIView 弹簧动画的底层实现
          • 7.4 settlingDuration 的预计算
        • 8. CATransition 转场系统
          • 8.1 type 与 subtype 的矩阵
          • 8.2 自定义转场过滤器
          • 8.3 startProgress 与 endProgress 的区间动画
          • 8.4 CATransition 与 UIViewController 转场的异同
        • 9. 性能调优与调试
          • 9.1 动画瓶颈的三种典型症状
          • 9.2 CADisplayLink 与动画帧率
          • 9.3 shouldRasterize 在动画中的双刃剑效应
          • 9.4 动画内存剖析
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次动画的全生命周期
          • 10.3 设计哲学回扣
          • 10.4 动画速查表
      • ViewController容器管理
      • 转场动画与导航设计
      • 网络层架构设计
      • 数据持久化方案选型
      • 多线程与GCD实践
      • 内存管理与ARC原理
      • Block底层原理与应用
      • KVO与KVC实现机制
      • 性能优化与启动加速
      • 组件化与模块设计
      • 安全加密与逆向防护
      • 跨平台与混编方案
  • Web开发和进阶

  • Linux应用开发

  • IoT智能硬件开发

  • Apps
  • iOS开发和进阶
  • 专栏博客
杨充
2026-06-20
目录

CoreAnimation动画实战

# 08.CoreAnimation动画实战

深度篇 | 隐式动画vs显式动画、CABasicAnimation/CAKeyframeAnimation/CATransition、图层树与呈现树、性能调优。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段反复横跳的直播间礼物动画
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 动画系统四层架构
    • 2.2 为什么这么分层
  • 3. 图层树与呈现树
    • 3.1 三棵树的本质
    • 3.2 modelLayer 与 presentationLayer
    • 3.3 动画中的"当前真实值"陷阱
    • 3.4 事务(CATransaction)的天平两端
  • 4. 隐式动画机制
    • 4.1 默认0.25秒的魔术
    • 4.2 哪些属性有隐式动画
    • 4.3 actionForKey 的查找链
    • 4.4 禁用隐式动画的三种姿势
  • 5. CABasicAnimation 插值引擎
    • 5.1 从 fromValue 到 toValue 的插值算法
    • 5.2 timingFunction 的四条贝塞尔曲线
    • 5.3 fillMode 与 removedOnCompletion 的暗坑
    • 5.4 可动画属性的完整清单
  • 6. CAKeyframeAnimation 关键帧系统
    • 6.1 values vs path:两种驱动方式
    • 6.2 keyTimes 与 timingFunctions 的逐段控制
    • 6.3 calculationMode 的五种模式
    • 6.4 rotationMode 的自动定向
  • 7. CASpringAnimation 物理弹簧
    • 7.1 阻尼振动方程
    • 7.2 四个物理参数的含义
    • 7.3 UIView 弹簧动画的底层实现
    • 7.4 settlingDuration 的预计算
  • 8. CATransition 转场系统
    • 8.1 type 与 subtype 的矩阵
    • 8.2 自定义转场过滤器
    • 8.3 startProgress 与 endProgress 的区间动画
    • 8.4 CATransition 与 UIViewController 转场的异同
  • 9. 性能调优与调试
    • 9.1 动画瓶颈的三种典型症状
    • 9.2 CADisplayLink 与动画帧率
    • 9.3 shouldRasterize 在动画中的双刃剑效应
    • 9.4 动画内存剖析
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次动画的全生命周期
    • 10.3 设计哲学回扣
    • 10.4 动画速查表

# 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?

论证:

  1. 主线程不能被动画渲染阻塞——动画需要每 16.67ms 计算一次每个动画属性的当前值。如果在主线程做,任何一次业务代码卡顿(如数据解析、图片解码)都会导致动画跳帧。把逐帧计算移到独立进程(backboardd),让主线程只负责"提交动画描述",Render Server 负责"逐帧执行"——两者互不阻塞。

  2. 呈现树(presentation tree)是动画的实时状态——modelLayer 存的是"动画结束后的最终值"(或动画开始前的初始值)。在动画进行中,modelLayer 的值是不变的。用户触摸到的位置、hitTest 命中的位置,必须是当前正在呈现的值——这就是 presentationLayer 的职责。它不是 modelLayer 的副本,而是"当前帧的瞬时快照"。

  3. 事务(CATransaction)保证一致性——多个属性同时修改时(如 frame + alpha + cornerRadius),如果不打包为一个事务,可能出现"frame 已变化但 alpha 还是旧的"的中间状态。事务把一组属性变化原子化提交给 Render Server。

  4. CAMediaTiming 协议的层次化时间——动画的时间不是全局钟表时间,而是分层级的:一个动画组有一个时间轴,组里的每个子动画有自己的 beginTime(相对于组的开始),再往下还有 speed、timeOffset 等参数遮盖。这种设计让"暂停整个动画组""调慢一组动画的速度"等操作成为可能。

  5. 反向验证:如果 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)——把子控制器嵌入、生命周期传递、转场协调的完整原理打通。

上次更新: 2026/06/28, 17:55:19
自定义View绘制实践
ViewController容器管理

← 自定义View绘制实践 ViewController容器管理→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式