Android 导航栏 Lottie 动画落地与性能优化实战 设计师把动效预览丢过来一段屏幕录制底部导航的图标在选中时先缩一下、再弹开房子的轮廓顺势变成放大镜松开手指的瞬间线条回弹带一点过冲。你盯着那段视频看了三遍然后打开项目发现当前的导航栏还是三个ImageView加Selector换图切换靠setImageResource。这种落差几乎每个 Android 开发都遇到过——动效在预览里很轻落地时却重得要命。Lottie 就是在这个缝隙里成为事实标准的工具它把 After Effects 里的矢量动画导出成一份 JSONAndroid 端用LottieAnimationView渲染体积通常只有几十 KB还能在运行时改颜色、绑进度、反向播放。这篇内容围绕的就是用 Lottie 做一套能上线的 Android 导航栏动画这件事从选型判断、动效资源怎么跟设计对齐、LottieAnimationView怎么塞进导航栏骨架到选中态同步、内存与首帧优化再到动画不播放、错帧、泄漏这些真刀真枪的排查链路。不管你是第一次接 Lottie还是已经用过但总是被第二次点击不动了换了深色模式图标全黑这类问题绊住下面这些内容应该都能直接拿去用。1. 导航栏动效选型为什么最后是 Lottie而不是别的1.1 四种实现路径摆在一起对比才看得清代价导航栏动画看起来只是个图标动一下的小需求但真正决定实现方案的是动效本身长什么样。如果只是选中时图标放大 1.1 倍再加个透明度渐变那用ObjectAnimator或者ViewPropertyAnimator十行代码就完事硬上 Lottie 属于杀鸡用牛刀。麻烦的是形变动画——比如房子轮廓逐渐变成搜索放大镜路径上的锚点要平滑过渡这种在属性动画体系里几乎无解只能靠帧动画或者自己用PathValueAnimator做插值。我把常见几条路线列个表这是我在几个项目里反复权衡之后的心得不是理论推演方案单个图标体积能否动态改色复杂路径形变实现成本运行性能属性动画缩放/位移/透明度0天然支持不支持低极好帧动画WebP/GIF 序列数百 KB 起不能支持中差大图易 OOMSVG PathMeasure 手写几 KB支持支持但代码量大高好Lottie20–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。如果每份都几百 KBres/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 把一条时间轴拆成两段前面提到可以把选中和取消选中做成一条时间轴的上下两段具体实现就是靠setMinAndMaxProgressfun 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 KB68 ms约 1.2 MB62 KB1419 ms约 3.1 MB190 KB4174 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: ListInt) { 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 设colorFilterlottieView.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 内存的老机器上从第一次点击有明显延迟变成了和旗舰机几乎无差别。导航栏这种全局常驻、每天被点几百次的控件值得在启动阶段多花几毫秒。