前言
作为前端同学,或多或少都会接到动画需求。如果是有规律性的动画还是相对容易实现的,但如果是比较复杂的帧动画,我们用 CSS 实现的话,就非常容易造成如下情况,设计师是卖家秀,我们开发的是买家秀。
或许你会想到用 GIF 实现,但是 GIF 经常会有杂边,无法满足设计师对精致度的要求。所以我们需要寻找更多的动画方案,能够让我们 100% 还原设计稿,又保证动画的精致度和性能。本文笔者主要是介绍的是 APNG 方案。
APNG(Animated Portable Network Graphics)是基于 PNG 格式扩展的一种动画格式,增加了对动画图像的支持,同时加入了 24 位图像和 8 位 Alpha 透明度的支持,这意味着动画将拥有更好的质量。
首先来看下 APNG 和 GIF 的对比效果:
上面的图不动的话,或者查看更多 Demo 请直接看 Demo1 和 Demo2,可以发现 APNG 和 GIF 的大小虽然相差不大,但是 APNG 要比 GIF 清晰的多,并且没有杂边。这是因为 APNG 拥有 24 位图像和 8 位 Alpha 透明度的支持。接下来一起看看 APNG 的主要原理和使用吧。
1. APNG 数据格式
1.1 PNG
在查看 APNG 数据格式前,要先了解下 PNG 的数据格式,毕竟 APNG 是基于 PNG 格式扩展的。PNG 的数据格式如下:
主要分为 4 部分:
-
PNG Signature 是文件标识,用于校验文件格式是否为 PNG。内容固定为:
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a // 这里为下文打下基础,通过校验前8个字节是否为这个内容,判断是否为png
-
IHDR 是文件头数据块,包含 PNG 图像的基本信息,例如图像的宽高等信息
-
IDAT 是图像数据块,最核心,存储具体的图像数据
-
IEND 是结束数据块,标示图像结束
1.2 APNG
在了解 PNG 的数据格式后,再来看下 APNG 的数据格式。如下图所示:
可以看到,APNG 在 PNG 的基础上增加了 acTL、fcTL 和 fdAT 3 种模块
-
acTL:必须在第一个 IDAT 块之前,用于告诉解析器这是一个动画格式的 PNG,包含动画帧总数和循环次数的信息,意味着可以通过这个字段来判断是否为 APNG 的图像格式。
-
fcTL:帧控制块,每一帧都必须有的,属于 PNG 规范中的辅助块,包含了当前帧的序列号、图像的宽高。
-
fdAT:帧数据块,和 IDAT 意义相同,都是图像数据。但是比 IDAT 多了帧的序列号,因为动画存在多帧。图中可以看到第一帧的图像数据依然叫做 IDAT,第 2 帧以后才叫 fdAT,这是因为第一帧和 PNG 数据的格式保持相同。在不支持 APNG 的浏览器上,可以降级为静态图片,只展示第一帧。
为了更好的理解 APNG 数据格式,感兴趣的同学可以通过下方 APNGb 这个软件,自己生成 APNG 动画。下面的 DEMO 是用 4 张时钟图片生成。
效果:(不动的话就直接看上述 Demo)
2. 性能
学习完 APNG 的数据格式,以及通过上面的 Demo 我们可以发现,一个时钟动画存储了 4 帧时钟图像数据,意味着一张 APNG 动画必然很大。如果有几十帧,那更是不敢想象了。页面加载动画很慢,反而造成不好的用户体验,那动画也没什么存在的意义了。
但是 APNG 的团队也意识到这个问题,因此也会进行帧优化:
如上这 4 帧,可以看出表盘部分是可以复用的,因此在生成 APNG 前,APNG 会通过算法计算帧之间的差异,只存储帧之前的差异,而不是存储全帧。如下,第 2、3、4 帧都没有表盘部分了。
优化后的 APNG 大小如下,可以看出第 2、3、4 帧数据要比第一帧小了很多。
但是这里有个问题便是,第 2、3、4 帧如何绘制呢?如何知道复用哪些元素呢?这个问题会在后面解答。
3. apng-canvas 源码分析
平时我们使用 APNG 方式如下,非常简单:
<img src="xxx.png" />
但是直接使用 img
标签存在 2 个问题:
- 兼容性问题,APNG 兼容性目前来看还算可以,取决于各个公司希望的兼容程度使用。
- 一个非常大的坑,在 Safari for iOS(Safari for macOS正常)预览 APNG 的时候,动图的循环次数为对应原图的
loop
+ 1。比如 APNG 有 10帧,loop
为 2,那么会循环总计展示 30 帧。如果我们的动画只想播放一次的话,那就糟糕了。
所以一般我们推荐 使用 apng-canvas 这个库。该库需要以下支持才能运行:
- Canvas
- Typed Arrays
- Blob URLs
- requestAnimationFrame
接下来带大家看一下 apng-canvas 库是如何实现 APNG 正常播放的,主要分为 3 个步骤:
- 解析 APNG 数据格式(按照 1.2 小节的 APNG 图片格式)。
- 将解析好的 APNG 数据进行整理。
- 按照每一帧的间隔时长,通过 requestAnimationFrame 进行绘制每一帧。
源码 apng-canvas/src 目录结构如下:
├─animation.js // APNG动画逻辑
├─crc32.js // 解码运算相关
├─loader.js //APNG下载
├─main.js // 入口
├─parser.js // 解码
├─support-test.js // 兼容性检查
3.1 解析 APNG 数据格式
解码流程如下:
APNG 的文件加载是通过 XMLHttpRequest
下载,可以看下 /src/loader.js ,不做解释。
解码逻辑主要是在 /src/parser.js 中,首先将 APNG 以 arraybuffer
的格式下载资源,通过操作二进制数据,校验文件格式是否为 PNG & APNG。
校验 PNG 格式就是校验 PNG Signature
块,在 1.1小节 PNG 数据格式中已提到,关键实现如下:
const bufferBytes = new Uint8Array(buffer);
const PNG_SIGNATURE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
for (let i = 0; i < PNG_SIGNATURE_BYTES.length; i++) {
if (PNG_SIGNATURE_BYTES[i] !== bufferBytes[i]) {
reject('Not a PNG file (invalid file signature)');
return;
}
}
校验 APNG 格式就是判断文件是否存在类型为 acTL
的块,在 1.2小节 APNG 数据格式中已提到。依序读取文件中的每一块,获取块类型等数据,判断代码如下:
let isAnimated = false;
parseChunks(bufferBytes, (type) => {
if (type === 'acTL') {
isAnimated = true;
return false;
}
return true;
});
if (!isAnimated) {
reject('Not an animated PNG');
return;
}
解码并整理每一帧数据的过程,如下代码所示。调用 parseChunks
依次读取每一块,根据每种类型块中包含的数据、宽高、对应的位置、字节大小分别进行处理存储。
let preDataParts = [], // 存储 其他辅助块
postDataParts = [], // 存储 IEND块
headerDataBytes = null; // 存储 IHDR块
const anim = anim = new Animation();
let frame = null; // 存储 每一帧
parseChunks(bufferBytes, (type, bytes, off, length) => {
let delayN,
delayD;
switch (type) {
case 'IHDR':
headerDataBytes = bytes.subarray(off + 8, off + 8 + length);
anim.width = readDWord(bytes, off + 8); // 画布宽
anim.height = readDWord(bytes, off + 12); // 画布高
break;
case 'acTL':
anim.numPlays = readDWord(bytes, off + 8 + 4); // 循环次数
break;
case 'fcTL':
if (frame) anim.frames.push(frame); // 上一帧数据
frame = {}; // 新的一帧
frame.width = readDWord(bytes, off + 8 + 4); // 当前帧的宽度
frame.height = readDWord(bytes, off + 8 + 8); // 当前帧的高度
frame.left = readDWord(bytes, off + 8 + 12); // 距离画布左侧位置
frame.top = readDWord(bytes, off + 8 + 16); // 距离画布顶部位置
delayN = readWord(bytes, off + 8 + 20);
delayD = readWord(bytes, off + 8 + 22);
if (delayD === 0) delayD = 100;
frame.delay = 1000 * delayN / delayD; // 当前帧播放时长
anim.playTime += frame.delay; // 累加播放总时长
frame.disposeOp = readByte(bytes, off + 8 + 24);
frame.blendOp = readByte(bytes, off + 8 + 25);
frame.dataParts = [];
break;
case 'fdAT':
// 图像数据
if (frame) frame.dataParts.push(bytes.subarray(off + 8 + 4, off + 8 + length));
break;
case 'IDAT':
// 图像数据
if (frame) frame.dataParts.push(bytes.subarray(off + 8, off + 8 + length));
break;
case 'IEND':
postDataParts.push(subBuffer(bytes, off, 12 + length));
break;
default:
preDataParts.push(subBuffer(bytes, off, 12 + length));
}
});
if (frame) anim.frames.push(frame); // 依次存储每一帧帧数据
上面将每一帧图像的宽高、位置、播放时长等处理好后,将每一帧的帧数据 dataParts
按序组成一份 PNG 图像资源,通过 createObjectURL
创建图片的 URL 存储到frame中,用于后续绘制。这里代码省略,感兴趣自行查看源码。
const url = URL.createObjectURL(new Blob(bb, { type: 'image/png' }));
frame.img = document.createElement('img');
frame.img.src = url;
frame.img.onload = function () {
URL.revokeObjectURL(this.src);
createdImages++;
if (createdImages === anim.frames.length) { //全部解码完成
resolve(anim);
}
};
解码这一块比较无聊,想了解更详细的可以看@网易云音乐专栏的这篇文章哈~ APNG 解码 ,笔者主要是带大家理清思路即可。
3.2 整理解析好的 APNG 数据
从 3.1小节可以看出将解析出的数据依次存储到 anim.frames
中了,前面提到的时钟案例解析结果如下:
anim.frames =
[
// 第1帧
{
blendOp: 0
delay: 1000 // 每一帧持续时间
disposeOp: 0
height: 150 // 高度
img: img // 当前帧的图片数据
left: 0 // 距离画布左侧位置
top: 0 // 距离画布顶部位置
width: 150 // 宽度
},
// 第2帧
{
blendOp: 1
delay: 1000
disposeOp: 0
height: 58
img: img
left: 46
top: 31
width: 73
},
// 第3帧
{
blendOp: 1
delay: 1000
disposeOp: 2
height: 66
img: img
left: 46
top: 53
width: 73
},
// 第4帧
{
blendOp: 1
delay: 1000
disposeOp: 0
height: 30
img: img
left: 31
top: 53
width: 89
}
]
上述 4 帧数据,分别对应下面 4 张图,前面提到过这是优化后的效果:
也可以看出只有第一帧的 width
、 height
、 left
、 top
比较完整,第 2、3、4 帧的 width
、 height
、 left
、 top
都是不同的,因为被算法优化过。
那么 blendOp
和 disposeOp
字段分别代表什么呢?可以看出笔者是没有注释的,这 2 个字段就是前文第 2 节中提到的 【第 2、3、4 帧如何绘制呢?如何知道复用哪些元素呢?】问题的答案。那具体如何处理的,在下一节绘制中再来解答。
3.3 绘制每一帧
APNG 的绘制,主要是通过 requestAnimationFrame
不断的调用 renderFrame
方法绘制每一帧,每一帧的图像、宽高、位置我们都在上一节中获取到了。 requestAnimationFrame
在正常情况下能达到 60 fps(每隔 16.7ms 左右),在上一节中提过 playTime
这个字段,是每一帧的绘制时间。所以,并不是 requestAnimationFrame
每次都会去绘制,而是通过 playTime
计算 nextRenderTime
(下次绘制时间),达到这个时间再绘制。避免无用的绘制,对性能造成影响。代码如下:
const renderFrame = function (now) {
if (nextRenderTime === 0) nextRenderTime = now;
while (now > nextRenderTime + ani.playTime) nextRenderTime += ani.playTime;
nextRenderTime += frame.delay;
};
const tick = function (now) {
while (played && nextRenderTime <= now) renderFrame(now);
if (played) requestAnimationFrame(tick);
};
具体的绘制是采用 Canvas 2D 的 API 实现。
const renderFrame = function (now) {
const f = fNum++ % ani.frames.length;
const frame = ani.frames[f];
if (prevF && prevF.disposeOp === 1) { // 清空上一帧区域的底图
ctx.clearRect(prevF.left, prevF.top, prevF.width, prevF.height);
} else if (prevF && prevF.disposeOp === 2) { // 恢复为上一帧绘制之前的底图
ctx.putImageData(prevF.iData, prevF.left, prevF.top);
} // 0 则直接绘制
const {
left, top, width, height,
img, disposeOp, blendOp
} = frame;
prevF = frame;
prevF.iData = null;
if (disposeOp === 2) { // 存储当前的绘制底图,用于下一帧绘制前恢复该数据
prevF.iData = ctx.getImageData(left, top, width, height);
}
if (blendOp === 0) { // 清空当前帧区域的底图
ctx.clearRect(left, top, width, height);
}
ctx.drawImage(img, left, top); // 绘制当前帧图片
// 下一帧的绘制时间
if (nextRenderTime === 0) nextRenderTime = now;
nextRenderTime += frame.delay; // delay为帧间隔时间
};
从上面的绘制代码中,我们可以看到 blendOp
和 disposeOp
2个字段决定了是否复用绘制过的帧数据。2 个字段对应的配置参数信息如下:
disposeOp
指定了下一帧绘制之前对缓冲区的操作- 0:不清空画布,直接把新的图像数据渲染到画布指定的区域
- 1:在渲染下一帧前将当前帧的区域内的画布清空为默认背景色
- 2:在渲染下一帧前将画布的当前帧区域内恢复为上一帧绘制后的结果
blendOp
指定了绘制当前帧之前对缓冲区的操作- 0:表示清除当前区域再绘制
- 1:表示不清除直接绘制当前区域,图像叠加
对应时钟 4 帧绘制流程如下:
-
第一帧:
- blendOp:0 绘制当前帧之前,清除当前区域再绘制
- disposeOp:0 不清空画布,直接把新的图像数据渲染到画布指定的区域
-
第二帧:
- blendOp:1 绘制当前帧之前,表示不清除直接绘制当前区域,图像叠加
- disposeOp:0 不清空画布,直接把新的图像数据渲染到画布指定的区域
-
第三帧:
- blendOp:1 绘制当前帧之前,表示不清除直接绘制当前区域,图像叠加
- disposeOp:2 渲染下一帧前将画布的当前帧区域内恢复为上一帧绘制后的结果(因为第4张图覆盖的是第二张图的红色线条,所以第三张图动完要回到第2帧)
-
第四帧:
- blendOp:1 绘制当前帧之前,表示不清除直接绘制当前区域,图像叠加
- disposeOp:0 不清空画布,直接把新的图像数据渲染到画布指定的区域
至此 apng-canvas 的绘制流程便讲完了,感兴趣的同学可以源码多琢磨下~
4. APNG 兼容性检测
在实际应用中如何检测浏览器是够支持 APNG,可以通过如下方法:
(function() {
"use strict";
var apngTest = new Image(),
ctx = document.createElement("canvas").getContext("2d");
apngTest.onload = function () {
ctx.drawImage(apngTest, 0, 0);
self.APNG = ( ctx.getImageData(0, 0, 1, 1).data[3] === 0 );
};
apngTest.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACGFjVEwAAAABAAAAAcMq2TYAAAANSURBVAiZY2BgYPgPAAEEAQB9ssjfAAAAGmZjVEwAAAAAAAAAAQAAAAEAAAAAAAAAAAD6A+gBAbNU+2sAAAARZmRBVAAAAAEImWNgYGBgAAAABQAB6MzFdgAAAABJRU5ErkJggg==";
// frame 1 (skipped on apng-supporting browsers): [0, 0, 0, 255]
// frame 2: [0, 0, 0, 0]
}());
-
加载一张 1x1 像素大小的 Base64 编码图片,图像有 2 帧数据,区分就是每一帧最后一个值不同。
// frame 1 (skipped on apng-supporting browsers): [0, 0, 0, 255] // frame 2: [0, 0, 0, 0]
-
将其绘制到画布中,通过 getImageData() 方法去获取该图片的像素数据,主要是获取
data[3]
的 Alpha 透明通道(值的范围:0 - 255)。在不支持 APNG 的浏览器上会降级只显示第一帧,因此data[3]
会等于 255。在支持 APNG 的浏览器上最终会显示第 2 帧,因此data[3]
会等于 0,则表示支持 APNG。
5. 总结
-
本文介绍了 APNG 的使用、性能、踩坑、兼容性以及检测、 apng-canvas 库源码分析,主要是对笔者个人学习进行总结。
-
在实际使用中,由于 Safari for iOS 中
loop
会自动+1
,所以不适合那些只播放一次的动画。 -
APNG 文件存储多帧数据会很大,所以建议使用比较小的动画场景上。如果场景合适,也可以放一张静态图在底部,待 APNG 加载完毕后替换,不过这种需要第一帧是可以对用户静态展示的。
-
apng-canvas 解码比较耗时,如果动画是进页面就展示的,会增加页面阻塞时间。笔者尝试过放到Web Worker 中解析,可以节省耗时 100ms 左右。
6. 参考资料
文中图片和相关信息来自以下参考资料:
- gist.github.com/eligrey/175…
- wiki.mozilla.org/APNG_Specif…
- littlesvr.ca/apng/inter-…
- github.com/davidmz/apn…
- Web 端 APNG 播放实现原理
- APNG 那些事
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!