双12前接了一个小项目“我的小纸条”,合作方中间各种延期改需求,好在两位师弟很给力很靠谱,一起把设计师的需求给完美实现,最终项目顺利上线。
最终效果如下(Gif图片有6MB,稍微多等会儿):
(如果看不了,可以尝试点击这里在页面观看效果图)
前期技术调研
-
存在着大量的图片和3个动画效果,这意味着需要使用大量的动图,那么gif、apng和webp这三种格式需要如何抉择?
答:首先,gif格式的图片体积相对来说比较大,且gif图片每个像素只有8bit,显示效果不好[1] ,而webp的兼容性暂时没有apng好,最终在考虑了显示效果、性能、兼容性这三种情况[2],我们决定使用apng格式,且考虑到低版本的手淘不支持apng,所以引入了apng-js这个库来对apng的图片做处理,并使用
canvas
作为画布来把我们所需要的结果绘制出来,将使得我们可以放心大胆地使用apng。 -
页面性能如何兼顾?
答:考虑飞机特效比较复杂,所以使用apng动图来实现,一开头的文字和后面的纸条展示使用transition并结合Promise做异步流程控制。
整体执行顺序图
代码分析
项目是在weex环境下用Rax语法写的,Rax[3]相当于一个DSL,语法和React类。
这里要说到是,我们对普通的图片和apng图的处理方式是不同的,普通图片可以通过JS代码中Image
对象来加载,而对于apng图片,我们需要将其转换为ArrayBuffer
对象[4],这是一种通用的、固定长度的原始二进制数据格式,可用来处理声音、视频等其他信息。这里我们封装一个loadImg方法来处理这两种情况。
同时为了实现顺序的异步控制,我们用Promise
的then
方法来做异步控制,具体情况详见代码:
function loadImg(url, useFetch = false) {
if (useFetch) {
// 转化为ArrayBuffer对象
return fetch(url).then(a => a.arrayBuffer());
} else {
return new Promise((resolve, reject) => {
let img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = url;
});
}
}
关键代码展示:
import apng from 'apng-js';
const images = {
bg: 'https://gw.alicdn.com/tfs/TB17A2BrQPoK1RjSZKbXXX1IXXa-1125-2001.jpg',
aText: 'https://gw.alicdn.com/tfs/TB1IULErQzoK1RjSZFlXXai4VXa-1125-900.png',
aBtn: 'https://gw.alicdn.com/tfs/TB1aDIMshTpK1RjSZR0XXbEwXXa-1125-315.png',
aPlane: {
apng: 'https://gw.alicdn.com/tfs/TB1zEjwrSzqK1RjSZFLXXcn2XXa-750-1334.png?getAvatar=1',
png: 'https://gw.alicdn.com/tfs/TB1XLCCppzqK1RjSZFvXXcB7VXa-750-1334.png',
},
bFrames: [
'https://gw.alicdn.com/tfs/TB1KyTBrMHqK1RjSZJnXXbNLpXa-1125-2001.png?getAvatar=1', // 02
'https://gw.alicdn.com/tfs/TB1mKHCrQvoK1RjSZFDXXXY3pXa-1125-2001.png?getAvatar=1', // 03
'https://gw.alicdn.com/tfs/TB1R9fArHrpK1RjSZTEXXcWAVXa-1125-2001.png?getAvatar=1', // 04
'https://gw.alicdn.com/tfs/TB1pwjzrOrpK1RjSZFhXXXSdXXa-1125-2001.png?getAvatar=1', // 05
'https://gw.alicdn.com/tfs/TB12T6zrFzqK1RjSZFoXXbfcXXa-1125-2001.png?getAvatar=1', // 06
'https://gw.alicdn.com/tfs/TB1h5_VrNnaK1RjSZFBXXcW7VXa-1125-2001.png?getAvatar=1', // 07
]
};
this.loadData()
.then(data => {
this.imageLoader[0] = loadImg(images.aPlane.apng, true)
.then(buf => {
// 使用apng-js加载apng图片
const imgObj = apng(buf);
if (imgObj instanceof Error) {
throw imgObj;
}
let retry = 0;
const waitCanvas = () => new Promise((resolve, reject) => {
// 配置canvas环境
const ctx = this.setupCanvas();
if (ctx) {
resolve(ctx);
} else if (++retry > 5) {
reject(new Error('cannot get canvas in .5s'));
} else {
setTimeout(() => {
resolve(waitCanvas());
}, 100);
}
});
return imgObj.createImages()
.then(waitCanvas)
// 获取player对象
.then(ctx => imgObj.getPlayer(ctx));
}).catch(err => {
// 加载 apng 过程出错,降级为纯图片
console.error(err);
return loadImg(images.aPlane.png).then(() => null);
});
[images.bg, images.aText, images.aBtn, data.imgUrl, data.shareImgUrl]
.forEach(url => this.imageLoader.push(loadImg(url)));
// images.bFrames 存放的是纸飞机展开过程的几张图片
this.imageLoader.push(Promise.all(images.bFrames.map(url => loadImg(url))));
// 使用Promise.all来等待全部图片加载完成,并做后续处理
Promise.all(this.imageLoader).then(([apngPlayer, bg, aText, aBtn, result, share, bFrames]) => {
this.bFrames = bFrames;
if (apngPlayer) {
this.apngPlayer = apngPlayer;
}
setTimeout(() => {
this.play();
}, 500);
}).catch(e => console.error(e));
});
import { findDOMNode } from 'rax';
import transition from 'universal-transition';
...
// 文字淡入
displayTexts = (duration = 400) => {
const $textDoms = this.textRefKeys.map(refKey => findDOMNode(this.refs[refKey]));
// 将上一个流程中的Promise置为完成,获取当前操作流程中的Promise,准备展示text
let sequence = Promise.resolve();
$textDoms.forEach(dom => {
// 更新当前的操作顺序里的Promise
sequence = sequence.then(() => new Promise(resolve => {
// 使用transition做动画,动画完成后调用resolve方法
transition(
dom,
{ opacity: 1 },
{ duration, timingFunction: 'ease-in-out' },
resolve
);
}));
});
return sequence;
}
总结
- 兼容性问题是个大坑,apng的效果还是杠杠的。
- 处理嵌套的
Promise
时,善用Promise.resolve