上个月接了个活动页需求,设计师甩过来一个 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 拿数据、自己管缓存、自己在失败时切到静态图,页面层干净得像什么都没发生。踩过几次坑之后,这种"把复杂度收进一个地方"的思路,比每次都临时写一遍要省心太多。