☰
微信小程序 Lottie 动画:lottie-miniprogram 渲染优化
2026/10/1 5:35:04 网站建设 项目流程

上个月接了个活动页需求,设计师甩过来一个 1.6MB 的 Lottie JSON,转盘加撒花两个动效,要求"按钮按下去要跟手、抽中大奖那一下要炸开"。这基本就是微信小程序使用 lottie 动画最典型的场景:既要矢量级的清晰度,又要能实时控制播放进度,还不肯接受 GIF 那种糊成一团的画质。而真正落到小程序里,绕不开的就是lottie-miniprogram这个适配库。

它做的事情说穿了很简单:把 lottie-web 的 canvas 渲染器从浏览器环境里抠出来,把宿主能力换成小程序那一套。但简单归简单,从 npm 安装到第一帧画面出现在真机上,中间有太多官方 README 不会写、但踩一次就够你排查半天的细节——包体积红线、dpr 与画布模糊、基础库版本、网络 JSON 的缓存、页面退后台不暂停导致的内存爬升。

这篇就按我实际项目里的顺序走一遍:先讲清它到底替代了什么、边界在哪,再给一套能直接抄的最小可运行链路,然后重点解决 JSON 从哪里来、怎么瘦身、怎么做到不发版就能换动效,最后把真机上才会暴露的那几个问题逐个拆开。看完你应该能独立把一个 Lottie 动效稳稳当当地放进小程序里。

1. lottie-miniprogram 在小程序里究竟替我们干了什么

1.1 lottie-web 为什么在小程序里连初始化都过不去

把 lottie-web 直接打进小程序代码包,最常见的报错是document is not defined,或者走到 SVG 渲染器时直接歇菜。原因不复杂:lottie-web 的两条渲染路径,一条依赖 SVG DOM(createElementNS一整套),另一条依赖document.createElement('canvas')、window.requestAnimationFrame、Image、XMLHttpRequest、fetch这些宿主能力。

小程序的架构是逻辑层加渲染层分离,逻辑层跑的是 JSCore / V8,没有 DOM 也没有 BOM,它拿不到任何真实节点。你想用 canvas,唯一入口是wx.createSelectorQuery().select('#id').node(),从渲染层把 canvas 组件实例"借"到逻辑层来用。这个模型和浏览器完全不同,所以 lottie-web 里那些"先document.createElement再挂上去"的代码天然跑不通。

lottie-miniprogram 的做法是把 lottie-web 里的 canvas 渲染器单独拎出来,然后做三件事的替换:画布由外部通过lottie.setup(canvas)注入,而不是自己创建;时间轴驱动改用 canvas 节点自带的requestAnimationFrame(canvas 2d 节点上是有这个方法的,很多人不知道);资源加载这条链路需要你自己兜底,因为小程序里没有 fetch。

1.2 三个必须先接受的前提

在决定用之前,有三条硬约束得先认下来,它们决定了你的方案会不会中途翻车。

第一,必须使用 canvas 2d,也就是<canvas type="2d">,基础库要求 2.9.0 以上。旧版的wx.createCanvasContext(那个canvas-id时代的接口)不行,因为 lottie 需要拿到真实的 canvas 节点和 2d context 对象。

第二,渲染完全依赖 canvas,没有 DOM 层可以叠加。这意味着你用 canvas 画出来的东西不能像普通view那样随意做 CSS 变换、不能直接盖在原生组件上(除非是同层渲染或者用 cover-view 兜底)。

第三,JSON 得你自己准备,库不管压缩、不管转换、不管缓存。它只负责"给我一份合法的 Lottie JSON,我给你画出来"。所有关于体积和加载的活儿,都在你身上。

1.3 和其他动效方案的横向对比

很多团队一上来就想用 Lottie,其实有些场景用不着。我把实际项目里评估过的四套方案列一下,方便你对自己项目做判断。

方案体积表现清晰度可控性开发成本主要风险
GIF / APNG差,动辄几百 KB 到数 MB差,有锯齿和色带极低,只能播放低画面质量通常过不了设计验收
帧序列图(雪碧图)中,取决于帧数和分辨率好中,需要自己写播放器中内存占用高,长动画容易打爆
CSS / WXS 动画极低好中高,复杂动效很难还原只能做简单位移缩放,缓动难对齐
Lottie可优化到很小矢量级高,可控制进度、速度、方向低受 canvas 2d 能力限制,部分 AE 特性失效

结论很清楚:结构复杂、要触发控制、要跟随滚动的话,Lottie 是最优解;但如果只是一个"淡入淡出加旋转"的简单效果,用 CSS 动画反而更省事,别为了技术而技术。

2. 从安装到第一帧画面:最小可运行链路

2.1 安装依赖与"构建 npm"这个绕不过去的动作

依赖本身一行命令:

npm install lottie-miniprogram --save

真正容易卡住的是下一步。小程序开发者工具默认是不会去读node_modules的,你必须在菜单里执行工具 → 构建 npm,构建完成后项目根目录会多出一个miniprogram_npm文件夹,里面才是真正能被打包进小程序的那份代码。如果本地设置里没勾选"使用 npm 模块",构建按钮可能是灰的,先在详情 → 本地设置里把它打开。

这里有个特别常见的坑:每次改动了package.json、升级了依赖版本、或者切换了分支,都要重新构建一次。我在团队协作里见过不止一次"我这边明明跑得好好的,你那边就是报lottie.setup is not a function",最后发现是对方拉完代码没重新构建 npm,引用到的还是旧目录甚至根本不存在。所以我现在会在提交说明里单独写一句"本次改动依赖,请重新构建 npm"。

再提醒一点,构建产物目录不要手动去改,也不要把它加进.gitignore之后又指望别人能跑起来——最稳妥的做法是把构建这步写进 README 的启动步骤里,让它在流程里固化下来。

2.2 WXML 里 canvas 的写法,尺寸必须落到 style 上

<canvas type="2d" id="lottie-canvas" style="width: 320px; height: 320px;" ></canvas>

三件事必须注意。type="2d"不能省,省了拿到的是旧接口的 canvas,没有getContext('2d')这套现代方法。id必须唯一且和逻辑层的选择器一致,页面里有多个 canvas 时尤其要小心复制粘贴改漏。宽高一定要显式写在style上,不要指望用flex: 1撑开——后面查询节点尺寸时如果拿到 0,画面就是一片空白,而且这种问题在开发者工具里往往不报错,特别难查。

还有一个隐性问题:不要用wx:if把 canvas 包在里面。如果你在onReady里就去查节点,而wx:if的条件此时还是 false,canvas 根本没渲染,select的结果是null。要么用hidden,要么把查询动作推迟到条件为真的时刻。

2.3 逻辑层:拿节点、按 dpr 放大、setup、loadAnimation

下面这段是我在项目里反复验证过的最小链路,可以直接抄:

const lottie = require('lottie-miniprogram') Page({ onReady() { this.initLottie() }, initLottie() { wx.createSelectorQuery() .select('#lottie-canvas') .fields({ node: true, size: true }) .exec((res) => { const info = res && res[0] if (!info || !info.node) { console.warn('canvas 节点未就绪,检查 wx:if 与 id') return } const canvas = info.node const ctx = canvas.getContext('2d') const winInfo = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() const dpr = winInfo.pixelRatio || 2 canvas.width = info.width * dpr canvas.height = info.height * dpr lottie.setup(canvas) this.ani = lottie.loadAnimation({ loop: true, autoplay: true, animationData: require('../../assets/lottie/celebrate.js'), rendererSettings: { context: ctx, }, }) }) }, })

逐行说一下为什么这么写。

用.fields({ node: true, size: true })一次把节点和尺寸都取回来,比先node()再boundingClientRect()发两次查询要好。小程序的逻辑层和渲染层之间是异步通信,每多一次查询就多一次往返开销,在页面初始化这种敏感阶段能省就省。

canvas.width = info.width * dpr这一步是为了清晰度。canvas 的width/height属性是"绘制缓冲区尺寸",style里的宽高是"显示尺寸",两者不一致时浏览器(小程序同理)会做缩放。如果缓冲区只有 320×320,在 3 倍屏上就等于用 320 个像素去铺 960 个物理像素,边缘必然发虚。把缓冲区按 dpr 放大后,Lottie 按缓冲区尺寸绘制,再缩回显示尺寸,视觉上就是锐利的。

lottie.setup(canvas)必须在loadAnimation之前调用,它负责把宿主画布注入渲染器、初始化内部的渲染上下文。rendererSettings.context里传的是我们刚拿到的 2d context,这两步经常有人漏一个,结果就是白屏。

关于animationData,这里有个硬性限制:小程序的逻辑层不能直接require一个.json文件。你必须把 JSON 转成 JS 模块,也就是文件内容前面加上module.exports =,后缀改成.js。手工改一次两次还行,动效多了就很痛苦,我一般写个小脚本,从设计给的目录批量转换:

// tools/build-lottie.js const fs = require('fs') const path = require('path') const srcDir = path.resolve(__dirname, '../design/lottie') const outDir = path.resolve(__dirname, '../assets/lottie') fs.readdirSync(srcDir).forEach((file) => { if (!file.endsWith('.json')) return const raw = fs.readFileSync(path.join(srcDir, file), 'utf8') const name = file.replace(/\.json$/, '.js') fs.writeFileSync(path.join(outDir, name), `module.exports = ${raw}\n`) console.log('生成', name) })

跑一次,assets/lottie目录下就全是可以直接 require 的模块了。不过要注意,这只是把 JSON 从磁盘搬进了代码包,代码包体积的问题一点都没解决,这部分在第 4 节专门讲。

2.4 自定义组件里使用:选择器必须限定作用域

把动效封装成组件是更规范的做法,但组件里有个坑:wx.createSelectorQuery()默认只在页面范围里找节点,组件内部的 id 它查不到。必须加.in(this):

Component({ ready() { wx.createSelectorQuery() .in(this) .select('#comp-canvas') .fields({ node: true, size: true }) .exec((res) => { const info = res[0] if (!info || !info.node) return const canvas = info.node const dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2 canvas.width = info.width * dpr canvas.height = info.height * dpr lottie.setup(canvas) this.ani = lottie.loadAnimation({ loop: true, autoplay: true, animationData: this.data.animationData, rendererSettings: { context: canvas.getContext('2d') }, }) }) }, lifetimes: { detached() { if (this.ani) { this.ani.destroy() this.ani = null } }, }, })

detached里销毁实例这一步很多人会忘。组件被反复切换、页面被重复进入时,未销毁的实例会一直持有 canvas 节点和解析后的 JSON 对象,内存只涨不降。我这边做过一个粗略观察:一个中等复杂度的动效反复进出页面十几次,不做销毁的版本内存曲线明显是往上爬的,加上销毁之后基本能拉平。

2.5 常见报错与对应处理

报错 / 现象最可能的原因处理方式
lottie.setup is not a function没执行构建 npm,或引用了错误路径重新构建 npm,确认miniprogram_npm/lottie-miniprogram存在
回调里res[0]是null节点未渲染、被wx:if包住、id 写错改用 hidden,核对 id,把查询放到条件成立之后
画面全白,无任何报错漏传rendererSettings.context,或画布宽高为 0打印info.width,确认 style 有显式宽高
画面能出来但发虚没有按 dpr 放大绘制缓冲区按 2.3 的写法设置canvas.width/height
开发者工具正常,真机不动基础库低于 2.9.0,或微信版本过旧工具里把调试基础库调到 2.9.0 以上,真机升级微信
内容被裁切或有大量留白画布宽高比与 JSON 的 comp 尺寸比例不一致让画布等比于 JSON 的w/h,或调整 JSON 的合成尺寸

3. Lottie JSON 从哪来:AE 导出与体积治理

3.1 导出环节就要做对:Bodymovin 的几个关键开关

动效一般是设计师在 After Effects 里做完,通过 LottieFiles 的 AE 插件(Bodymovin 的后续维护版本)导出。这一步如果不管,后面全是坑。我一般会在给设计师的规范里写清三条硬要求。

第一条,导出前把所有表达式转成关键帧。AE 里的表达式(Expressions)本质是运行时的 JS 代码,lottie-web 在浏览器里靠eval执行,而小程序的 JS 沙箱不允许动态执行代码。带了表达式的图层在小程序里表现是"静止不动"或者"直接跳变",而且不报错。转换方式是在属性上右键,选择"将表达式转换为关键帧",确认之后表达式就变成了实打实的关键帧数据。

第二条,文字图层一律转成形状。文字图层在小程序里的字体依赖完全不可控,缺字体时要么显示成方块,要么字重字距全乱。做法是选中文字图层,右键"从文本创建形状",然后把原文字图层删掉。转完之后体积会涨一点,但可控性完全不同。

第三条,别用外链图片资源。Lottie 支持把图片作为外部资源引用,但小程序里无法按那个相对路径去加载。要么在插件里选择"合并图片到 JSON"(会变成 base64,体积暴涨),要么干脆把图片重绘成矢量形状。

另外我强烈建议按动画单元拆分导出。一个页面里"按钮基础态动效"和"抽奖爆炸动效"显然是两个独立的 JSON,别让设计师导出成一个包含所有状态的大文件,那样你连按需加载都做不了。

3.2 小程序 canvas 2d 渲染不了的 AE 特性

这张表我建议直接发给设计师,能省掉大量来回沟通。

AE 特性小程序里的表现处理建议
表达式 Expressions完全不生效,不报错导出前转换为关键帧
文字图层字体缺失、位置错乱转为形状图层
图层效果(投影、模糊、发光)不支持,直接丢失在 AE 里烘焙成形状叠加,或改由容器层做阴影
3D 图层不支持,会被拍平且常变形拍平后再导出,或改用 2D 表达
轨道遮罩(Luma 类)部分异常,可能出现全黑优先改造成 Alpha 遮罩
混合模式部分支持,行为与 AE 有差异减少使用,或改为直接调色
蒙版路径支持,但顶点越多越吃性能简化路径,减少顶点数
外链图片资源无法加载内嵌 base64 或重绘为形状
时间重映射支持有限,长动画易错位拆成多段独立动画

其中"图层效果不支持"是设计师最容易误判的一条。他们在 AE 里加个投影觉得画面很立体,导出后到小程序里发现投影没了,第一反应是"你们实现有问题"。提前把表格发过去,这类争论基本就消失了。

3.3 把 1.6MB 压到 100KB 以内的实操路径

1.6MB 的 JSON 放在代码包里,主包直接超限,上传都上传不了。我实际处理下来,一般能压到原来的 5% 到 15%。具体手段和收益大致是这样的:

优化手段典型收益代价与注意事项
降低数字精度(小数点后 3 位砍到 1 至 2 位)10% 到 30%视觉上几乎看不出差别,优先做
删除隐藏图层与无用图层视工程而定,常有惊喜AE 里隐藏的图层默认仍会导出,一定要在插件里关掉
合并关键帧、改用缓动曲线30% 到 60%需要重新调缓动,要设计师配合
图片资源重绘为矢量形状幅度最大重绘工作量取决于图形复杂度
按动画阶段拆分成多个 JSON首屏体积明显下降请求数增加,需要加载策略配合
使用在线优化工具二次压缩5% 到 15%优化后务必逐帧比对,防止细微形变

我的操作顺序是:先删隐藏图层,再降精度,再看关键帧密度,最后才是考虑拆图。前两步几乎零风险,很多时候光这两步就能从 1.6MB 降到 600KB 左右;真正要动图层的活儿放到最后,因为那意味着返工。

提示:压缩之后一定要用开发者工具和真机各跑一遍完整动画,重点看首尾帧和颜色过渡。在线优化工具偶尔会把渐变的停止点舍入过头,导致颜色出现肉眼可见的断层。

4. 动画资源不要塞进主包:网络加载与本地缓存方案

4.1 先算清这笔账

小程序主包的大小上限是 2MB,整个小程序所有分包合计上限目前是 20MB(以官方最新文档为准)。一个活动页的动效 JSON 压完还有 300KB 到 500KB,再加上业务代码、图片、字体,主包 2MB 的红线几乎必然被击穿,结果就是代码上传时直接报体积超限,连提交审核的机会都没有。

放分包能缓解主包压力,但分包本身也有 2MB 限制,而且用户首次进入这个分包时仍然要下载,体验上只是把等待从"启动"挪到了"点进去"。更麻烦的是,动效的迭代频率往往远高于发版频率:运营想在活动第二天把主视觉的配色从金色换成红色,如果 JSON 在包里,你就得重新提交审核,链路太长了。

所以我的结论是:除了极少数几 KB 的微动效,Lottie JSON 一律走 CDN,配合本地文件缓存。这样换动效只需要替换 CDN 上的文件,前端代码一行不改。

4.2 落地代码:下载、读取、解析、渲染

小程序提供了wx.env.USER_DATA_PATH这个本地用户目录,可以持久化写文件,非常适合做这个缓存。wx.downloadFile支持指定filePath,直接把文件落到我们指定的路径上,省去一次读写。

const lottie = require('lottie-miniprogram') const CACHE_DIR = `${wx.env.USER_DATA_PATH}/lottie` const fs = wx.getFileSystemManager() function ensureDir() { try { fs.accessSync(CACHE_DIR) } catch (e) { try { fs.mkdirSync(CACHE_DIR, true) } catch (err) { console.warn('创建缓存目录失败', err) } } } function localPath(version, name) { return `${CACHE_DIR}/${name}_${version}.json` } // 返回一个 Promise,resolve 出解析后的 JSON 对象 function loadLottieJson(name, version, cdnUrl) { ensureDir() const filePath = localPath(version, name) return new Promise((resolve, reject) => { // 命中缓存,直接读本地 try { fs.accessSync(filePath) const content = fs.readFileSync(filePath, 'utf8') resolve(JSON.parse(content)) return } catch (e) { // 未命中,走下载 } wx.downloadFile({ url: cdnUrl, filePath, success(res) { if (res.statusCode !== 200) { reject(new Error(`下载失败 ${res.statusCode}`)) return } try { const content = fs.readFileSync(filePath, 'utf8') resolve(JSON.parse(content)) } catch (err) { reject(err) } }, fail(err) { reject(err) }, }) }) }

页面里配合使用:

Page({ data: { lottieData: null }, onLoad() { const version = 'v3' loadLottieJson('celebrate', version, `https://your-cdn.com/lottie/celebrate.json?v=${version}`) .then((json) => { this.setData({ lottieData: json }) this.tryRender() }) .catch((err) => { console.error('动效加载失败,降级为静态图', err) this.setData({ fallback: true }) }) }, onReady() { this.canvasReady = true this.tryRender() }, tryRender() { if (!this.canvasReady || !this.data.lottieData || this.ani) return wx.createSelectorQuery() .select('#lottie-canvas') .fields({ node: true, size: true }) .exec((res) => { const info = res[0] if (!info || !info.node) return const canvas = info.node const dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2 canvas.width = info.width * dpr canvas.height = info.height * dpr lottie.setup(canvas) this.ani = lottie.loadAnimation({ loop: true, autoplay: true, animationData: JSON.parse(JSON.stringify(this.data.lottieData)), rendererSettings: { context: canvas.getContext('2d') }, }) }) }, })

注意这里我用了this.canvasReady和this.data.lottieData两个门闩,谁先到都无所谓,最后一个到达的时候触发渲染。这比"在 onReady 里发请求现下载现渲染"要好得多,因为那样用户会看到一个明显的空白等待期。

4.3 缓存策略怎么选

不同使用频率的动效,策略应该不一样,一刀切会浪费本地空间。

使用场景推荐策略理由
一次性活动动效,用完就下线不写本地文件,下载到临时路径直接用避免长期占用用户目录空间
长期复用的品牌动效文件名带版本号,永久保留版本不变就一直命中缓存,零流量
频繁迭代的动效文件名带内容哈希,配合清理旧文件保证每次拿到最新版本
多个动效共用按目录分组,按最近使用时间清理控制总占用

这里有个必须记住的约束:wx.env.USER_DATA_PATH有容量上限,一般是 10MB 左右,超了写入会失败。所以每次写入新版本之前,我一般会把同名的旧版本文件删掉,或者维护一个简单的清理逻辑:

function cleanOldVersions(name, keepVersion) { try { const files = fs.readdirSync(CACHE_DIR) files.forEach((file) => { if (file.startsWith(`${name}_`) && !file.includes(`_${keepVersion}.json`)) { try { fs.unlinkSync(`${CACHE_DIR}/${file}`) } catch (e) {} } }) } catch (e) {} }

4.4 版本探测的小技巧

如果每次启动都去下载完整 JSON 来判断是否有更新,那缓存的意义就丢了一半。我的做法是在 CDN 上额外放一个几十字节的manifest.json,只记录各个动效的当前版本号,启动时先请求它,比对本地记录的版本,只在版本变化时才去拉完整文件。这点流量几乎可以忽略,但换来的是"永远拿到最新动效"的能力。

注意:manifest.json这个请求本身也要考虑失败的情况。我的处理是请求失败时直接用缓存里的版本,不做任何升级尝试,宁可看到旧动效也不要白屏。

5. 真机上才会暴露的问题:性能、内存与层级

5.1 生命周期里的 pause 和 destroy,一个都不能少

onHide的时候必须暂停动画:

onHide() { if (this.ani) this.ani.pause() }, onShow() { if (this.ani) this.ani.play() },

原因有两个。一是耗电,页面退到后台后渲染还在跑,属于纯粹的浪费。二是时间轴跳帧,如果暂停,用户切回来时动画会接着原来的位置继续,观感自然;如果不暂停,切回来那一下可能因为长时间挂起而出现进度突跳。

onUnload里必须销毁:

onUnload() { if (this.ani) { this.ani.destroy() this.ani = null } },

destroy不只是停掉播放,它会释放渲染器内部持有的画布引用、缓存的帧数据、事件监听。页面被反复打开关闭时,不销毁的实例会囤积,内存曲线肉眼可见地往上走。我在一个活动页里踩过这个坑:用户在"抽奖 → 结果 → 返回"之间来回切了十几次之后,安卓机上的滚动开始明显卡顿,加上销毁之后问题直接消失。

5.2 列表里的多实例是灾难现场

每个 canvas 2d 节点在后端对应一块原生绘图表层,是有实际内存成本的。一个列表里塞十个正在播放的 Lottie,等于同时开十个渲染循环,中低端安卓机基本必卡。

我的处理原则是:一屏内同时播放的 Lottie 不超过两个。具体到列表场景,有三种做法。

方案 A 最省事:列表项里的动效只渲染首帧,autoplay设为 false,加载后立刻goToAndStop(0, true),等该项进入视口再play()。

const observer = wx.createIntersectionObserver(this, { thresholds: [0.5] }) observer .relativeToViewport() .observe('.list-item', (res) => { if (res.intersectionRatio > 0.5) { this.ani && this.ani.play() } else { this.ani && this.ani.pause() } })

方案 B 是只保留一个 canvas,靠绝对定位在列表项之间"移动"。这个方案省内存,但滚动时的定位跟随会有明显的粘滞感,除非你能接受动效跟手性稍差,否则不建议。

方案 C 最干脆:列表项用 CSS 动画或静态图,只有进入详情页才启用 Lottie。绝大多数业务场景下,列表里的动效本来就是装饰性的,用户根本不会盯着看完整段动画,这个取舍很划算。

5.3 画面异常时的排查顺序

我把踩过的坑整理成一个固定的排查顺序,遇到问题顺着走,基本都能定位。

第一,先确认节点是不是拿到了。在exec回调里console.log(info),如果info是undefined或者info.node为空,就不用往下查了,问题在 WXML 那一层。

第二,确认canvas.width/height不是 0。打印出来,如果是 0,说明style上的宽高没生效,可能是被 flex 布局压扁了,或者父容器宽高为 0。

第三,确认rendererSettings.context传了。这个漏了就是纯白屏,没有任何报错。

第四,确认 dpr 有没有算。画面能出来但边缘发毛,基本都是这个原因。

第五,检查画布比例和 JSON 的合成尺寸是否一致。JSON 里有个w和h字段,代表设计稿的合成尺寸。如果这个比例和画布比例差得远,内容要么被裁,要么四周留一大圈空白。

第六,排查层级。canvas 在部分基础库版本下是原生组件,普通view盖不住它,会被压在最底层。这时候要么用cover-view做浮层,要么把基础库提到支持 canvas 同层渲染的版本。这个问题的典型表现是"弹窗出来了,但弹窗里的内容被画布挡住了"。

5.4 iOS 和 Android 的差异实录

真机调试阶段最让人头大的就是两端不一致。我遇到过的差异大致有这么几类。

iOS 上首次渲染略慢,大概几十毫秒,但后续非常稳定;安卓低端机上如果画布逻辑尺寸超过 400px,掉帧非常明显。所以我现在会把动效画布控制在 320px 到 400px 之间,超出部分用缩放来适配,而不是直接把画布做大。

dpr 差异也很典型。开发者工具上pixelRatio一般是 2,真机可能是 2 或者 3。如果代码里把 dpr 写死了,工具上看着正常,真机上就模糊或者尺寸不对。

还有一个只在部分安卓机型上出现的问题:页面切走再切回来,canvas 内容会丢,画布变成空白。这通常是系统回收了绘制资源。我的兜底方案是在onShow里检查实例状态,必要时重新调用一次goToAndStop定位到当前进度再恢复播放,而不是重建整个实例。

6. 几个进阶玩法与我的经验清单

6.1 动态换色:改 JSON,而不是改渲染器

lottie-miniprogram 没有暴露"改颜色"的接口,但 Lottie 的 JSON 本身就是一份可读的数据结构,颜色就写在图层形状的填充节点里。做法是递归遍历layers里的shapes,找到ty为fl(填充)或st(描边)的节点,它的c.k是一个[r, g, b, a]的归一化数组,把目标颜色替换进去即可。

function replaceColor(node, from, to) { if (Array.isArray(node)) { node.forEach((item) => replaceColor(item, from, to)) return } if (node && typeof node === 'object') { if (node.ty === 'fl' || node.ty === 'st') { const k = node.c && node.c.k if (Array.isArray(k) && k.length >= 3) { const same = k[0] === from[0] && k[1] === from[1] && k[2] === from[2] if (same) { k[0] = to[0] k[1] = to[1] k[2] = to[2] } } } Object.keys(node).forEach((key) => replaceColor(node[key], from, to)) } } function tintJson(json, from, to) { const copy = JSON.parse(JSON.stringify(json)) replaceColor(copy.layers, from, to) return copy }

这里最关键的一行是JSON.parse(JSON.stringify(json))。通过require拿到的模块对象是被缓存的同一个引用,如果你直接在上面改颜色,所有引用这个模块的页面都会跟着变色,而且这个污染在整个小程序生命周期内都不会恢复。我第一次遇到这个问题时排查了很久,因为"只有从活动页返回首页后颜色才不对"这种时序性表现特别迷惑人。所以,改之前一定深拷贝。

性能上也不用太担心,1MB 级别的 JSON 做一次完整遍历大概在几十毫秒量级,放在onLoad阶段做完全没问题,但千万别放到渲染帧里。

6.2 用进度驱动动画:滚动联动与拖拽联动

先加载但不自动播放,然后手动控制进度:

this.ani = lottie.loadAnimation({ loop: false, autoplay: false, animationData: json, rendererSettings: { context: canvas.getContext('2d') }, }) // progress 取值 0 到 1 const total = this.ani.totalFrames this.ani.goToAndStop(Math.floor(progress * total), true)

goToAndStop的第二个参数true表示按帧定位,配合totalFrames用起来最直观。这个能力是 Lottie 相比 GIF 最大的优势:动画不再是"播放",而是"被驱动的状态"。

典型场景有下拉刷新时头部图标的展开程度、进度条上的角色动作、长按按钮的蓄力效果。这里有个细节必须注意:不要每次scroll事件都调用goToAndStop,滚动事件触发频率远高于渲染帧率,频繁调用会把主线程占满。我一般的做法是加一个时间戳节流,间隔小于 16ms 的直接丢弃。

6.3 我踩过的坑与处理清单

现象根本原因处理方式
改了颜色后所有页面都变了require返回的是缓存引用修改前深拷贝,JSON.parse(JSON.stringify())
工具正常,真机白屏基础库低于 2.9.0 或未重新构建 npm检查调试基础库,重新构建
返回页面后动画从头播实例被销毁重建记录当前进度,重建后定位回去
上传代码包提示体积超限JSON 放在主包里统一挪到 CDN,走本地文件缓存
长时间停留后页面卡顿多实例未销毁,内存累积生命周期里 destroy,限制同时播放数量
文字显示成方块或位置错乱字体缺失导出前把文字转成形状
投影和发光效果全部消失canvas 2d 不支持图层效果导出前烘焙,或由容器层实现
部分安卓机返回后画布空白系统回收绘制资源onShow 里重新定位进度恢复播放

最后分享一个小经验:在项目里给 Lottie 做一个统一的封装组件,把加载、缓存、dpr 处理、生命周期管理、失败降级全部收进去。这事看起来是多写了几百行,但等到活动页做到第五个的时候,你会发现每个页面只需要传一个名字和一个版本号,剩下的全都不用管了。我现在的做法是组件接受name、version、loop、autoplay四个参数,内部自己去 CDN 拿数据、自己管缓存、自己在失败时切到静态图,页面层干净得像什么都没发生。踩过几次坑之后,这种"把复杂度收进一个地方"的思路,比每次都临时写一遍要省心太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询