
1. 导航栏动画为什么要用 Lottie先想清楚这件事Android 底部导航栏这个组件几乎每个 App 都有但绝大多数项目的实现停留在“选中换图标换文字颜色”这个层面。产品经理拿着设计稿过来说这个切换要有点“弹一下”“转一圈”“图标从线条变实心”的动效很多人的第一反应是找现成的开源库或者干脆用 View 动画硬怼。我一开始也是这么干的直到某个项目里设计给了一套包含 12 个图标的完整切换动画每个动画有 40 多帧还带路径变形和颜色渐变那一刻我才真正意识到这套东西不该用代码画。Lottie 就是解决这个问题的工具。它是 Airbnb 开源的一套跨平台动画方案核心思路特别朴素设计师在 After Effects 里做动画装一个叫 Bodymovin 的插件把动画导出成一份 JSON 文件客户端把这个 JSON 丢给 Lottie 的渲染引擎直接按帧还原出来。整个链路里开发不需要逐帧写代码也不需要切几十张序列图一个 JSON 文件通常几十到几百 KB比 GIF 和帧动画的体积小一个量级而且矢量渲染在任何分辨率下都不糊。放到底部导航栏这个场景里Lottie 的价值就更明显了。导航栏的图标尺寸一般固定但动画往往涉及图标形变、外圈扩散、粒子飞散这类效果用矢量方式渲染天然契合。更关键的是导航栏切换是个高频交互用户一秒可能点三次动画必须能随时被打断、随时反向播放、随时从头开始这套控制能力 Lottie 都直接给了playAnimation()、reverseAnimation()、setProgress()、cancelAnimation()这些方法基本够用。相比之下传统帧动画在频繁切换时会疯狂 GC属性动画又很难表达复杂的路径变化。这篇东西适合谁看如果你已经会用 Lottie 加载一个 JSON 让它自动播放但一到导航栏这种“状态驱动”的场景就不知道动画该怎么和选中状态绑定那这篇正好对路。如果你还没接触过 Lottie也没关系我会把从资源准备到状态控制的整条链路都拆开讲中间会补充必要的背景知识。全文基于常见工程实践整理具体参数和目录结构可以按你的项目实际情况调整。2. 整体方案设计与技术选型考量2.1 为什么不直接用属性动画或帧动画在动手之前值得把选型的账算清楚。底部导航栏动画的实现路径大致有三条传统 View 属性动画、帧动画AnimationDrawable 或序列图、Lottie。属性动画的问题在于表达能力上限。ObjectAnimator能改的是平移、缩放、旋转、透明度这些通用属性一旦设计稿要求图标从“房子”形变成“带烟囱的房子”或者选中时有个环形扩散波纹属性动画就得靠ValueAnimator配合自定义 View 的onDraw手算路径工作量陡增而且不同图标要写不同逻辑维护成本直线上升。我见过一个项目为了做“图标弹跳外圈扩散”在一个自定义 View 里写了八百多行绘制代码后来设计改了一版动效全部推翻重来。帧动画的问题是体积和内存。一个 300x300 的动画40 帧 PNG压缩后可能 500KB 到 1MB而且解码后每帧都要占一块 Bitmap 内存导航栏有 4 到 5 个 tab等于同时驻留好几套帧序列中低端机上很容易触发内存抖动。更麻烦的是帧动画不好做“反向播放”和“中途打断”用户快速连点时体验会很怪。Lottie 的优势恰好落在这两个痛点上JSON 描述的是矢量路径和关键帧体积小渲染是实时计算能任意 seek 到某一帧打断和反向都是原生支持的。当然它也有代价后面会讲性能问题但在这个场景下收益明显大于成本。2.2 Lottie 在导航栏场景下的三种落地形态实际项目里Lottie 和底部导航栏结合的方式主要有三种选哪种取决于动效复杂度和交互要求。第一种是图标级替换。导航栏还是用系统的BottomNavigationView或者自己写的 LinearLayout只是把每个 tab 的 ImageView 换成一个LottieAnimationView选中时播放对应动画。这种做法改动最小能复用现有导航栏的选中逻辑缺点是 Lottie 视图和 TextView 的联动要自己处理比如动画播放到某一帧时文字颜色才变。第二种是整体替换。整个导航栏就是一个 Lottie 动画包含所有图标和背景通过setProgress()或者setMinAndMaxProgress()控制播放区间每个 tab 对应 JSON 里的一段帧区间。这种做法视觉一致性最好动画可以跨图标联动但灵活性差图标数量一变就得重新导出而且点击热区要自己算。第三种是混合方案导航栏结构用原生 View 保证点击和可访问性每个 tab 内部嵌一个小 Lottie 做图标动画背景高亮用另一个 Lottie 或者普通 View 做。这是我在实际项目里用得最多的一种平衡了灵活性和视觉表现力。下面这张表把三种形态的关键差异列一下方便你对着自己的需求选。方案形态改动成本视觉上限灵活性适用场景图标级替换低中高tab 数量稳定、动效集中在图标本身整体替换中高低强视觉统一、动画跨 tab 联动混合方案中高中高大多数商业项目的实际选择2.3 状态驱动是这套方案的核心设计思想这一点必须先说透否则后面所有代码都是散的。导航栏动画和“打开页面自动播放一段动画”最大的区别在于它是状态驱动的不是事件驱动的。事件驱动的思路是“用户点了 tab B我就播放 B 的动画”。听起来对但会漏掉很多情况App 从后台恢复时当前 tab 的动画应该是什么状态用户快速连点 A、B、A动画该怎么处理代码里通过setCurrentItem()程序化切换 tab 时要不要播动画如果只用setOnItemSelectedListener里播放这些问题全都会变成 bug。状态驱动的思路是动画的进度是当前选中状态的纯函数。选中就是动画播到最后一帧未选中就是第 0 帧切换过程就是在这两个值之间过渡。这样一来无论切换是用户点击触发的、代码触发的、还是页面恢复触发的只要状态变了视图就同步到对应进度逻辑天然自洽。Lottie 恰好很适合这种模式因为setProgress(float)就是给你干这个的。基于这个思路我在实现里通常会把“选中态进度”和“未选中态进度”定义成常量切换时用ValueAnimator在两者之间插值再把插值结果喂给setProgress()。这样动画的播放速度、是否可打断、是否带弹性全都由这一个 Animator 控制非常干净。提示如果 JSON 是从 AE 导出的完整“未选中到选中”动画通常第 0 帧是未选中态最后一帧是选中态。但 Bodymovin 导出时首尾帧不一定正好是这两个状态建议导出前在 AE 里把时间轴对齐或者在代码里用setMinAndMaxProgress裁掉多余的帧。3. Lottie 资源准备与工程接入的关键细节3.1 从设计稿到可用 JSON 的完整链路资源这一环是最容易出问题的而且问题往往不在开发这边沟通不到位就会反复返工。我把这条链路上真正需要注意的点按顺序列一下。设计侧要做的第一件事是确认动画的时间轴结构。理想的导航栏动画 JSON 应该是一个“双向动画”0 到 100% 是未选中变选中反过来播放就是取消选中。但很多设计师习惯做两个独立动画一个“选中动画”一个“取消选中动画”导出成两个 JSON。这两种做法开发侧的成本差很多单 JSON 双向的方案一个setProgress就搞定双 JSON 的方案切换时要先setAnimation()再playAnimation()会多一次解析开销而且切换瞬间可能出现一帧闪烁。所以我一般会在设计开始前就提出要求一个 tab 一个 JSON且动画可逆。第二件事是画布尺寸和锚点。AE 里的合成尺寸要和导航栏图标的实际显示尺寸保持合理比例锚点Anchor Point要放在图标中心。这一点如果错了Lottie 缩放后会偏移你会看到图标在切换时“跳一下”。判断方法很简单把 JSON 丢进 Lottie 官方预览工具看首帧和末帧的图标位置是否重合不重合就是锚点或位置关键帧有问题。第三件事是图层命名和结构。Bodymovin 导出时如果开了“Glyphs”转换文字图层会转成路径体积更小但不可编辑如果动画里有纯色背景可以让设计改成透明导航栏背景交给原生 View 控制这样主题切换时不用重新导出资源。第四件事是导出的 JSON 体积控制。经验值是单个导航栏图标动画控制在 30KB 以内比较理想。如果超过 100KB通常是这几个原因用了位图素材、路径点太多没优化、关键帧密度过高、开了不必要的表达式。让设计在 AE 里用“简化路径”功能降点或者把不必要的关键帧删掉往往能砍掉一半体积。3.2 依赖引入与初始化配置工程接入这块Lottie 的依赖引入本身没什么坑但版本选择和初始化配置有几个点值得说。dependencies { implementation com.airbnb.android:lottie:6.4.0 }版本方面5.x 到 6.x 有过一次比较大的 API 调整LottieAnimationView的一些方法签名变了比如setAnimation(String)的过时替代、lottie_rawRes属性的支持等。新项目直接用 6.x 就行老项目升级要注意LottieDrawable相关的兼容处理。另外如果你的项目开了 R8 混淆Lottie 自带 consumer rules一般不用额外配置但如果用了lottie_fileName从 assets 加载记得把 JSON 文件放到src/main/assets/下。初始化上我习惯在 Application 里做一次全局配置主要关掉一些默认开启但导航栏场景用不到的能力Lottie.initialize( LottieConfig.Builder() .setEnableSystraceMarkers(false) .build() )另外有个很实用的配置是setRenderMode。Lottie 6.x 支持RenderMode.AUTOMATIC、RenderMode.HARDWARE、RenderMode.SOFTWARE三种。硬件渲染性能好但某些复杂蒙版和混合模式在硬件模式下会渲染异常软件渲染兼容性好但耗 CPU。导航栏动画一般比较简单用AUTOMATIC让它自己判断就行如果遇到渲染黑块或者动画不显示先试试切成SOFTWARE排查。3.3 缓存策略导航栏动画必须提前预加载这是个特别容易忽略的点。Lottie 第一次加载 JSON 时要做解析包括读取 JSON、构建关键帧数据结构、解析路径这个过程在主线程做的话会有明显卡顿尤其是导航栏这种一进首页就要显示的组件用户很可能在解析没完成时就点了 tab结果第一次切换动画是“跳”过去的非常掉价。解决方案是提前预加载并缓存。Lottie 内部有LottieCompositionCache默认缓存大小是 10 个 composition。导航栏 tab 一般 4 到 5 个刚好在缓存范围内但前提是这些 composition 已经被加载过。所以我会在首页onCreate或者更早的时机用LottieCompositionFactory把导航栏所有 JSON 异步加载一遍val tabAnimations listOf(tab_home.json, tab_discover.json, tab_message.json, tab_mine.json) tabAnimations.forEach { fileName - LottieCompositionFactory.fromAsset(context, fileName) .addListener { /* 加载完成已被内部缓存 */ } }这样等用户真正点击时setAnimation()直接命中缓存切换几乎无延迟。实测在低端机上不做预加载时首次切换可能有 80 到 150ms 的延迟预加载后基本感知不到。注意LottieCompositionCache是全局单例缓存的是解析后的 composition 对象不是 Bitmap所以内存占用可控。但如果你的 JSON 特别大或者项目里 Lottie 用得很广比如还有引导页动画、空状态动画建议手动调大缓存或者对导航栏动画单独做一层引用持有避免被其他大动画挤出去。4. 核心实现把动画进度和选中状态绑在一起4.1 单个 tab 的动画视图封装先从一个 tab 的视图开始。我倾向于给每个 tab 写一个自定义 View内部包一个LottieAnimationView和一个TextView对外只暴露一个setSelectedState(selected: Boolean, animate: Boolean)方法。这样导航栏容器只负责分发状态不关心动画细节。class NavTabView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : LinearLayout(context, attrs) { private val lottieView: LottieAnimationView private val labelView: TextView private var progressAnimator: ValueAnimator? null private val selectedProgress 1f private val unselectedProgress 0f init { orientation VERTICAL gravity Gravity.CENTER // 布局代码略实际就是 addView 一个 LottieAnimationView 和一个 TextView } fun bindAnimation(composition: LottieComposition) { lottieView.composition composition lottieView.progress unselectedProgress } fun setSelectedState(selected: Boolean, animate: Boolean true) { progressAnimator?.cancel() val target if (selected) selectedProgress else unselectedProgress if (!animate) { lottieView.progress target updateLabel(selected) return } val current lottieView.progress progressAnimator ValueAnimator.ofFloat(current, target).apply { duration 300L interpolator FastOutSlowInInterpolator() addUpdateListener { lottieView.progress it.animatedValue as Float } addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { updateLabel(selected) } }) start() } } private fun updateLabel(selected: Boolean) { labelView.setTextColor( ContextCompat.getColor(context, if (selected) R.color.nav_selected else R.color.nav_normal) ) } }这段代码有几个设计取舍值得展开说。为什么用ValueAnimator驱动setProgress而不是用playAnimation()。因为playAnimation()是“从当前进度播到结束”它不关心目标状态而导航栏需要的是“精确走到某个进度”。如果用户在中途连点playAnimation()的行为是继续从当前位置播可能方向反了而ValueAnimator每次都从当前进度插值到目标进度方向天然正确。而且插值器可以自己指定想做弹性效果换个OvershootInterpolator就行不用改动画资源。为什么在onAnimationEnd里才更新文字颜色。如果文字颜色和图标动画同时变视觉上文字会“抢戏”。让动画先走完再变文字观感更舒服。当然这个要看设计意图如果设计要求同步变把updateLabel提到addUpdateListener里按进度插值颜色也行。为什么progressAnimator?.cancel()放在最前面。这是防连点的关键。每次状态变化先取消上一个 animator保证同一时刻只有一个动画在跑。cancel()会触发onAnimationCancel和onAnimationEnd所以要注意别在onAnimationEnd里做副作用太重的操作比如更新 View 可见性可能被意外调用。4.2 导航栏容器如何分发状态容器层的职责很单一维护“当前选中是哪个 tab”并在变化时通知所有 tab 更新状态。这里我用一个简单的自定义 LinearLayout 实现不依赖 Google 的BottomNavigationView因为后者的 item 结构不好插入 Lottie 视图。class LottieNavBar JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : LinearLayout(context, attrs) { private val tabs mutableListOfNavTabView() private var currentIndex 0 var onTabSelected: ((Int) - Unit)? null fun setTabs(items: ListNavItem) { removeAllViews() tabs.clear() items.forEachIndexed { index, item - val tab NavTabView(context) tab.bindAnimation(item.composition) tab.setOnClickListener { selectTab(index) } addView(tab, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) tabs.add(tab) } selectTab(currentIndex, animate false) } fun selectTab(index: Int, animate: Boolean true) { if (index !in tabs.indices) return val changed index ! currentIndex currentIndex index tabs.forEachIndexed { i, tab - tab.setSelectedState(i index, animate (changed || !animate)) } if (changed) onTabSelected?.invoke(index) } }这里有个细节selectTab里判断了changed只有真的换了 tab 才播动画。但要注意首次初始化和程序化调用setCurrentItem时不应该播动画所以加了animate参数控制。另外changed || !animate这个条件看起来有点绕实际是想表达“如果是非动画模式所有 tab 都直接设到目标状态”。如果你觉得难读直接拆成两个分支写清楚也行可读性比炫技重要。点击热区的处理也顺手提一下。因为每个 tab 是weight1平分宽度点击区域是整块符合用户预期。但如果 Lottie 动画里有超出边界的效果比如选中时有个外圈扩散要给父容器设clipChildren false和clipToPadding false否则动画会被裁掉。4.3 和 ViewPager2 的双向联动真实项目里导航栏很少独立存在通常要和ViewPager2或者Fragment容器联动。联动有两个方向点击 tab 切页面、滑动页面切 tab。滑动切 tab 这个方向要特别注意动画的实时性。如果用ViewPager2的OnPageChangeCallback在onPageSelected里切 tab动画只会在页面切换完成后才播用户滑动过程中导航栏是静止的割裂感很强。更好的做法是用registerOnPageChangeCallback的onPageScrolled拿到positionOffset实时喂给相邻两个 tab 的setProgress做到“滑动多少图标形变多少”。viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled(position: Int, offset: Float, offsetPixels: Int) { if (offset 0f) return val fromTab tabs.getOrNull(position) ?: return val toTab tabs.getOrNull(position 1) ?: return fromTab.setProgressDirect(1f - offset) toTab.setProgressDirect(offset) } override fun onPageSelected(position: Int) { navBar.syncSelected(position) } })这里需要给NavTabView暴露一个setProgressDirect(progress: Float)内部直接调lottieView.progress progress不经过 Animator也不更新文字颜色。文字颜色可以在onPageSelected里统一更新避免滑动过程中颜色来回闪。提示实时联动时有个坑onPageScrolled在offset 0f时也会回调而且是在动画结束的瞬间。如果你在里面无条件设置进度会把 Animator 驱动的动画直接打断。所以要么加offset 0f的提前返回要么在点击驱动的切换过程中用一个标志位屏蔽掉 scrolled 回调。我一般用前者简单可靠。5. 性能优化与真机实测的那些坑5.1 渲染性能和内存占用的实测数据Lottie 不是免费的午餐尤其是在导航栏这种常驻组件上性能问题会被放大。我在一台中端机骁龙 6 系8GB 内存和一台三年前的低端机上做过对比测试数据大致如下供你有个量化的预期。测试项中端机低端机备注单 tab JSON 首次解析耗时18ms45ms大小约 24KB含路径形变缓存后 setAnimation 耗时2ms 以内5ms 左右命中 LottieCompositionCache单次切换动画渲染帧率58 到 60fps45 到 55fps动画时长 300ms四个 tab 常驻内存增量约 6MB约 6MB主要是 composition 和 drawable快速连点 10 次后的 GC 次数0 到 1 次2 到 3 次未做防抖时会更多从数据看中端机基本无压力低端机的帧率会有波动但可接受。真正需要注意的是内存——四个 tab 各持有一个 Lottie drawable加上缓存里的 composition常驻增量在 6MB 左右。如果项目里还有别的 Lottie 场景总量可能到 15MB 以上这时候就要考虑按需释放了导航栏所在的 Activity 不可见时可以调lottieView.clearAnimation()释放动画资源但保留 composition 缓存回来时重新绑定即可。另外一个容易踩的坑是动画复杂度和渲染模式的关系。如果 JSON 里用了大量蒙版、轨道遮罩、混合模式硬件渲染在某些机型上会渲染错误表现为动画区域黑屏或者只显示一部分。判断方法在开发者选项里打开“GPU 渲染模式分析”看切换动画时有没有异常的长条或者直接代码里强制setRenderMode(RenderMode.SOFTWARE)对比效果。如果软件模式正常、硬件模式异常基本可以确认是渲染兼容问题这时候要么改动画资源让设计少用复杂蒙版要么就在这个场景固定用软件渲染。5.2 快速连点导致的状态错乱怎么破这个问题我在两个项目里都遇到过表现是用户快速点 A、B、A最后停在了 A但 B 的图标是选中态或者两个图标都停在半途。原因通常是状态更新和动画执行没有对齐。根源在于前面提到的cancel()时序。假设点 A 时启动 animator 从 0 到 1点了 50ms 后点 Bcancel()掉 A 的 animator此时 A 的进度停在 0.3 左右然后 B 的 animator 从 0 到 1 启动。但如果 A 的onAnimationCancel之后又收到了onAnimationEnd有些实现里会这样updateLabel可能被错误调用。另外如果容器层和 tab 层的状态判断有偏差也可能出现两个 tab 都认为自己是选中态。我的处理方式是让状态只有一个源头。容器维护currentIndex每次selectTab时无条件遍历所有 tab 设置setSelectedState(i index)不依赖 tab 自己判断。同时给点击加一个短防抖比如 200ms 内忽略重复点击private var lastClickTime 0L private fun onTabClick(index: Int) { val now SystemClock.uptimeMillis() if (now - lastClickTime 200L) return lastClickTime now selectTab(index) }防抖窗口设 200ms 是我试出来的经验值小于 150ms 用户能感知到“点了没反应”大于 250ms 连点体验又会变钝。200ms 左右既能挡住误触连点又不影响正常操作节奏。另外ValueAnimator本身在 API 26 以上支持setCurrentFraction如果要做更精细的中断控制可以在cancel后手动progress到目标值避免停在中间态。5.3 主题切换和深色模式下的资源适配导航栏动画在深色模式下经常翻车原因就一个JSON 里的颜色是写死的。Lottie 支持颜色替换但需要 JSON 里的图层有命名或者你提前知道要改哪个图层的哪个属性。比较稳妥的做法是让设计导出时就用透明度或纯白/纯黑颜色由代码在渲染时叠加。Lottie 提供了addValueCallback可以在运行时替换指定图层的颜色lottieView.addValueCallback( KeyPath(icon, **), LottieProperty.COLOR ) { colorResolver }这里的KeyPath要对着 JSON 的图层结构写所以前面强调图层命名要规范。如果动画结构复杂不好定位还有个更省事的办法用ColorFilter整体染色lottieView.colorFilter PorterDuffColorFilter(...)适合单色图标。缺点是如果动画里有多种颜色会被统一染色所以只适合纯色风格的导航栏。深色模式切换的时机也要注意。如果在 Activity 的onConfigurationChanged里重建Lottie 会重新绑定颜色回调自然生效如果配置了android:configChanges不重建就需要手动监听并刷新颜色。我一般前者让系统重建最省心导航栏动画重新加载也就几十毫秒的事。6. 常见问题速查与排查思路6.1 动画不显示、错位、闪烁的排查表这几种症状在接入 Lottie 的初期几乎都会遇到我把排查路径整理成表按出现频率排序。症状表现最可能原因排查方法解决方式完全空白无任何绘制JSON 路径写错或资源未打包检查fromAsset/fromRawRes返回是否 success核对文件名大小写、assets 目录位置显示但动画不动设置了 progress 但没触发更新检查是否autoPlay关闭后忘了驱动确认 Animator 是否启动、progress 是否被覆盖首帧或末帧位置偏移AE 锚点位置不对用官方预览工具对比首末帧让设计重设锚点重新导出切换瞬间闪一下白视图背景或缓存释放问题看是否频繁setAnimation重建 drawable预加载 composition复用同一个实例快速连点后状态错乱动画中断时序和状态源不一致打日志看onAnimationEnd调用次数统一状态源加点击防抖部分区域黑块硬件渲染不支持该混合模式切成 SOFTWARE 模式对比简化动画或固定软件渲染文字颜色和图标不同步更新时机不一致检查updateLabel调用位置统一在动画结束或按进度插值其中“显示但动画不动”这个我多解释一句。Lottie 有个容易被忽略的行为如果你在 XML 里设了lottie_autoPlayfalse又在代码里只调了setProgress那是没问题的但如果同时设了lottie_progress属性代码里的setProgress在初始化时可能被属性值覆盖导致你以为设置了但实际没生效。排查时打一行日志把lottieView.progress和lottieView.speed打出来通常就清楚了。6.2 几个非常规但很有用的实操心得心得一用setProgress的同步调用做“无动画初始化”。有些团队会给导航栏加一个首次展示的入场动画然后又希望程序化切换时不带过渡。我试过比较麻烦的做法是准备两套逻辑后来发现直接在首次bind时不启动 Animator只progress target视觉上就是瞬间到位没有任何闪烁。这个技巧在页面恢复onResume时也一样适用避免用户从其他页面回来看到导航栏“重播”一遍。心得二动画时长不要照搬设计给的 500ms。设计工具里预览动画通常按 500ms 或 600ms 做但在真机上导航栏切换超过 350ms 就会让用户觉得“卡”。我通常压到 250 到 300ms用FastOutSlowInInterpolator保证起步快、收尾稳体感上反而更利落。如果设计坚持要长动画可以考虑把动画拆成“快速形变 缓慢细节”两段但导航栏场景一般没必要。心得三给 Lottie 视图设固定尺寸别用 wrap_content。Lottie 的测量逻辑在wrap_content下会按 JSON 的画布尺寸来如果画布是 300x300 而导航栏图标实际只显示 48dp你会得到一个巨大的图标。正确做法是在布局里写死宽高或者用scaleType CENTER_INSIDE配合固定尺寸。心得四调试时打开 Lottie 的调试覆盖层。在 XML 里加lottie_enableMergePathsForKitKatAndAbove之类的属性是一方面更实用的是把LottieAnimationView的setOutlineMasksAndMattes相关开关打开看蒙版边界很多“动画形状不对”的问题其实是蒙版路径问题看边界一目了然。心得五JSON 版本兼容性要实测。Bodymovin 导出的 JSON 有版本字段Lottie 不同版本对高版本 JSON 的支持程度不一样。我曾经遇到过一个 JSON 在 6.0 上正常、在 5.2 上部分图层丢失的情况原因是用了新版本才支持的特性。解决办法要么升级 Lottie 依赖要么让设计用低版本 Bodymovin 重新导出。这个坑很难从报错里看出来只能靠实测对比。7. 写在这套方案落地之后的几点个人体会导航栏动画这个需求做之前觉得是“锦上添花”做完之后发现它对产品质感的提升比预期大。用户在那个位置每天要点几十次动画顺不顺、快不快、会不会卡感知非常直接。我自己的经验是Lottie 这套方案的成败不在代码而在两头一头是设计产出 JSON 的规范性另一头是状态管理的清晰度。代码本身其实就百来行ValueAnimator加setProgress的组合翻来覆去就那几种写法。真正花时间的是和设计对齐时间轴、排查锚点偏移、处理连点时序这些琐碎事。最后分享一个我最近在用的扩展思路把 tab 切换动画和页面的转场动画做成一套“共享节奏”。比如导航栏动画 300ms页面内容做一个同样 300ms 的淡入位移两者用同一个插值器。视觉上会形成一种整体感用户会觉得整个 App 的切换是“一个动作”而不是导航栏动它的、页面动它的。这个改动成本很低但效果提升挺明显值得试一试。