文章目录
-
- SnipTrip 简介
- 问题现象
- 问题根源分析
-
- 1. 多层离屏渲染
- 2. 动态参数每帧变化
- 3. 多个光晕组件同时渲染
- 4. 动画与光晕竞争 GPU 资源
- 1. 多层离屏渲染
- 解决方案
-
- Plan A: 在托盘动画期间暂停光晕 ✅ (已实施)
-
- 设计思路
* 实现细节
* 实施效果
* 技术细节说明
- 设计思路
-
- Plan A: 在托盘动画期间暂停光晕 ✅ (已实施)
- 备选方案
-
- Plan B: 使用 drawingGroup() 进行光晕栅格化
-
- 设计思路
* 实现方式
* 优缺点分析
- 设计思路
- Plan C: 优化贴纸按压动画时序
-
- 设计思路
* 实现方式
* 优缺点分析
- 设计思路
- Plan D: 降低光晕刷新率
-
- 设计思路
* 实现方式
* 优缺点分析
- 设计思路
-
- Plan B: 使用 drawingGroup() 进行光晕栅格化
- 性能测试结果
-
- 测试方法
- 测试结果
- 设备兼容性
- 测试方法
- 实施建议
-
- 推荐实施顺序
- 何时需要实施其他方案?
- 推荐实施顺序
- 技术总结
-
- 核心原则
- 关键经验
- 开发建议
- 核心原则
- 相关信息
- 结语
SnipTrip 简介
SnipTrip 是一款精致的 iOS 贴纸拼贴应用,专注于为用户提供流畅、优雅的创作体验。应用采用了大量现代 UI 设计语言,包括类似 Apple Intelligence 风格的动态光晕效果、液态玻璃质感的界面元素,以及细腻的交互反馈。

左:深色模式下的彩虹光晕流动效果 | 右:浅色模式下的柔和光晕呼吸效果
在 SnipTrip 中,光晕动画是视觉体验的重要组成部分。这些光晕会持续流动和呼吸,为界面增添生命力。然而,在实际使用中发现了一个性能问题:当托盘展开/收起时,动画会出现明显的卡顿。
本文记录了这个性能问题的分析过程,以及最终采用的优化方案。
问题现象
在测试过程中发现,当用户点击托盘展开/收起按钮时,动画会出现明显的掉帧和卡顿。这个问题虽然不影响功能,但严重影响了用户体验,让原本流畅的交互变得"不够丝滑"。
预期行为:
- 托盘展开/收起动画流畅,保持 60fps
- 光晕效果持续运行,不影响动画性能
实际行为:
- 托盘动画期间出现明显掉帧
- 动画速度不均匀,有"卡顿感"
- 在低端设备上尤其明显
问题根源分析
通过 Xcode Instruments 的 Core Animation 工具分析,发现了以下性能瓶颈:
1. 多层离屏渲染
光晕效果的实现使用了三层 blur + blendMode(.plusLighter) 叠加:
1// 外层光晕 2shape.stroke(gradient, lineWidth: lineWidth + 4) 3 .blur(radius: 12) 4 .opacity(breathe * 0.45) 5 .blendMode(.plusLighter) 6 7// 中层光晕 8shape.stroke(gradient, lineWidth: lineWidth + 2) 9 .blur(radius: 7) 10 .opacity(breathe * 0.65) 11 .blendMode(.plusLighter) 12 13// 内层光晕 14shape.stroke(gradient, lineWidth: lineWidth) 15 .blur(radius: 3) 16 .opacity(breathe * 0.85) 17 .blendMode(.plusLighter) 18
每一层都会触发离屏渲染,GPU 需要为每层创建独立的纹理缓冲区。
2. 动态参数每帧变化
光晕的渐变参数每帧都在变化:
1let breathe = 0.88 + (sin(time * 2.513) * 0.12) // 呼吸效果 2let rotation = Angle(degrees: time * 80) // 旋转效果 3let hueDrift = Angle(degrees: time * 12) // 色相漂移 4
这意味着 GPU 无法缓存渲染结果,每帧都需要重新计算和合成。
3. 多个光晕组件同时渲染
SnipTrip 中有多个光晕组件同时运行:
- 画布边框的光晕
- 底部按钮的光晕(2个)
- 托盘的光晕
所有光晕同时运行时,GPU 负担很重。
4. 动画与光晕竞争 GPU 资源
托盘展开/收起使用了 SwiftUI 的 spring 动画,需要 GPU 进行插值计算和渲染。当动画和光晕同时运行时,GPU 资源不足,导致掉帧。
性能分析数据:
| 场景 | FPS | GPU 利用率 | 离屏渲染层数 |
|---|---|---|---|
| 静止状态 | 60 | ~30% | 12 层 |
| 托盘动画(光晕运行) | 45-50 | ~85% | 15 层 |
| 托盘动画(光晕暂停) | 58-60 | ~45% | 3 层 |
解决方案
经过分析,设计了四个优化方案。最终采用了 Plan A: 在托盘动画期间暂停光晕,成功消除了卡顿问题。
Plan A: 在托盘动画期间暂停光晕 ✅ (已实施)
设计思路
在托盘展开/收起动画期间暂停光晕,避免动画和光晕渲染同时竞争 GPU 资源。这是最简单、最直接的方案,且效果立竿见影。
实现细节
1. 添加托盘动画状态追踪
在 ContentView.swift 中添加新的状态变量:
1@State private var isTrayAnimating = false 2
2. 修改光晕控制逻辑
将 isTrayAnimating 加入到 shouldAnimateGlow 的判断中:
1private var shouldAnimateGlow: Bool { 2 guard !reduceMotion else { return false } 3 let isPressingControls = isPressingAddButton || isPressingExportButton || isPressingAssetTrayToggle 4 let isInteracting = isManipulating || isPressingControls || isAssetTrayScrolling || isTrayAnimating 5 return !isInteracting 6} 7
3. 在托盘状态变化时设置动画标志
1.onChange(of: isAssetTrayExpanded) { _, newValue in 2 isTrayAnimating = true 3 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { 4 isTrayAnimating = false 5 } 6 if !newValue { 7 isAssetTrayScrolling = false 8 isPressingAssetTrayToggle = false 9 } 10} 11
实施效果
✅ 托盘动画流畅度显著提升
- FPS 从 45-50 提升到 58-60
- GPU 利用率从 ~85% 降低到 ~45%
- 离屏渲染层数从 15 层减少到 3 层
✅ 用户体验改善明显
- 托盘展开/收起动画丝滑流畅
- 低端设备上也能保持良好性能
- 光晕在动画结束后无缝恢复,无跳变
✅ 代码改动最小
- 只需修改一个文件
- 新增代码不到 10 行
- 不影响现有功能
技术细节说明
为什么选择 0.4 秒的延迟?
托盘的展开/收起动画使用了 interactiveSpring(response: 0.35, dampingFraction: 0.8),实际动画时长约 0.35-0.4 秒。这里选择 0.4 秒的延迟,确保动画完全结束后再恢复光晕。
为什么不使用动画完成回调?
SwiftUI 的动画没有提供标准的完成回调机制。使用 DispatchQueue.main.asyncAfter 是最简单可靠的方案,且 0.4 秒的固定延迟在实际使用中完全够用。
备选方案
除了 Plan A,还设计了其他三个优化方案。这些方案可以根据实际需求选择性实施,或作为 Plan A 的补充。
Plan B: 使用 drawingGroup() 进行光晕栅格化
设计思路
使用 .drawingGroup() 将光晕组件栅格化为单个纹理,减少合成层数。
实现方式
在 ModernUI.swift 中修改光晕组件:
1var body: some View { 2 if reduceMotion { 3 glowStroke(at: 0) 4 } else { 5 glowStroke(at: effectiveTime) 6 .drawingGroup() // 添加栅格化 7 .onAppear { 8 if !isAnimating { 9 frozenTime = glowClock.time 10 } 11 } 12 .onChange(of: isAnimating) { _, newValue in 13 frozenTime = newValue ? nil : glowClock.time 14 } 15 } 16} 17
优缺点分析
优点:
- 减少离屏渲染次数
- 提升合成性能
缺点:
- 占用额外内存用于缓冲区
- 如果光晕尺寸很大,内存压力增加
- 需要监控内存使用情况
适用场景:
- 光晕尺寸较小的场景
- 内存充足的设备
- 需要进一步优化性能时
Plan C: 优化贴纸按压动画时序
设计思路
确保贴纸按压时光晕暂停发生在动画开始之前,避免短暂的卡顿。
实现方式
1. 优化手势状态变化回调
在 A4CanvasView.swift 中:
1.onChange(of: isInteracting) { oldValue, isNowInteracting in 2 guard isInteractive else { return } 3 // 确保状态变化立即传递 4 if isNowInteracting != oldValue { 5 onInteractionChanged?(sticker.id, isNowInteracting) 6 wasInteracting = isNowInteracting 7 } 8} 9
2. 确保状态更新不使用动画包装
在 ContentView.swift 中:
1private func handleFocusModeChanged(_ isActive: Bool) { 2 // 立即更新状态,不等待动画 3 isManipulating = isActive 4 5 // 动画只用于 UI 过渡效果 6 let animation: Animation = reduceMotion 7 ? .linear(duration: 0.01) 8 : .interactiveSpring(response: 0.35, dampingFraction: 0.8) 9 withAnimation(animation) { 10 // 其他需要动画的 UI 状态 11 } 12} 13
优缺点分析
优点:
- 消除贴纸按压时的短暂卡顿
- 提升交互响应速度
缺点:
- 需要仔细处理状态更新时序
- 代码复杂度略有增加
适用场景:
- 贴纸交互频繁的场景
- 需要极致流畅体验时
Plan D: 降低光晕刷新率
设计思路
在托盘动画或其他交互期间降低光晕刷新率(从 60fps 降到 30fps),而不是完全暂停。
实现方式
1. 在 GlowClock 中添加低功耗模式
1@MainActor 2final class GlowClock: ObservableObject { 3 @Published private(set) var time: TimeInterval = 0 4 5 private var displayLink: CADisplayLink? 6 private var lastTick: CFTimeInterval? 7 private var isLowPowerMode = false 8 9 func setLowPowerMode(_ enabled: Bool) { 10 guard isLowPowerMode != enabled else { return } 11 isLowPowerMode = enabled 12 displayLink?.preferredFrameRateRange = enabled 13 ? CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30) 14 : CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) 15 } 16 17 // ... 其他代码 18} 19
2. 在托盘动画期间启用低功耗模式
1.onChange(of: isAssetTrayExpanded) { _, newValue in 2 glowClock.setLowPowerMode(true) 3 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { 4 glowClock.setLowPowerMode(false) 5 } 6 // ... 其他代码 7} 8
优缺点分析
优点:
- 保持光晕的视觉连续性
- 减少 GPU 负担的同时不完全停止动画
缺点:
- 实现复杂度较高
- 需要维护帧率切换逻辑
- 效果不如完全暂停明显
适用场景:
- 需要保持光晕持续运行的场景
- 作为 Plan A 的替代方案
性能测试结果
实施 Plan A 后,进行了全面的性能测试:
测试方法
- Instruments - Core Animation
- 监控 FPS 稳定性
- 检查 Renderer Utilization
- 分析离屏渲染情况
- Xcode Debug Options
- 启用 Color Offscreen-Rendered
- 确认离屏渲染区域减少
- 手动测试
- 反复点击托盘展开/收起(50次)
- 按住贴纸拖拽(100次)
- 在不同设备上测试
测试结果
| 指标 | 优化前 | 优化后 | 改善幅度 |
|---|---|---|---|
| 托盘动画 FPS | 45-50 | 58-60 | +20% |
| GPU 利用率 | ~85% | ~45% | -47% |
| 离屏渲染层数 | 15 层 | 3 层 | -80% |
| 用户感知卡顿 | 明显 | 无 | ✅ |
设备兼容性
| 设备 | 优化前 | 优化后 |
|---|---|---|
| iPhone 15 Pro | 轻微卡顿 | 完全流畅 |
| iPhone 13 | 明显卡顿 | 完全流畅 |
| iPhone 11 | 严重卡顿 | 完全流畅 |
实施建议
推荐实施顺序
- Plan A - 最简单,立竿见影 ✅ (已实施)
- Plan C - 小改动,优化贴纸交互
- Plan B - 中等工作量,进一步优化性能
- Plan D - 可选,作为 Plan A 的替代或补充
何时需要实施其他方案?
- Plan B: 当光晕数量增加,或需要在更低端设备上运行时
- Plan C: 当贴纸交互频繁,需要极致流畅体验时
- Plan D: 当需要保持光晕持续运行,不能完全暂停时
技术总结
核心原则
在处理复杂动画性能问题时,应该遵循以下原则:
优先选择最简单、最直接的方案。 复杂的优化往往带来更多的维护成本和潜在问题。
关键经验
- 性能分析先行: 使用 Instruments 准确定位瓶颈,避免盲目优化
- 分而治之: 将复杂动画拆分,在关键时刻暂停非必要动画
- 用户体验优先: 16ms 的延迟不会被察觉,但卡顿会立即被感知
- 渐进式优化: 先实施简单方案,必要时再叠加复杂优化
开发建议
在 SwiftUI 中实现复杂动画效果时:
- 使用 Instruments 持续监控性能
- 在低端设备上测试
- 为动画提供降级方案(如
reduceMotion) - 在关键交互时暂停非必要动画
- 避免过度使用 blur 和 blendMode
相关信息
- 实施提交:
29a102f - 主要文件:
SnipTrip/ContentView.swift - 影响组件: 托盘动画、光晕控制逻辑
- 相关文档:
结语
通过实施 Plan A,成功消除了托盘缩放时的卡顿问题,显著提升了 SnipTrip 的用户体验。这个案例再次证明:在性能优化中,简单的方案往往是最有效的方案。
在实际开发中,不需要一开始就追求完美的优化。先用最简单的方案解决主要问题,然后根据实际需求逐步优化,这才是高效的开发方式。
SnipTrip 的开发过程中,这类细节的打磨是提升用户体验的关键。一个流畅的动画,一个精准的交互,都能让用户感受到应用的"用心"。
《SwiftUI 光晕动画性能优化:消除托盘缩放卡顿的实战方案》 是转载文章,点击查看原文。