Lottie动画双状态切换的渐进式优化实践
Contents
在移动应用中,双状态动画切换是最常见的交互模式之一:
- TabBar图标的聚焦/失焦状态
- 按钮的选中/未选中状态
- 开关的开启/关闭状态
当使用Lottie实现这类需求时,传统方案面临两大痛点:
- 启动阻塞:同步加载动画资源导致主线程卡顿
- 切换卡顿:状态变化时重复解析JSON文件
本文将揭示如何通过三次渐进式优化,构建高性能的双状态动画解决方案。
第一阶段:基础方案(同步阻塞模式)
原始实现方案
在初始实现中,我们直接在主线程同步加载动画资源。以下是代码实现:
class DualStateLottieView: UIView {
private var animationView: LottieAnimationView!
init(activePath: String, inactivePath: String) {
// 同步加载失焦状态动画(阻塞主线程)
animationView = LottieAnimationView(filePath: inactivePath)
super.init(frame: .zero)
addSubview(animationView)
}
func setActive(_ isActive: Bool) {
let path = isActive ? activePath : inactivePath
// 每次切换都重新加载(性能黑洞!)
animationView.removeFromSuperview()
animationView = LottieAnimationView(filePath: path)
addSubview(animationView)
animationView.play()
}
}
- 初始化动画视图:
- 在
init
方法中,我们直接通过LottieAnimationView(filePath:)
同步加载失焦状态的动画资源。 - 这种方式会阻塞主线程,直到动画资源加载完成。如果资源较大或网络延迟,会导致明显的卡顿。
- 在
- 状态切换逻辑:
- 在
setActive(_:)
方法中,根据传入的布尔值isActive
,选择对应的动画路径。 - 每次状态切换时,都会移除当前的
animationView
,重新创建一个新的LottieAnimationView
实例,并加载对应的动画资源。 - 这种方式不仅会导致主线程卡顿,还会频繁地创建和销毁视图对象,进一步增加性能开销。
- 在
执行流程分析
以下是状态切换的执行流程图:
sequenceDiagram
participant User
participant View
participant Lottie
User->>View: setActive(true)
View->>Lottie: removeFromSuperview()
View->>Lottie: 创建新实例(filePath)
Lottie->>文件系统: 读取JSON
文件系统-->>Lottie: JSON数据
Lottie->>JSON解析: 解析动画
JSON解析-->>Lottie: 动画模型
Lottie->>GPU: 渲染动画
GPU-->>User: 显示动画
性能瓶颈分析
graph TD
A[用户操作] --> B[移除旧动画]
B --> C[创建新实例]
C --> D[加载JSON文件]
D --> E[解析动画数据]
E --> F[播放动画]
通过分析可以得出以下几点性能瓶颈:
-
主线程阻塞:
-
在初始化和状态切换时,
LottieAnimationView(filePath:)
的调用会同步加载动画资源,这会阻塞主线程。 -
如果动画资源较大或加载路径较慢(如从网络加载),会导致明显的卡顿。
-
- 重复解析 JSON 文件:
- 每次状态切换时,都会重新加载和解析 JSON 文件。这不仅增加了 I/O 开销,还导致了不必要的重复计算。
-
资源加载与视图渲染强耦合:
-
动画资源的加载和视图的渲染紧密耦合,导致每次状态切换都需要重新加载资源并重新渲染视图。
-
这种方式在高频操作时会导致性能急剧下降,用户体验极差。
-
核心缺陷:资源加载与视图渲染强耦合,导致高频操作时性能急剧下降
第二阶段:异步加载与缓存(性能优化)
架构改造方案
为了优化性能,我们对代码进行了架构改造,引入了异步加载和缓存机制。以下是改造后的代码实现:
class DualStateLottieView: UIView {
// 动画数据缓存
private var activeAnimation: LottieAnimation?
private var inactiveAnimation: LottieAnimation?
// 视图实例
private let animationView = LottieAnimationView()
func loadResources() {
// 异步加载主动画
DispatchQueue.global().async {
let anim = LottieAnimation.filepath(activePath)
DispatchQueue.main.async {
self.activeAnimation = anim
}
}
// 异步加载被动画...
}
func setActive(_ isActive: Bool) {
animationView.animation = isActive ? activeAnimation : inactiveAnimation
animationView.play()
}
}
- 动画数据缓存:
- 引入了两个变量
activeAnimation
和inactiveAnimation
,分别用于缓存主动画和被动画的数据。 - 这样可以避免每次状态切换时重新加载和解析动画资源。
- 引入了两个变量
- 异步加载资源:
- 在
init
方法中,使用DispatchQueue.global().async
在后台线程中加载动画资源。 - 加载完成后,通过
DispatchQueue.main.async
将动画数据更新到主线程的缓存变量中。 - 这种方式将文件 I/O 和 JSON 解析操作移出主线程,避免了主线程的阻塞。
- 在
- 状态切换逻辑:
- 在
setActive(_:)
方法中,直接从缓存中获取对应的动画数据,并设置给animationView
。 - 这样可以快速切换动画状态,而无需重新加载资源。
- 在
性能优化点
- 主线程零阻塞:
- 初始化时仅创建轻量级的
animationView
容器视图,耗时小于 1ms,不会阻塞主线程。 - 动画资源的加载和解析都在后台线程完成,不会影响主线程的响应速度。
- 初始化时仅创建轻量级的
- 资源异步加载:
- 通过后台线程加载动画资源,避免了主线程的 I/O 操作和 JSON 解析,显著提升了性能。
- 动画数据复用:
- 使用
LottieAnimation
对象缓存动画数据,避免了重复解析 JSON 文件,减少了不必要的计算开销。
- 使用
但是这种方案并不完善,产生了新的问题。
新问题浮现
尽管引入了异步加载和缓存机制,但在测试中发现了一个新问题:
graph TD
A[用户] --> B[视图setActive-true]
B --> C[缓存: 请求主动画]
C --> D[视图: nil(尚未加载完成)]
D --> E[日志: 状态切换失败!]
测试发现:快速切换时出现状态丢失,动画不响应,这是为什么呢?——状态切换失败:
- 当用户快速切换状态时,可能会出现动画数据尚未加载完成的情况。
- 例如,用户调用
setActive(true)
时,activeAnimation
可能还没有加载完成,导致animationView.animation
被设置为nil
,动画无法正常播放。
通过引入异步加载和缓存机制,我们显著提升了动画切换的性能,消除了主线程的阻塞问题。然而,快速切换时的状态丢失问题仍然需要进一步优化。下一阶段将通过状态机和 Pending 机制来解决这一问题。
第三阶段:状态机与Pending机制(健壮性增强)
状态机设计
为了处理动画加载和状态切换的时序问题,我们引入了状态机和Pending机制。以下是状态机的设计:
enum AnimationState {
case active
case inactive
case pendingActive // 新增中间状态
case pendingInactive
}
private var currentState: AnimationState = .inactive
- 状态定义:
active
:当前显示主动画。inactive
:当前显示被动画。pendingActive
:正在加载主动画,但尚未完成。pendingInactive
:正在加载被动画,但尚未完成。
- 状态管理:
- 通过
currentState
变量记录当前的状态,确保状态切换的逻辑清晰且可控。
- 通过
Pending机制实现
func setActive(_ isActive: Bool) {
let targetState: AnimationState = isActive ? .active : .inactive
switch (targetState, activeAnimation, inactiveAnimation) {
case (.active, let anim?, _):
play(animation: anim) // 立即执行
case (.active, nil, _):
currentState = .pendingActive // 挂起请求
// 其他状态处理...
}
}
// 动画加载完成回调
private func handleActiveLoaded() {
if case .pendingActive = currentState {
play(animation: activeAnimation!)
currentState = .active
}
}
- 状态切换逻辑:
- 在
setActive(_:)
方法中,根据目标状态和当前缓存的动画数据,决定是否立即播放动画或进入挂起状态。 - 如果目标动画已经加载完成(
activeAnimation
或inactiveAnimation
不为nil
),则直接播放动画。 - 如果目标动画尚未加载完成,则将当前状态设置为
pendingActive
或pendingInactive
,并等待加载完成。
- 在
- 加载完成回调:
- 在动画加载完成的回调方法中(
handleActiveLoaded()
和handleInactiveLoaded()
),检查当前状态是否为挂起状态。 - 如果是挂起状态,则立即播放对应的动画,并将状态更新为目标状态。
- 在动画加载完成的回调方法中(
生命周期兜底
为了确保视图在挂载时能够正确处理挂起状态,我们在 didMoveToWindow
方法中添加了生命周期兜底逻辑:
override func didMoveToWindow() {
super.didMoveToWindow()
guard window != nil else { return }
// 检查并执行挂起操作
switch currentState {
case .pendingActive where activeAnimation != nil:
play(animation: activeAnimation!)
currentState = .active
// 其他状态处理...
}
}
- 在
didMoveToWindow
方法中,检查视图是否已经挂载到窗口(window != nil
)。 - 如果视图已经挂载,且当前状态为挂起状态(
pendingActive
或pendingInactive
),则检查对应的动画是否已经加载完成。 - 如果动画已经加载完成,则立即播放动画,并将状态更新为目标状态。
资源加载流程优化
graph LR
A[setActive调用] --> B{资源就绪?}
B -->|是| C[立即切换]
B -->|否| D[记录pending状态]
E[异步加载完成] --> F{有pending状态?}
F -->|是| G[执行延迟切换]
F -->|否| H[更新缓存]
通过引入状态机和Pending机制,我们解决了以下问题:
- 资源未就绪时的状态丢失问题:
- 在动画资源尚未加载完成时,记录当前状态为挂起状态,确保在资源加载完成后能够正确切换状态。
- 确保最终一致性:
- 通过生命周期兜底逻辑,确保视图在挂载时能够处理挂起状态,避免因加载时序问题导致的状态不一致。
第四阶段:多资源管理(生产级方案)
Lottie动画与图片
Lottie 的 json 文件分为两种情况:
-
纯 json 文件,所有资源(包括图片)都内嵌在 json 里(base64),这种情况下,Lottie 只需要加载 json 文件本身即可,动画和图片都能正常显示。
-
json 文件 + 外部 images 目录(图片分离),这种情况下,Lottie 需要能访问到 json 文件旁边的 images 目录,才能正确加载图片资源。如果找不到图片,动画会显示不出来或图片部分缺失。
现在的异步加载方式
let animation = LottieAnimation.filepath(path)
这种方式只传入了 json 文件路径,没有告诉 Lottie 去哪里找 images 目录。
Lottie 的底层实现会尝试用 json 路径的同级目录下的 images 文件夹,但如果你用的是沙盒缓存路径、或者 images 目录和 json 不在同一目录,或者 images 目录没有被正确拷贝,Lottie 就找不到图片,结果动画就不会被正常显示出来。
那么如何解决呢?
图片资源隔离方案
Lottie 支持自定义图片加载方式,可以用 FilepathImageProvider 指定 images 目录;
当你切换 animation 属性时,如果新动画的图片资源目录和上一个动画不同,必须同步切换 imageProvider,否则会出现图片丢失或显示异常。
// 初始化时创建独立ImageProvider
let activeProvider = FilepathImageProvider(
filepath: URL(fileURLWithPath: activePath)
.deletingLastPathComponent()
.appendingPathComponent("images")
.path
)
// 状态切换时同步更新
func play(animation: LottieAnimation, provider: AnimationImageProvider) {
animationView.imageProvider = provider // 先切换资源
animationView.animation = animation // 再切换动画数据
animationView.play()
}
完整架构图
classDiagram
class DualStateLottieView {
-activeAnimation: LottieAnimation?
-inactiveAnimation: LottieAnimation?
-activeImageProvider: AnimationImageProvider?
-inactiveImageProvider: AnimationImageProvider?
-currentState: AnimationState
+setActive(Bool)
+loadResources()
}
class AnimationLoader {
+loadAnimation(path: String) LottieAnimation
}
class StateMachine {
+handleStateChange()
}
DualStateLottieView --> AnimationLoader : 使用
DualStateLottieView --> StateMachine : 委托状态管理
DualStateLottieView
:- 主类,负责管理双状态动画的加载、切换和渲染。
- 包含动画数据缓存(
activeAnimation
和inactiveAnimation
)和图片资源提供者(activeImageProvider
和inactiveImageProvider
)。 - 使用状态机管理动画状态的变化。
AnimationLoader
:- 负责异步加载动画资源。
- 提供
loadAnimation(path:)
方法,返回加载完成的LottieAnimation
对象。
StateMachine
:- 负责处理状态变化的逻辑。
- 提供
handleStateChange()
方法,确保状态切换的正确性和一致性。
关键优化点总结
优化阶段 | 核心技术 | 解决问题 |
---|---|---|
异步加载 | 全局队列+主线程回调 | 消除主线程阻塞 |
状态机 | Pending状态管理 | 处理加载时序问题 |
资源隔离 | 独立ImageProvider | 解决多资源冲突 |
生命周期 | didMoveToWindow | 视图挂载兜底 |
性能对比数据
针对同一个Lottie动画,JOSN大小4KB,含7张1KB-800KB图片,内存占用0.7MB
启动耗时测试(ms)
| 原始方案 | 最终方案 | 优化幅度 | | ——– | ——– | ——– | | 89.38 | 2.27 | 94.8% |
状态切换性能
| 指标 | 原始方案 | 最终方案 | 优化幅度 | | ——– | ——– | ——– | ——– | | 首次切换 | 6.16ms | 4.57ms | 25% | | 二次切换 | 6.91ms | 0.04ms | 99% | | N次切换 | 6.91ms | 0.04ms | 99% | | 内存波动 | 高频分配 | 零分配 | 100% |
结论:99%的主线程阻塞被消除,切换性能大幅提升
最佳实践指南
1. 资源规范
推荐目录结构:
├── tab_animations
│ ├── home_active
│ │ ├── active.json
│ │ ├── images/ # 独立图片目录
│ ├── home_inactive
│ │ ├── inactive.json
│ │ └── images/
2. 预加载策略
// 在应用空闲时预加载
func preloadAnimations() {
let preloadQueue = OperationQueue()
preloadQueue.qualityOfService = .utility
for path in criticalAnimationPaths {
preloadQueue.addOperation {
_ = LottieAnimation.filepath(path) // 触发缓存
}
}
}
3. 降级方案
func safePlay(animation: LottieAnimation?) {
guard let anim = animation else {
showPlaceholder() // 降级为静态图片
return
}
animationView.animation = anim
animationView.play { [weak self] success in
if !success {
self?.animationView.currentProgress = 1 // 显示最后一帧
}
}
}
结语
通过三次关键迭代:
- 异步解耦:解决主线程阻塞
- 状态补全:处理资源未就绪场景
- 资源隔离:保障复杂资源正确性
我们最终实现了:
- ⚡️ 启动加速:主线程接近零耗时
- 💫 切换流畅:60fps稳定运行
- 🧩 通用性强:适配任意双状态场景
优化本质在于解耦三个关注点:
- 资源加载(异步)
- 状态管理(状态机)
- 视图渲染(轻量)
在本次实践中,我们通过一系列渐进式优化,成功解决了 Lottie 动画双状态切换中的性能瓶颈,实现了高性能、高可靠性的动画交互体验。