1. 为什么底部导航栏要上Lottie
1.1 底部导航栏的动画需求不只是“好看”
做客户端开发这些年,底部导航栏是每个项目都要碰一次的东西。早年的做法很直接:静态icon,选中时换一张图,最多加个变色。后来产品开始提品牌感,要求tab切换要“活一点”,静态方案就不太够用了。这时候Lottie几乎是绕不开的选择:它以JSON为载体,把设计师在动效工具里做好的矢量动画直接搬到端上,体积比GIF小得多,还能随时控制播放进度、方向和速度,并且跨端效果基本一致。
但在实际项目里,底部导航栏用Lottie这件事远没有“加依赖、放JSON、play一下”这么简单。我第一版实现就是无脑播放,结果快速切换tab时动画各种乱跳,低端机上还有掉帧;后来又遇到内存问题、图标发虚、换肤失效等一系列破事。折腾了小半年,最后沉淀出一整套资源规范和渲染控制方案。这篇就全部复盘出来,给正准备做同类改造的同学一条能直接落地的路线。
从产品角度看,底部导航栏的动画承担着明确的交互反馈任务:用户点了哪个tab,哪个tab要给出响应,其他tab要安静退出。如果所有tab同时播放,就会让用户产生“页面到底切没切”的疑惑。很多团队只把Lottie当成“会动的图标”,忽略了它背后还涉及状态机、资源管理、播放时机和性能回收,于是上线后各种翻车。提前把这些想明白,后续开发会省掉大量返工。
1.2 传统动画方案对比,Lottie赢在哪里
把主流的“图标动起来”方案摆到一起对比,优劣其实很明显:
| 方案 | 包体影响 | 动画复杂度上限 | 播放可控性 | 跨端一致度 | 维护成本 |
|---|---|---|---|---|---|
| 序列帧图片 | 大,几组图就上百KB | 高,但资源量大 | 低,只能整段播放 | 差,各端需分别导图 | 高 |
| 矢量Drawable动画 | 小 | 中,适合简单位移动画 | 中,可控制度有限 | 差,Android独有 | 中 |
| 属性动画 | 小 | 低,颜色位移缩放 | 中 | 一般 | 中 |
| GIF | 中 | 中 | 低,几乎不可控 | 差,各端渲染有差异 | 中 |
| Lottie | 小,JSON通常几十KB | 高,支持复杂图层动画 | 高,可控制进度方向速度 | 好,同一JSON三端复用 | 低 |
这里不是想表达Lottie能解决所有动画问题,而是在底部导航栏这个具体场景里,它确实最合适。底部导航的核心特点是小尺寸、高频率、需要明确选中态和未选中态,而且往往要跟App的整体交互节奏保持一致。序列帧方案在视觉效果上确实能做得非常华丽,但包体和内存开销会让这个常驻组件变成性能负担;GIF虽然制作门槛低,可一不能控制进度二不能透明处理得干净;原生属性动画做做透明度和位移动画还行,一旦涉及复杂形变就力不从心。
相比之下,Lottie把动画渲染成矢量图层,颜色、路径、透明度都能在运行时读取和修改。这意味着除了播放动画之外,我们还能让它停留在某一帧,以“普通态”静态图标的形式存在。这个特性对底部导航栏来说是决定性的:选中前它是静态图标,选中时动画播放,播放完继续保持静态,全靠同一个JSON文件控制,不会出现资源切换的闪烁。
2. 动手之前:动画资源的设计与准备
2.1 先画状态机:普通态、选中态、按压态
底部导航栏的状态一定要在动效设计之前定义清楚。很多项目失败在“动画文件有了,但不知道什么时候该播、播到哪里、停下来停在哪儿”。我在工程里一般用三段式设计:普通态是动画文件起始帧附近的一个静态区间,选中态是起始帧到某一进度之间的过渡动画区间,选中后的常驻态则是过渡动画结束后的那个结束帧。
以一个总长度为60帧的tab动画为例,可以把0到10帧做成普通态的静态图标,10到45帧做成选中时的过渡动画,45到60帧做成选中后的轻微呼吸循环。切换tab时,代码控制播放进度从普通态的0.1走到选中态的1.0;取消选中时,再把进度从当前值反向拉回0.1。这样做的好处是,整个动画生命周期都由同一个JSON承载,不会出现选中动画和普通态资源之间衔接不上的问题。
按压态我一般不复用选中动画,而是额外让设计在源文件里加一个“按下缩放”关键帧区间,通常只占5到8帧。这里的关键原则是:动画文件设计得越“状态自动机化”,端上代码就越简单。如果设计给的动画是一个“从头播放到尾”的完整叙事,端上虽然也能强行切成一段段,但播放体验会非常别扭,而且后期想微调某个状态的起始位置,就得回设计那边重新导图,沟通成本极高。
2.2 从设计工具到JSON:导出时容易踩的三个选项
动效设计师在工具里做好动画后,导出JSON不是点一下“导出”就能收工。有三个选项特别容易踩坑。第一个是图层特性兼容性:如果源文件里用了过多预合成、表达式、3D图层或特殊混合模式,Lottie在iOS或Android上解析时可能会报错或者静默丢弃某些效果。导出前应该尽量把表达式烘焙成关键帧,3D图层拆成2D,特殊混合模式换成普通混合模式。
第二个是画板尺寸。我见过不少JSON文件,解析之后图标位置偏移,原因是设计导出时的画板是自动裁剪后的尺寸,各tab之间大小不一致。正确的做法是把画板固定成统一尺寸,比如256x256或512x512,然后图标画在安全区内。这样端上只需要按统一比例缩放,不会出现同一个导航栏里有的图标偏左、有的偏右的问题。
第三个是位图素材混入。Lottie虽说是矢量动画,但设计师有时会直接拖入PNG素材。导出后JSON里会出现assets字段,引用外部图片,必须把图片文件一起打包进资源目录。漏掉它们,运行时就表现为黑屏或者只有部分图层显示。检查方法很简单:搜索导出文件夹里除了JSON之外是否还有别的图片文件,有就说明资源没有完全矢量化成形状,建议尽量转成矢量路径,否则压缩体积和换色的优势都会打折扣。
2.3 资源命名、尺寸与压缩策略
资源乱命名是后期维护的地狱。我见过一个项目把tab动画命名为file_1、file_2,两周后再改需求时已经分不清哪个是首页、哪个是个人中心。我强烈建议统一前缀:tab_模块名_状态,比如tab_home_selected、tab_profile_normal。哪怕一个JSON里同时包含普通态和选中态,命名也要体现业务含义,方便后面按需替换单个tab的动画。
尺寸方面,Lottie是矢量渲染,理论上不受像素分辨率限制,但导出画板过小会导致某些设备上边缘发虚,尤其是带缩放动画的图标,在放大的过程中会露出锯齿。我的经验是设计端画板用512大小,端上展示时压到36dp左右。这样可以兼顾清晰度和解析性能,动画在过渡放大的瞬间也不会有明显模糊。如果设计习惯用插画风格,导出设置里的路径精度可以适当降低,保留两位小数的坐标点就够用,曲线点太多会让JSON体积膨胀并拖慢首次解析速度。
压缩策略上,先用工具自带的“简化路径/减少小数点”功能,再做一次JSON字段清理。Lottie JSON本质是结构化数据,里面有大量诸如k(关键帧)、i(入切线)、o(出切线)这样的字段。很多字段对播放没有影响,但会被编辑器自动写入。上线前可以用构建脚本跑一遍去除冗余,体积通常能减小一成到两成。另外,如果团队端上有对应的解码能力,可以直接使用压缩后的.lottie单文件格式,体积比裸JSON更小,加载也更稳定。
3. 核心实现:三端统一方案落地
3.1 Android端:用进度控制而不是直接play()
Android端最常见的问题是开发者把LottieAnimationView当成普通ImageView用:选中tab就调playAnimation(),取消选中就调pauseAnimation()。这会带来两个后果:快速点击时动画互相抢占,出现乱跳;停在半路的图标看起来像“坏掉了”。底部导航对动画的可控性要求很高,我的做法是彻底放弃直接播放,改用progress驱动。
把LottieAnimationView封装成一个TabIconView,内部维护当前正在执行的动画任务。每次状态变化时,先取消上一个任务,再创建一个新的ValueAnimator,把进度从当前值平滑过渡到目标值:
class TabIconView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LottieAnimationView(context, attrs) { private var currentTask: ValueAnimator? = null fun setSelectedState(selected: Boolean, animate: Boolean = true) { currentTask?.cancel() val target = if (selected) 1f else 0f if (!animate) { setProgress(target) return } val start = progress currentTask = ValueAnimator.ofFloat(start, target).apply { duration = 300 interpolator = DecelerateInterpolator() addUpdateListener { setProgress(it.animatedValue as Float) } start() } } override fun onDetachedFromWindow() { currentTask?.cancel() super.onDetachedFromWindow() } }这里有个关键认知:LottieAnimationView的progress是0到1的浮点数,表示当前播放到动画时间轴的哪一个位置。通过ValueAnimator驱动它,我们可以获得完整的插值控制能力,并且随时可以被新任务打断。动画时长建议在250到350ms之间,太短会显得生硬,太长会让页面切换显得拖沓。低端机上如果出现掉帧,优先检查是否多个tab同时在驱动动画,而不是盲目缩短时长。
3.2 iOS端:用一个TabButton封装搞定
iOS端的思路完全同构,但要特别注意UIKit的交互习惯。我的做法是把Lottie动画View和UIButton合装成一个LottieTabButton,在isSelected变化时做动画切换:
final class LottieTabButton: UIButton { private let lottieView = LottieAnimationView() override var isSelected: Bool { didSet { guard oldValue != isSelected else { return } if isSelected { lottieView.play(completion: nil) } else { lottieView.play(fromProgress: 0, toProgress: 0) } } } }这段代码里有一个很容易忽略的细节:当Button取消选中时,我调用的是play(fromProgress: 0, toProgress: 0),而不是stop()。因为stop()会立刻停在当前帧,如果动画正好停在半空中,取消选中的图标就会是一帧很奇怪的样子。强制把进度归到0这个静态普通态,视觉上才会显得干净。
另一个iOS专属坑是系统Button的高亮效果。UIButton默认在手指按住时会叠加半透明遮罩或改变图片渲染模式,Lottie动画View如果没有被正确处理,会看到一个很明显的“颜色变暗”反馈,和动画混在一起非常违和。解决办法是使用自定义Button,或者把adjustsImageWhenHighlighted设为false,让高亮状态完全由Lottie动画自己来表达。
3.3 Flutter端:同一个JSON直接复用
跨端项目的最大红利是同一份JSON直接复用。Flutter端接入Lottie很直接:在BottomNavigationBarItem的icon位置放一个Lottie控件,然后用AnimationController的value去驱动它的progress。不能让它自己播放,否则会失去和导航状态绑定的控制力:
class LottieTabIcon extends StatelessWidget { final Animation<double> progress; final String assetPath; const LottieTabIcon({required this.progress, required this.assetPath}); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: progress, builder: (context, _) { return Lottie.asset( assetPath, animate: false, progress: progress.value, ); }, ); } }Flutter版本的animate: false非常重要,它告诉控件不要自动播放,只接受外部传入的progress。在父Widget里,切换tab时可以AnimationController.forward(from: 0),配合CurvedAnimation做缓动。由于Flutter的渲染帧率和Android、iOS原生存在差异,同一套进度值在真机上的手感可能有细微差别,但这通常不影响体验,反而比各端自己写关键帧要统一得多。
3.4 状态同步与快速点击防抖
底部导航栏最常被诟病的体验问题就是“快速来回点导致动画失控”。我的工程里在BottomBar外层维护一个selectedIndex,并加了一个动画任务队列。切换tab时,旧tab先强制退出动画,新tab再启动进入动画,顺序绝对不能反。反了的话,旧tab还在播放中,新tab就开始播放,两者叠加在视觉上就是一团乱。
更稳妥的做法是加一个全局的动画锁:点击tab后进入一个200到300ms的锁定窗口,窗口内重复点击不触发新的动画任务,但会记录用户想去的目标位置;锁结束后如果目标位置已经变化,就直接切换过去。这个队列式处理比直接“禁点”体验好很多,因为用户不会感到按钮“失灵”,只是感受到节流后的回应。如果你在线上遇到低端机快速点击时动画“鬼畜”,多半就是缺了这道锁。
4. 性能、内存和细节优化
4.1 Composition缓存:多个Tab共享同一条动画
Lottie接管了动画解析和渲染,但解析JSON本身是有开销的。底部导航栏通常有三到五个tab,如果每次页面创建都重新解析所有JSON,冷启动时会有一段时间的空白,甚至在低端机上掉帧。我建议在Application或导航栏宿主初始化时,把所有tab动画预加载成LottieComposition并缓存到全局Map里。
Android端可以通过LottieCompositionFactory.fromAsset()获取composition,再调用setComposition(composition)而不是每次setAnimation(fileName)。后者会重新走一遍文件读取和JSON解析,前者直接复用内存里的动画模型。iOS和Flutter也都有类似的composition缓存机制,关键是不要在页面重建时重复加载。一个简单的自检方式:底部导航栏首次出现时,如果Log里能看到JSON解析耗时,并且每次切回页面都会重新打印,那说明缓存没生效。
4.2 页面不可见时暂停渲染
底部导航栏是常驻组件,用户可能停留在某个tab刷很久,完全不碰导航。如果Lottie还在渲染,即使播放的是“静态帧”,也会持续占用渲染资源。我一般在页面onResume时把当前选中tab的进度设置到正确位置,但不会主动播放循环动画;在onPause或进入后台时,对当前选中tab调用暂停操作。
这里涉及一个容易误解的点:Lottie渲染静态帧也有成本,因为它每一帧都要计算矢量路径并绘制到画布上。如果导航栏的根本不需要动,直接用一张普通ImageView展示等效的静态图标,资源开销反而更低。所以在做架构时,可以给底层留一个“无动画降级”开关:用户开启“减少动态效果”系统设置或App内低功耗模式时,直接把LottieView替换为静态图片,虽然少了互动细节,但换来的是更稳定的性能和更低的耗电。
4.3 颜色动态变化怎么处理
很多App的底部导航栏会跟随主题换色,比如品牌色从蓝色变成红色,这时如果动画文件里把颜色写死,就不得不出多套JSON。正确的做法是设计端就预留下“颜色占位符”,运行时用统一的色调映射做替换。
Android端可以通过解析LottieComposition里的图层结构,找到指定颜色图层并替换ColorFilter。iOS和Flutter也有对应的色板替换能力。我的经验是:能不改文件就不要改文件,把动画文件与业务色板解耦。比如选中态图标的主色,可以存成变量,运行时读取当前主题颜色后传给动画库,整体替换。这样换肤逻辑只发生在代码层,不用让设计师为每个主题都导一遍动画,维护成本会低很多。
5. 实际操作中的高频问题与排查
5.1 动画不播放或黑屏
遇到黑屏先别急着怀疑代码。最常见的三个原因:一是JSON文件路径写错,Android的Assets路径不能带后缀,iOS却可能要求带后缀,两边习惯不一样;二是LottieComposition加载失败但错误被吞掉,建议在初始化时显式加载并检查返回结果;三是View宽高为0,LottieAnimationView放在布局里如果用wrap_content包裹,但JSON里没有读取到有效合成尺寸,就会渲染成透明区域。
还有一个经常被忽略的情况:某些Lottie版本对JSON格式的要求更严格,如果设计导出的是旧版本工具生成的JSON,里面有新版本库不支持的历史字段,也会导致静默失败。遇到这种情况,用官方提供的校验工具把JSON重新保存一遍,很多时候问题就消失了。如果仍然黑屏,我建议用最小JSON样例做二分定位,把动画文件里的图层逐个删掉,直到找出引发黑屏的那个元素。
5.2 切换时动画闪烁、错乱
切换tab时动画闪烁,多半是因为多个LottieView同时播放,或者同一个View被快速切换了多次状态。解决方案是给每个tab的动画实例加一个“当前任务”的持有对象,新的切换任务进来时先把上一个cancel掉。如果底部导航栏里有容器复用,比如某些风格化设计用列表来承载tab,还要在item复用方法里按当前选中状态重置进度,不能依赖复用item里的残留播放状态。
这类问题的特征是“偶发、难复现”。我见过一个项目在真机上快速交替点击两个tab时,图标会闪一下,后来定位到是旧tab在取消动画时使用了stop(),停在了中间帧,而新动画已经启动,导致两帧内容短暂重叠。把stop()改成强制回普通态进度,问题立刻消失。排查时可以在每次状态切换时打印progress值和正在执行的任务数,基本能快速锁定是哪个动画没退出干净。
5.3 JSON解析报错与图层特性兼容性
Lottie对动效工具里的部分特性支持不全,尤其是表达式、3D图层、部分混合模式,解析时可能会抛Unable to parse composition或者干脆掉帧。这时候千万别想着在代码里绕过去,绕行成本远高于让设计师修改源文件。让设计师把3D图层拆成2D,表达式烘焙成关键帧,复杂矢量路径简化。记住:Lottie的兼容性是受限于它自身的渲染引擎的,端侧无法通过配置解开。
我在项目里遇到过曲线路径太多导致首帧解析慢的情况,最终是把设计文件里的自动描边路径换成预设形状路径,JSON体积直接减半,解析速度也快了一倍。如果团队里没有懂Lottie资源导出的设计师,开发同学最好自己也学一遍导出规范,避免每次都是“导出→报错→让设计改→再导出”的低效循环。
5.4 包体变大和加载变慢怎么办
底部导航的Lottie JSON本来不该很大,但如果设计源文件里混入位图素材,包体可能直接飙升数百KB。检查方式就是看JSON里的assets字段,如果有大量图片引用,说明设计用了位图而不是矢量形状。优化手段是让设计师把位图转成矢量路径,或者用描边和填充替代照片级素材。
另外,资源加载变慢通常和文件读取、JSON解析、图像解码三个阶段有关。如果动画文件已经压缩过仍然偏大,可以考虑运行时按需加载而不是启动预加载:底部导航栏首次显示时,先解析当前选中的tab动画,其他tab等到第一次被选中时再加载。这样虽然单个动画首次播放会有一点延迟,但启动速度影响最小。
5.5 快速点击导致动画失控,如何从架构层根治
“快速点击导致动画失控”是最容易让开发崩溃的bug,因为它测试时很难稳定复现,上线后被用户投诉。根治思路不是让按钮不可点,而是给动画切换加一个严格的“状态仲裁层”。我在工程里抽象了一个TabAnimatorCoordinator,统一接收点击事件,统一分发动画任务。
这个仲裁层只做三件事:记录当前选中的tab索引;记录当前是否处于动画中;决定新的点击事件是立即执行、排队执行还是忽略。每个tab的LottieView只负责执行仲裁层下发的动画指令,不自己监听点击。这样即使产品后面加了一个“摇一摇快速切tab”的功能,也不会破坏动画状态一致性,因为所有状态变化都流经同一个决策中心。
6. 一些个人实操体会
6.1 跨端一致性带来的实际价值
做跨端项目最难受的是同一套交互在三个平台上效果不一致。以前做原生帧动画,Android、iOS、Flutter三套资源经常有色差、节奏不一致,评审会要开两轮才能对齐。Lottie方案落地后,设计只需维护一份动画源文件,三端用同一个JSON,动画曲线几乎完全一致。我记得第一次在三个真机上同时对比同一个tab切换动画时,第一反应是“居然能一帧不差”,从那以后团队内部就把Lottie定成了动态icon的默认方案。
当然跨端一致性的前提是资源规范严格。如果iOS用了一个动画版本,Android用了另一个版本,差别依然会出现。所以我在工程里把JSON资源放在一个统一的资源中心,构建时同步到各端,任何人修改都需要走同一套发布流程。这看起来是流程管理,不是技术问题,但在真实项目里比写一万行优化代码更有用。
6.2 后续还能怎么玩
底部导航只是Lottie在交互里最基础的场景。我后来把同一套“进度驱动”思路用在了空白页引导和下拉刷新loading上,体验提升非常明显。下拉刷新时,把下拉位移换算成0到1的进度值,动画会跟着手指移动;松手后继续播放到1.0,变成刷新成功的反馈。这个交互和底部导航栏完全相通:先驱动进度,再决定播放或暂停。
如果你已经完成了底部导航的Lottie改造,我建议顺手把它抽象成一个“ProgressDrivenView”的组件:外部只传入一个0到1的进度值,内部负责播放、暂停、取消和缓存。这个组件后续接任何“需要和用户手势或页面状态绑定的动画”都好用。Lottie本身不复杂,复杂的是怎么让动画在真实业务里长期稳定地运行,而这恰恰是工程层面的基本功。