作者:杨华云
以下内容基于 taro 3.x
一、背景
在古茗小程序的七夕集卡活动中,用户通过完成任务或好友助力收集不同类型的角色卡片,并通过合成机制解锁抽奖机会。为了增强这一关键交互节点的情绪价值与沉浸感,业务希望在"合卡"动作触发时加入更具仪式感的氛围动画效果,例如卡片旋转合并、光粒散射、浪漫特效联动等,进一步放大节日主题情绪,提升页面吸引力和互动反馈质感,从而提升用户停留时长与参与积极性。
对于这种复杂的动画而言,在项目中几乎不可能用纯CSS实现,业界常用的比较成熟的方案无非是 Lottie,但Lottie也有其自身的局限性(比如其不能很好的支持粒子效果),而且我们的小程序需要同时支持 (微信 / 抖音 / 支付宝)三端小程序。
二、关于 Galacean Effect
Galacean Effects (www.galacean.com/effects/) (以下简称 GE)是一款由蚂蚁集团创意设计中心与体验技术部联合出品的专注于移动端的在线化动效方案。
Galacean Effects 主要由 Studio (编辑器,www.galacean.com/effects/das...%25E5%2592%258C "https://www.galacean.com/effects/dashboard)%E5%92%8C") Player (播放器,www.npmjs.com/package/@ga...) 组成。播放器用于在运行时解析和播放编辑器中发布/导出的动画产物,保障动画上线的效果和效率,大大节约设计师与开发者之间动效实现、效果调试的沟通成本。
设计师只需通过浏览器访问编辑器,无需安装。
可以通过导入编辑器中内置模版快速制作动画,也可以使用图层动画 、粒子动画 、3D 动画 、Spine 骨骼动画 、交互设置等元素完成更丰富的动画效果。此外,编辑器还具备项目管理、多人协同、版本保存等能力。
动效制作后,使用「发布」功能即可生成动效产物,产物中的资源会自动审核和压缩优化,可直接用于播放器播放。
开发通过播放器可以在运行时加载解析和播放动效产物,上线效果与设计师在编辑器中制作的效果完全一致。播放器底层使用 WebGL/OpenGL 渲染,能够高性能地支持粒子、3D、Spine 骨骼等特性,实时展现惊艳的视觉表现效果。
三、安装
shell
# 安装 Galacean Effects 的小程序适配器
$ npm i @galacean/appx-adapter --save
# 安装 Galacean Effects
$ npm i @galacean/effects --save
1. 适配器导出
得益于Taro 原生支持多端脚本,只需要通过在不同端后缀文件中导出对应的模块,并在动画组件中导入,达到对应的平台只打包对应的适配器。
shell
├── ge
│ ├── index.alipay.ts
│ ├── index.ts
│ ├── index.tt.ts
│ └── index.weapp.ts
└── index.tsx
typescript
export { registerCanvas } from '@galacean/appx-adapter/weapp';
export { Player } from '@galacean/effects/weapp';
四、简单上手
1. 准备动画 json
typescript
{
"playerVersion": {
"web": "2.5.3",
"native": "0.0.1.202311221223"
},
"images": Array[4],
"fonts": Array[0],
"version": "3.3",
"shapes": Array[0],
"plugins": Array[0],
"type": "ge",
"compositions": Array[1],
"components": Array[25],
"geometries": Array[0],
"materials": Array[0],
"items": Array[27],
"shaders": [],
"bins": Array[1],
"textures": Array[4],
"animations": [],
"miscs": Array[142],
"compositionId": "17"
}
动画 json 由 UI 同学在动画编辑器中导出,导出的json中,资源的路径是相对路径,需开发自行转换成绝对路径,避免播放动画时找不到对应的资源。
另外需要注意的一点是,动画 json 中的 playerVersion的版本要与 @galacean/effects 的版本一致,否则某些动画效果,可能会和预期不一致。
2. 设置动画容器
typescript
<Canvas
type='webgl'
id='J-webglCanvas'
style={{ width: "100%", height: "100%" }}
></Canvas>
3. 创建播放器
typescript
import { View, Canvas } from "@tarojs/components";
import { useEffect, useRef } from "react";
import Taro from "@tarojs/taro";
import { registerCanvas, Player } from "./ge";
const playerRef = useRef<Player | null>(null);
useEffect(() => {
initAndPlay();
return () => {
// 页面卸载时,要销毁Player,避免内存泄露
playerRef.current?.dispose();
};
}, []);
const initAndPlay = async () => {
const query = Taro.createSelectorQuery();
const nodeCanvas = await new Promise((resolve) => {
query
.select("#J-webglCanvas")
.node()
.exec((res) => {
resolve(res[0].node);
});
});
const canvas = await registerCanvas({ id: nodeCanvas as string });
playerRef.current = new Player({
canvas,
renderFramework: "webgl",
});
playerRef.current
.loadScene("https://g.gumingnc.com/u/Hw7lZMp/EA.json")
};
通过以上代码,就可以轻松的将 UI 同学设计的动画效果在小程序中还原,效果如下

五、加载优化
从上面的视频中可以看出,从点击按钮到动画真正出现,中间间隔了一段时间,这对用户来说,就像是卡了一样。从用户体验角度看这是不能接受的,要了解为什么会发生这样的情况,我们需要了解 GE 动画的播放流程。

1. 加载分析
首帧耗时
首帧耗时是指从加载到第一帧渲染的时间,也就是用户看到画面的耗时。
首帧耗时主要经过两个阶段:
- 相关资源的网络请求及初处理(即:加载耗时)。
- GPU 编译等,所有的 WebGL 程序在绘制之前都需要编译 Shader(编译耗时优化由 player 处理)
我们可以通过以下代码打印出首帧时间
typescript
playerRef.current
.loadScene("https://g.gumingnc.com/u/Hw7lZMp/EA.json")
.then((composition) => {
console.log(composition.statistic);
});
{
"loadStart": 1760429624085,
"loadTime": 753,
"compileTime": 31,
"firstFrameTime": 811
}
加载耗时
以上面的动画为例,其在控制台打印的结果加载耗时如下:
typescript
%c[Galacean Effects] color: #AA0100
Load asset: totalTime: 796.0000ms
[loadJSON: 343.00]
[processJSON: 2.00]
[processFontURL: 1.00]
[plugin:processAssets: 1.00]
[processBins: 193.00]
[processImages: 447.00]
[processTextures: 1.00]
[plugin:prepareResource: 0.00],
url: https://g.gumingnc.com/u/Hw7lZMp/EA.json.
具体含义如下:
- totalTime: 加载总耗时
- loadJSON : 加载
.json
的网络耗时 - processJSON: 对 JSON 数据进行预处理
- processBins : 加载
.bin
文件并进行预处理 - processImages: 加载图片(含动态换图/视频、压缩纹理)文件并进行预处理
- processFontURL : 加载
.ttf
的字体文件并进行预处理 - plugin:processAssets : 插件生命周期的
processAssets
,插件加载所需类型资源(如:音/视频资源) - processTextures : 图片相关资源转
Texture options
- plugin:prepareResource : 插件生命周期的
prepareResource
,插件对加载好的资源做进一步处理
2. 优化方向
从上面的数据我们可以看出在首帧时间内的大部分时间,都消耗在通过网络加载动画所需的资源,特别是在用户网络较差的时候,加载时间会更长,造成首帧时间更长,影响用户体验。为了避免此种情况,我们可以通过设置超时时间,超过一定时间若资源未加载成功,则执行降级逻辑。
默认超时时间为10s
typescript
// onError 中处理超时情况,进行降级处理
playerRef.current = new Player({
canvas,
renderFramework: "webgl",
onError: (error) => {
console.log(error);
},
});
// 1s 内未完成加载会抛出超时错误
await player.loadScene(json, { timeout: 1 });
虽然可以通过以上方式解决加载时间过长的问题,但也粗暴的让用户体验降级了,这显然不符合做这个事情的目的,花那么大力气,用户看到的确是降级的内容,那有没有什么办法提高动画播放的成功率呢。既然在动画复杂度不高的情况下,播放动画的时间主要消耗在动画资源的网络请求上,那我们是不是可以小程序启动的时候,将动画资源加载到小程序本地,然后播放的动画的时候读取本地文件,极大的缩短了动画播放时资源的加载时间。
核心思路
- 小程序启动时下载动画资源包
typescript
const geMap = [
{
name: "qixi",
url: "https://g.gumingnc.com/u/Pxil6Fp/6w.zip",
},
];
const targetPath = `${Taro.env.USER_DATA_PATH!}/animation`;
const fs = Taro.getFileSystemManager();
const currentGe = geMap[0];
useLaunch(() => {
const { url, name } = currentGe;
Taro.downloadFile({
url: url,
success: (res) => {
try {
fs.accessSync(targetPath);
} catch (error) {
fs.mkdirSync(targetPath, true);
}
fs.unzip({
zipFilePath: res.tempFilePath,
targetPath: `${targetPath}/name`,
success: () => {
try {
fs.accessSync(`${targetPath}/index.json`);
const indexJsonStr = fs.readFileSync(`${targetPath}/index.json`,'utf-8');
const indexJson = JSON.parse(indexJsonStr as string);
indexJson[name] = `${targetPath}/${name}/qixi/index.json`;
fs.writeFileSync(`${targetPath}/index.json`, JSON.stringify(indexJson));
} catch (error) {
fs.writeFileSync(`${targetPath}/index.json`, JSON.stringify({ [name]: `${targetPath}/${name}/qixi/index.json` }));
}
},
fail: (err) => {
console.log("解压失败", err);
},
});
},
fail: () => {},
});
在这个过程中,我们下载的文件不再是 json 文件,而是完整的 zip 包,这样做的好处是,不用单独解析 json 里面的图片及bin资源,保持其相对路径。下载完成后,将资源包解压到对应的目录下,并记录下动画json路径和动画名称的映射关系,方便后续播放动画时处理。
- 处理动画 json 中的资源路径
typescript
let file: any = {};
const targetPath = `${Taro.env.USER_DATA_PATH!}/animation`;
const fs = Taro.getFileSystemManager();
const animationName = "qixi";
const indexJson = fs.readFileSync(targetPath + "/index.json", "utf8");
const animationMap = JSON.parse(indexJson as string);
const currentAnimationJsonConfig = animationMap[animationName];
const json = fs.readFileSync(currentAnimationJsonConfig, "utf8");
file = JSON.parse((json as string) || "{}");
file.images = file.images.map((item: any) => ({
...item,
url: item.url.replace("./", `${targetPath}/${animationName}/qixi/`),
webp: item.webp.replace("./", `${targetPath}/${animationName}/qixi/`),
}));
file.bins = file.bins.map((item: any) => ({
...item,
url: item.url.replace("./", `${targetPath}/${animationName}/qixi/`),
}));
// 模拟器arraybuff会有问题,因此模拟器上保持绝对路径
if (Taro.getSystemInfoSync().platform.toLowerCase() !== "devtools") {
const bins = file.bins.map((item) => fs.readFileSync(item.url));
file.bins = bins;
}
// 3. 加载资源并执行播放
playerRef.current
.loadScene(file, {
autoplay: true,
timeout: 1,
})
.then((composition) => {
console.log(composition.statistic);
})
.catch((error) => {
console.log(error);
});
在动画资源包成功下载到用户本地的情况下,我们只需要读取到对应的动画 json 文件,并把图片等资源的路径改成绝对路径(图片使用相对路径,真机会报错,因此要用绝对路径);,但bin文件要特殊处理,其使用绝对路径时,真机会报错,因此需要将其转成arraybuffer再消费。
优化结果
typescript
%c[Galacean Effects] color: #AA0100
Load asset: totalTime: 35.0000ms
[processJSON: 1.00]
[processBins: 2.00]
[processFontURL: 0.00]
[plugin:processAssets: 0.00]
[processImages: 33.00] [processTextures: 0.00]
[plugin:prepareResource: 0.00], url: 1.
typescript
{"loadStart": 1760537905401, "loadTime": 35, "compileTime": 22, "firstFrameTime": 75}
通过上述数据可以发现,使用离线包的形式,可以大幅减少资源包的加载时间,减少用户的等待时间。
六、总结
Galacean Effect 是一款优秀的动画效果框架。其内置的灵感中心提供了丰富的动效模块和可视化设计能力,UI 设计师可以基于这些模块快速完成动画方案设计并交付给开发团队。开发人员则能够以较低的实现成本高效还原设计效果,大幅提升协作效率与动效质量。