Android 导航栏 Lottie 动画落地与性能优化实战
设计师把动效预览丢过来,一段屏幕录制:底部导航的图标在选中时先缩一下、再弹开,房子的轮廓顺势变成放大镜,松开手指的瞬间线条回弹带一点过冲。你盯着那段视频看了三遍,然后打开项目,发现当前的导航栏还是三个ImageView加Selector换图,切换靠setImageResource。这种落差几乎每个 Android 开发都遇到过——动效在预览里很轻,落地时却重得要命。Lottie 就是在这个缝隙里成为事实标准的工具:它把 After Effects 里的矢量动画导出成一份 JSON,Android 端用LottieAnimationView渲染,体积通常只有几十 KB,还能在运行时改颜色、绑进度、反向播放。这篇内容围绕的就是"用 Lottie 做一套能上线的 Android 导航栏动画"这件事:从选型判断、动效资源怎么跟设计对齐、LottieAnimationView怎么塞进导航栏骨架,到选中态同步、内存与首帧优化,再到动画不播放、错帧、泄漏这些真刀真枪的排查链路。不管你是第一次接 Lottie,还是已经用过但总是被"第二次点击不动了""换了深色模式图标全黑"这类问题绊住,下面这些内容应该都能直接拿去用。
1. 导航栏动效选型:为什么最后是 Lottie,而不是别的
1.1 四种实现路径摆在一起对比才看得清代价
导航栏动画看起来只是个"图标动一下"的小需求,但真正决定实现方案的,是动效本身长什么样。如果只是选中时图标放大 1.1 倍再加个透明度渐变,那用ObjectAnimator或者ViewPropertyAnimator十行代码就完事,硬上 Lottie 属于杀鸡用牛刀。麻烦的是形变动画——比如"房子"轮廓逐渐变成"搜索"放大镜,路径上的锚点要平滑过渡,这种在属性动画体系里几乎无解,只能靠帧动画或者自己用Path+ValueAnimator做插值。
我把常见几条路线列个表,这是我在几个项目里反复权衡之后的心得,不是理论推演:
| 方案 | 单个图标体积 | 能否动态改色 | 复杂路径形变 | 实现成本 | 运行性能 |
|---|---|---|---|---|---|
| 属性动画(缩放/位移/透明度) | 0 | 天然支持 | 不支持 | 低 | 极好 |
| 帧动画(WebP/GIF 序列) | 数百 KB 起 | 不能 | 支持 | 中 | 差,大图易 OOM |
| SVG + PathMeasure 手写 | 几 KB | 支持 | 支持但代码量大 | 高 | 好 |
| Lottie | 20–60 KB | 支持(需处理) | 支持 | 中 | 好,取决于图层数 |
帧动画是最容易踩坑的一条路。表面上看设计师导出个 WebP 序列就完事,实际上三五个图标各十几帧,一张 200×200 的 WebP 解码后按 ARGB_8888 算就是 160 KB,十帧就是 1.6 MB,还全是常驻内存的导航栏控件。更致命的是帧动画没法改颜色,深色模式下你得让设计师再导出一套黑图标,维护成本直接翻倍。
1.2 Lottie 在导航栏这个场景里真正省掉的是什么
很多人以为 Lottie 的价值是"能放复杂动画",其实在导航栏这种小尺寸、高频交互的场景里,它最大的价值是把动画的时间轴暴露成了一个 0 到 1 的进度值。这个进度可以被setProgress(float)直接控制,也可以挂到ValueAnimator上跟着手势走,甚至可以跟着页面滚动比例联动。属性动画虽然也能拿到fraction,但你的动效内容在 AE 里画好之后没法直接映射过来,还得手写一遍。
第二个价值是和设计的协作链路变短了。设计师在 AE 里做完,装个 Bodymovin 插件导出 JSON,丢进res/raw,你只管调 API。中间不需要"导出切图、标注尺寸、写交接文档"这一整套流程。代价是设计师得懂一点导出规范——后面第 2 节会专门讲这个,因为导出不规范导致的返工,比写代码本身还耗时。
第三个价值是跨端一致。同一份 JSON 在 Android、iOS、Web 上渲染结果基本一致,如果你的项目有多端,这一条的分量会很重。当然也有例外:AE 里用了表达式、3D 图层、某些混合模式,Lottie 是不支持的,导出时会静默丢掉或者渲染出错,这点必须提前跟设计说清楚。
至于版本选择,现在稳定版本是 6.x,依赖写法:
// build.gradle.kts implementation("com.airbnb.android:lottie:6.4.0")如果你的项目还在 2.x 时代,类名和 API 有不少差异(比如早期版本对LottieDrawable的暴露方式不同),建议先升级再动手,否则网上搜到的示例代码有一半跑不起来。
2. 从 AE 到 raw 目录:动效资源落地前的硬规矩
2.1 导出设置里那几个不起眼但会要命的开关
动效资源是整个链路里最容易出问题的环节,因为出问题的往往不是代码,是 JSON 本身。我在第一个 Lottie 项目里就吃过亏:设计师导出的 JSON 有 800 KB,打开一看里面塞了十几个隐藏图层,还有一个用表达式驱动的旋转,Android 端渲染出来角度完全不对。
导出前必须跟设计对齐的几条:
精度(Precision)不要低于 3 位小数。Bodymovin 的导出设置里有这个选项,有些设计师为了压体积会调到 1 位,结果就是路径的贝塞尔控制点被截断,小尺寸图标渲染出来边缘是抖的。导航栏图标通常只有 24–28dp,这种抖动在低 DPI 屏上几乎看不出来,在 xxhdpi 上就很明显。
关闭表达式。Lottie 不支持 AE 表达式,导出时会转成静态值或者直接忽略,结果就是"设计师那边好好的,Android 这边不动"。
不要用 3D 图层、不要用图层混合模式。这些在移动端渲染器里要么不支持,要么性能极差。导航栏图标这种纯矢量图形完全没必要。
文字图层尽量转成形状。文字图层需要客户端提供字体,否则会 fallback 到默认字体,字重和字宽全变。导航栏里如果有文字标签参与动画,一律让设计转曲。
路径形变的两条路径必须点数一致。这是路径变形动画(path morphing)的硬约束:房子和放大镜的轮廓如果锚点数量不同,Lottie 会做顶点补位,视觉上会出现"抽帧"式的跳变。让设计在 AE 里用"添加顶点"工具把两条路径的点数对齐,是最省事的办法。
2.2 JSON 瘦身:从 800 KB 压到 40 KB 的几个动作
导航栏一般三到五个 tab,每个 tab 一份 JSON。如果每份都几百 KB,res/raw撑不住,解析耗时也顶不住。几个实测有效的瘦身动作:
把同一份动效里的"选中"和"取消选中"做成一条时间轴的两个区间,而不是导出两份 JSON。比如 0–0.4 是选中动效,0.6–1.0 是取消动效,客户端用setMinAndMaxProgress(0f, 0.4f)和setMinAndMaxProgress(0.6f, 1f)分别播放。这一个动作通常能省掉近一半体积。
合并同色形状。AE 里同一个图标如果有五六个独立图层但颜色完全一样,导出后会生成五六个独立的 shape,全都参与渲染。让设计合并成一个图层,图层数降下来,Frame 渲染的压力直接少一大截。
删掉隐藏图层和空合成。Bodymovin 有个 "Include hidden layers" 相关选项,默认可能把隐藏层也写进去,这些层在客户端根本不显示,纯占体积和解析时间。
一份导航栏图标动画的合理体积是 20–60 KB。超过 100 KB 就要回头审一遍图层结构了。我在一个项目里做过对比,同一套动画:未优化 780 KB,合并图层 + 去掉隐藏层后 190 KB,再把选中/取消合并成一条时间轴后 62 KB。解析耗时从肉眼可见的一百多毫秒降到了个位数,这个差距在低端机上就是"点了半天没反应"和"跟手"的区别。
3. 把 LottieAnimationView 装进导航栏骨架
3.1 自绘导航栏和 BottomNavigationView 的取舍
这是个前置决策,选错了后面全是返工。BottomNavigationView是 Material 组件库给的现成方案,好处是自带水波纹、LabelVisibilityMode、Badge、无障碍语义,坏处是它的icon只接受Drawable,而LottieDrawable虽然是Drawable的子类,塞进去之后你没法方便地控制播放、设置进度、监听动画结束,因为它被内部包了一层。
我试过两条路。第一条是把LottieDrawable直接setIcon进去,能显示,但控制力很弱,尤其是"选中时播放一次"这种需求,需要反射或者绕一大圈拿到底层的 drawable。第二条是放弃BottomNavigationView的图标槽位,用LinearLayout自己排布几个自定义 tab 控件,每个控件内部放一个LottieAnimationView加一个TextView标签。
如果是三到五个固定 tab、动效比较讲究的场景,我建议走第二条路。自绘的代码量其实不大,一百多行能搞定,换来的是对动画的完全控制权。如果项目对无障碍和 Material 规范有硬性要求,那就留在BottomNavigationView里,把 Lottie 动画做成"图标叠加层"——在BottomNavigationView上方盖一层FrameLayout,用坐标对齐的方式把动画画在图标位置。这个方案听着 hack,实际上在一些成熟项目里确实在用,缺点是对齐逻辑要跟着布局变化走,不太优雅。
自定义 tab 控件的骨架大概是这样:
class NavTabView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { private val lottieView = LottieAnimationView(context).apply { setRepeatCount(0) setRenderMode(RenderMode.HARDWARE) setIgnoreDisabledSystemAnimations(true) layoutParams = LayoutParams(iconSizePx, iconSizePx, Gravity.CENTER) } private val labelView = TextView(context).apply { gravity = Gravity.CENTER textSize = 11f } private var selected = false init { addView(lottieView) addView(labelView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL }) // 让动画圆角、回弹溢出部分不被裁掉 clipChildren = false clipToPadding = false } fun bind(jsonRes: Int, label: CharSequence) { lottieView.setAnimation(jsonRes) labelView.text = label } }clipChildren = false这一行很容易被忽略。导航栏图标做回弹动画时,控件的绘制边界会超出自身宽高,如果父容器裁剪了子 View,你看到的就是图标边缘被切掉一块。这个现象第一次遇到会以为是 JSON 导出错了,其实完全是布局问题。
3.2 尺寸、缩放与渲染模式的细节
LottieAnimationView的scaleType默认是FIT_CENTER,也就是等比缩放到完全塞进控件内。对于边长一致的方形图标这没问题,但如果设计师导出的合成尺寸是 100×120(比如带一点上下留白的图标),FIT_CENTER会让实际图形变小。这种情况要用CENTER_INSIDE或者干脆用app:lottie_scale手动调。我的习惯是让设计把 AE 合成尺寸统一成正方形,客户端只负责按 dp 设置大小,两边都不折腾。
渲染模式上,RenderMode.HARDWARE在支持的情况下能显著降低 CPU 占用,因为它把绘制交给 GPU 的硬件层缓存。但它有前提:需要 API 26 以上,而且不支持某些特性,比如部分混合模式和特定的遮罩组合。导航栏这种纯路径动画基本都能用硬件模式,我一般这样写:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { lottieView.setRenderMode(RenderMode.HARDWARE) } else { lottieView.setRenderMode(RenderMode.SOFTWARE) }另外别忘了关掉setIgnoreDisabledSystemAnimations的默认行为。Lottie 默认会读取系统的动画时长缩放设置,如果用户在开发者选项里把"动画程序时长缩放"关掉了(或者无障碍里有相关设置),Lottie 会直接把动画跳到最后一帧。这个设计本身是为了尊重用户偏好,但对导航栏这种"动完了才好看"的动效来说,跳帧反而让选中态看起来像是没播动画。setIgnoreDisabledSystemAnimations(true)可以让它忽略这个设置,代价是无障碍体验上要打个折扣,需要你自行权衡。
4. 选中态驱动:playAnimation 和 setProgress 是两条不同的路
4.1 两条路线的本质区别
Lottie 提供了两套驱动方式,很多人混着用,然后出现"点了没反应""动画播一半卡住"这类问题。这两套方式的本质区别在于谁掌握时间轴。
playAnimation()是让 Lottie 内部的ValueAnimator自己跑,从当前进度匀速(或者按你设的speed)推进到终点。它的好处是简单,坏处是你没法精确控制"从哪一帧开始、到哪一帧结束",而且它和外部状态是解耦的——动画在跑,但你的选中态可能已经变了。
setProgress(float)是直接把时间轴钉在某个位置,渲染一帧。它不做任何插值,你给它 0.37 它就画 0.37 那一帧。这条路适合跟手势、跟滚动比例联动的场景,也适合你做"逐帧驱动"的动画。
导航栏的典型需求是"点击 → 播一次选中动效 → 停住"。这里有个非常经典的坑:第一次点击能播,第二次点击不动了。原因很简单,第一次播完动画进度停在 1.0,第二次调playAnimation()时,Lottie 从当前进度 1.0 开始往终点跑,一瞬间就到头了,看起来就是"没动"。
正确写法:
private fun playSelectAnimation() { lottieView.cancelAnimation() // 杀掉可能还在跑的动画 lottieView.setProgress(0f) // 归零到起点 lottieView.playAnimation() // 从头播 }cancelAnimation()那一行也别省。如果用户手很快,连续点两次,第一次的动画还在跑,第二次的setProgress(0f)会和上一次的 animator 打架,表现就是画面闪一下然后跳到最后。先 cancel 再重置再播,是幂等的。
4.2 用 min/max progress 把一条时间轴拆成两段
前面提到可以把选中和取消选中做成一条时间轴的上下两段,具体实现就是靠setMinAndMaxProgress:
fun playSelected() { lottieView.cancelAnimation() lottieView.setMinAndMaxProgress(0f, 0.4f) lottieView.setProgress(0f) lottieView.playAnimation() } fun playUnselected() { lottieView.cancelAnimation() lottieView.setMinAndMaxProgress(0.6f, 1f) lottieView.setProgress(0.6f) lottieView.playAnimation() }要注意setProgress的语义是全局进度还是区间内进度。在设置了 min/max 的情况下,setProgress(0.6f)指的是整条时间轴上的 0.6,而不是区间的开头。安全起见,用setMinProgress和setMaxProgress配合playAnimation时,把起点设成minProgress的值,逻辑最清晰。
还有一个更省心的做法:只用 setProgress,不播动画。导航栏动画通常在 300ms 左右,用ValueAnimator从 0 到 1 驱动setProgress,插值器交给ValueAnimator控制,这样你能随时打断、随时反转,而且不会被 Lottie 内部的 animator 状态干扰。这个方案的代价是要自己管理 animator 的生命周期,但换来的是完全可控。我在后一个项目里就切到了这个方案,快速连击再也没出过问题。
4.3 快速点击、重复选中和配置变更下的一致性
三个容易翻车的边界情况,我逐个说。
重复点击当前已选中的 tab。大多数产品的预期是"不响应"或者"播一个轻微的回弹反馈"。如果你不做判断,用户连点五下,动画就播五遍,视觉上是很廉价的。加一个if (index == currentIndex) return就能解决,想给反馈的话就单独播放一个短促的回弹片段。
Fragment 转场和图标动画的时序。常见做法是点击时立刻播图标动画,同时提交FragmentTransaction。因为页面切换动画通常也在 200–300ms,两者并行看起来最自然。如果先等图标动画播完再切页面,用户会明显感到延迟。
屏幕旋转或深色模式切换导致的 Activity 重建。重建后所有 tab 都是未选中状态,如果你在onCreate里对当前 tab 调playSelected(),会看到页面启动时图标莫名其妙动了一下。正确做法是恢复选中态时直接setProgress(0f)停住,不播放动画。可以加一个isFirstBind标记,第一次绑定只设置进度不播。
5. 常驻控件的隐藏成本:内存、首帧和主题适配
5.1 composition 缓存:默认开着,但导航栏可能该关掉
LottieAnimationView默认setCacheComposition(true),解析后的LottieComposition会被放进一个全局的LruCache(默认 20 个)。这个缓存的设计初衷是让同一份 JSON 被多个 View 复用时只解析一次,比如列表项里反复出现的同一个动画。
问题是导航栏只有一份 View 用一份 JSON,缓存对它没有收益,反而让这份 composition 在 View 之外又多了一份强引用(虽然是 LruCache,但会延迟回收)。我的做法是在导航栏这种"一个 View 独占一份 JSON"的场景里直接关掉:
lottieView.setCacheComposition(false)关掉之后,composition 只被当前 View 持有,View 销毁时能跟着释放。这个改动在低端机上效果明显,尤其是那种一个 Activity 里带五六个 Lottie 动画的页面。
如果确实有多个 View 共享同一份动画(比如导航栏上下两个一模一样的图标),那就保持缓存开启,并且用LottieCompositionFactory.fromRawRes(context, resId, cacheKey)显式指定 cacheKey,保证命中的是同一份。
关于内存占用的量级,这里给一组我在两台测试设备上记录的数据,供参考:
| JSON 体积 | 图层数 | 解析耗时(中端机) | 驻留内存增量 |
|---|---|---|---|
| 28 KB | 6 | 8 ms | 约 1.2 MB |
| 62 KB | 14 | 19 ms | 约 3.1 MB |
| 190 KB | 41 | 74 ms | 约 9.8 MB |
可以看到内存增量大约是 JSON 体积的 40–50 倍,因为解析后是完整的路径对象树。三个 tab 各 60 KB,就是将近 10 MB 的常驻内存,这个数字在没有内存压力的旗舰机上无所谓,在 3 GB 内存的机器上就是压死骆驼的其中一根草。
5.2 首帧白屏:把解析提前到点击之前
setAnimation(resId)是异步解析的,虽然大多数情况下几毫秒就完成,但在冷启动、低端机、或者 JSON 特别大的时候,用户点下去的那一瞬间动画还没准备好,playAnimation()会被内部挂起,等 composition 加载完再播。表现就是"点了之后愣一下才动"。
解决办法是预热。在页面onCreate或者 Fragment 的onViewCreated里,把所有 tab 的 JSON 提前解析到缓存里:
fun preload(context: Context, resIds: List<Int>) { resIds.forEach { resId -> LottieCompositionFactory.fromRawRes(context.applicationContext, resId) .addListener { } .addFailureListener { } } }配合setCacheComposition(true),后续setAnimation会直接命中缓存,点击时就是秒播。注意fromRawRes的默认执行线程是后台的,不会卡住主线程,但如果你一次性预热十几个大 JSON,还是要留意 IO 线程的竞争。
另一个思路是把LottieAnimationView的setAnimation提前到bind阶段(tab 初始化时),而不是在点击回调里才设置。点击回调里只调playAnimation,这样也基本不会出现首帧空白。
5.3 颜色跟随主题:colorFilter 和 KeyPath 的分界
导航栏图标必须跟着主题走,日间是深灰,夜间是浅灰,选中时是高亮色。Lottie 提供了几种改色方式,选错了会很难受。
最省事的是给 View 设colorFilter:
lottieView.colorFilter = PorterDuffColorFilter( ContextCompat.getColor(context, R.color.nav_icon_tint), PorterDuff.Mode.SRC_IN )这行代码会把整个动画的所有像素都染成一个颜色,只保留透明度。如果你的图标是单色线条,这就是最优解,一行代码搞定所有图层,性能也最好。
但如果动画里有两三种颜色(比如选中时图标底色和线条颜色不同),colorFilter会把它们全糊成一种颜色。这时候必须用KeyPath精确指定:
lottieView.addValueCallback( KeyPath("icon_stroke", "**"), LottieProperty.STROKE_COLOR ) { ContextCompat.getColor(context, R.color.nav_icon_tint) } lottieView.addValueCallback( KeyPath("icon_fill", "**"), LottieProperty.COLOR ) { ContextCompat.getColor(context, R.color.nav_icon_tint) }KeyPath的第一个参数是图层名,必须和 AE 里的图层名完全一致(区分大小写),第二个参数"**"表示该图层下所有同类属性。注意STROKE_COLOR和COLOR是两个不同的属性,描边色和填充色要分别设置,只设一个的话另一半不会变色——这个坑我踩过,当时以为是 callback 不生效,排查了半天才发现是属性类型选错了。
还有一个更隐蔽的问题:addValueCallback的回调是在渲染时求值的,如果你在里面读取主题相关的资源,切换深色模式时它不会自动重新求值。需要手动重新调用一次addValueCallback,或者干脆重建 View。常见的处理方式是在onConfigurationChanged里遍历所有 tab 重新绑定颜色。
6. 排查链路:动画不动、错帧和泄漏怎么一层层剥
6.1 "点了没反应"的完整排查顺序
这个问题问得最多,我把它拆成一个可以直接照着走的排查链路,从最外层往里剥。
第一步,确认 JSON 本身能被渲染。把setAutoPlay和setLoop都设成 true,直接进页面看它动不动。如果不动,问题在资源侧:可能是 raw 文件名带了大写字母、可能是库模块里的 R 引用到了错误的包、可能是 JSON 里用了不支持的特性。这一步能过滤掉大约三成的问题。
第二步,确认进度归零。在上面那个"第二次点击不动"的场景里,加一行日志打lottieView.progress,如果点下去之前就是 1.0,那基本可以确诊。修复方式就是setProgress(0f)。
第三步,确认 View 可见。Lottie 内部会在onVisibilityChanged里根据 View 的可见性暂停和恢复动画。如果你在OnClickListener里调playAnimation(),但这个 View 此刻的isShown还是 false(比如它在一个刚 add 进来、还没 attach 的父容器里),动画会被立刻暂停。表现就是"调用成功了但画面不动"。这种情况可以在post { playAnimation() }里延后一帧再播,或者干脆不用playAnimation,改用ValueAnimator驱动setProgress,完全绕开 Lottie 的可见性逻辑。
第四步,确认系统动画开关。前面提到的"动画程序时长缩放被关掉"会让动画直接跳到终态。测试机上很容易踩,因为很多性能测试工具会顺手关掉系统动画。加一行setIgnoreDisabledSystemAnimations(true)之后世界就清净了。
第五步,确认 min/max progress 没设错。如果之前调过setMinAndMaxProgress(0.6f, 1f)做取消选中,然后又忘了重置回来,下一次playAnimation()就只会在 0.6 到 1 之间跑,视觉上像是"只播了一小段"。这个状态是有记忆的,很容易被忘记。
6.2 错帧、闪烁和"播到一半跳一下"
这类问题通常不是 Lottie 的锅,而是调用方把两套驱动方式混在了一起。典型的错误写法是这样:点击时先playAnimation(),然后又在另一个地方监听选中状态变化调setProgress(0.5f)做过渡效果。两个时间轴同时改同一个属性,渲染出来就是来回跳。
我遇到过最诡异的一次闪烁,最后定位到是addAnimatorUpdateListener里调了setProgress——监听器本身是在动画每帧回调里执行的,你在里面再设进度,等于给正在跑的时间轴打补丁,结果就是帧序错乱。
排查这类问题的办法是在addAnimatorUpdateListener和addLottieOnCompositionLoadedListener里打桩,把每次调用的progress和调用栈打出来,看是不是有多处在写同一个 View。如果确认只有一处,那问题多半在 JSON 本身:检查一下时间轴上是不是有跳变的关键帧,或者某个图层的出入点设置得不对。
还有一类"闪烁"其实和渲染模式有关。硬件渲染模式下,如果动画图形超出了 View 的边界(比如回弹效果),超出部分的表现和软件模式不一致,有时候会看到边缘闪一下。这种情况要么把 View 的实际尺寸放大一圈留出余量,要么退回RenderMode.SOFTWARE。导航栏图标做回弹时,我一般直接把控件尺寸设成目标尺寸的 1.5 倍,用padding或translation视觉上收回来,比调渲染模式省事。
6.3 从内存曲线看出泄漏
LottieAnimationView本身不太会泄漏,但围绕它的几个 API 很容易出事。最常见的两个:addAnimatorUpdateListener持有外部 Activity 或 Fragment 的匿名内部类引用,addLottieOnCompositionLoadedListener也是同样的问题。这两个监听器都不会随着 View detach 自动清空。
我的处理方式是在自定义控件的onDetachedFromWindow里做一次清理:
override fun onDetachedFromWindow() { super.onDetachedFromWindow() lottieView.cancelAnimation() lottieView.removeAllAnimatorUpdateListeners() lottieView.removeAllLottieOnCompositionLoadedListener() }cancelAnimation()那行尤其重要。如果 View 被移除时动画还在跑,Lottie 内部的 animator 会继续持有 View 引用直到动画结束,中间这段时间就是一个活着的泄漏点。虽然最终会释放,但如果用户快速反复切换页面,就能看到内存曲线一路爬升。
判断是不是真的泄漏,最直接的办法是跑一遍 LeakCanary,看LottieAnimationView或者你的自定义控件是否出现在 retained 对象里。如果出现了,再顺着引用链往上找,八成就是某个监听器没清。
还有一个容易被忽略的点:LottieComposition本身持有WeakReference到Context(用于解析图片资源),但如果动画里用了位图图层,那些Bitmap是常驻在 composition 里的。导航栏图标理论上应该是纯矢量,如果 JSON 里出现了位图图层,那就要么让它改成矢量,要么接受这份额外的内存开销——一张 200×200 的位图在 composition 里就是 160 KB,五个 tab 就是 800 KB,纯属浪费。
7. 一套能直接抄的封装思路
7.1 把状态机收进自定义控件里
前面零散讲了很多细节,实际项目里最好把它们收拢到一个自定义 View 里,对外只暴露setSelected(animated: Boolean)和bind(resId, label)两个方法。内部的实现要点:
class NavTabView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { private var selected = false private var boundResId = 0 private var hasAnimatedOnce = false fun bind(resId: Int, label: CharSequence) { if (boundResId == resId) return boundResId = resId lottieView.setAnimation(resId) labelView.text = label lottieView.setProgress(0f) hasAnimatedOnce = false } fun setSelectedState(isSelected: Boolean, animated: Boolean) { if (selected == isSelected) return selected = isSelected labelView.setTextColor(labelColor(isSelected)) if (!animated || !hasAnimatedOnce && !isSelected) { // 首次绑定只摆姿态,不播动画 lottieView.setProgress(if (isSelected) 1f else 0f) } else { hasAnimatedOnce = true lottieView.cancelAnimation() lottieView.setProgress(if (isSelected) 0f else 1f) lottieView.setSpeed(if (isSelected) 1f else -1f) lottieView.playAnimation() } } }这里用了setSpeed(-1f)做反向播放,比单独做一段取消动画更省资源,前提是动画本身是可逆的(选中和取消在视觉上对称)。如果不对称,那就回到第 4 节讲的 min/max progress 分段方案。
hasAnimatedOnce这个标记的作用是让"进入页面时恢复选中态"不触发动画。逻辑上稍微绕:未选中的 tab 第一次绑定不需要播动画,已选中的 tab 第一次绑定也不需要,所以我在!hasAnimatedOnce && !isSelected这个条件之外,把选中项通过setProgress(1f)直接摆到终态。实际写的时候可以再简化,核心思想就是"首次绑定不播"。
7.2 和文字标签的联动节奏
图标在动,文字如果只是硬切颜色,视觉上会脱节。最简单的对齐方式是让文字颜色跟着图标动画同步过渡:
ValueAnimator.ofObject( ArgbEvaluator(), unselectedColor, selectedColor ).apply { duration = 300L // 和 Lottie 动画时长保持一致 interpolator = FastOutSlowInInterpolator() addUpdateListener { labelView.setTextColor(it.animatedValue as Int) } start() }时长必须和动画一致,这个数值建议直接从设计给的动效时长抄过来,不要自己拍。Material Design 3 的动效规范里,导航栏这类状态切换推荐 200–300ms,超过 400ms 就会让用户觉得"卡"。我在项目里统一用 280ms,和图标动画的 300ms 视觉上基本吻合,慢 20ms 用户感知不到。
如果 tab 之间有滑动指示器(那个跟着选中的小横条),它的移动动画也要用同一套时长和插值器。三处动画(图标、文字、指示器)时长不一致,是导航栏动效看起来"廉价"的常见原因,比动画本身粗糙更致命。
7.3 Compose 写法下的差异点
现在不少项目的新页面已经迁到 Compose,导航栏动画在 Compose 里写起来和 View 体系差别挺大。lottie-compose提供的是声明式的进度驱动:
@Composable fun NavTabIcon(selected: Boolean, resId: Int) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(resId)) val progress by animateLottieCompositionAsState( composition = composition, isPlaying = selected, iterations = 1, restartOnPlay = false ) LottieAnimation( composition = composition, progress = { progress }, modifier = Modifier.size(24.dp) ) }这套写法的好处是天然幂等。因为渲染是由progress状态驱动的,快速切换选中态时 Compose 会自己收敛到最终状态,不会出现 View 体系里那种两个 animator 打架的问题。第 4 节讲的"快速点击"那一整类坑,在 Compose 里基本不存在。
代价是animateLottieCompositionAsState的进度管理是"从当前播放到终点",如果你需要精确控制区间(比如只播 0 到 0.4 那一段),还是得自己写一个animateFloatAsState把进度映射到[0f, 0.4f],然后传给LottieAnimation。另外rememberLottieComposition在 Composable 重进时会重新解析,需要配合LottieCompositionSpec.CachedRawRes或者把 composition 提到上层,否则每次都有一小段解析开销。
我个人的建议是:新页面用 Compose 写,就用lottie-compose,代码量少、状态管理干净;如果是存量 View 体系的页面,别为了一个导航栏动画去引 Compose,直接用第 7.1 节那套自定义 View,成本最低。
最后分享一个我在实际项目里验证过的小技巧:把导航栏所有 tab 的 JSON 在Application.onCreate之后、首页onResume之前统一预热一遍,并且用StrictMode检测一下有没有主线程解析。做完这两件事之后,同一套动画在一台 3 GB 内存的老机器上,从"第一次点击有明显延迟"变成了"和旗舰机几乎无差别"。导航栏这种全局常驻、每天被点几百次的控件,值得在启动阶段多花几毫秒。