阅读 1726

Web 帧动画解决方案 - APNG原理与实现

前言

作为前端同学,或多或少都会接到动画需求。如果是有规律性的动画还是相对容易实现的,但如果是比较复杂的帧动画,我们用 CSS 实现的话,就非常容易造成如下情况,设计师是卖家秀,我们开发的是买家秀。

image-20210104112052946

或许你会想到用 GIF 实现,但是 GIF 经常会有杂边,无法满足设计师对精致度的要求。所以我们需要寻找更多的动画方案,能够让我们 100% 还原设计稿,又保证动画的精致度和性能。本文笔者主要是介绍的是 APNG 方案。

APNG(Animated Portable Network Graphics)是基于 PNG 格式扩展的一种动画格式,增加了对动画图像的支持,同时加入了 24 位图像和 8 位 Alpha 透明度的支持,这意味着动画将拥有更好的质量。

首先来看下 APNG 和 GIF 的对比效果:

clock.gifclock.png

上面的图不动的话,或者查看更多 Demo 请直接看 Demo1Demo2,可以发现 APNG 和 GIF 的大小虽然相差不大,但是 APNG 要比 GIF 清晰的多,并且没有杂边。这是因为 APNG 拥有 24 位图像和 8 位 Alpha 透明度的支持。接下来一起看看 APNG 的主要原理和使用吧。

1. APNG 数据格式

1.1 PNG

在查看 APNG 数据格式前,要先了解下 PNG 的数据格式,毕竟 APNG 是基于 PNG 格式扩展的。PNG 的数据格式如下:

image-20210105200622328

主要分为 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 张时钟图片生成。

image-20210105201547679

效果:clock_apng.png(不动的话就直接看上述 Demo)

2. 性能

学习完 APNG 的数据格式,以及通过上面的 Demo 我们可以发现,一个时钟动画存储了 4 帧时钟图像数据,意味着一张 APNG 动画必然很大。如果有几十帧,那更是不敢想象了。页面加载动画很慢,反而造成不好的用户体验,那动画也没什么存在的意义了。

但是 APNG 的团队也意识到这个问题,因此也会进行帧优化:

image-20210105202906640

如上这 4 帧,可以看出表盘部分是可以复用的,因此在生成 APNG 前,APNG 会通过算法计算帧之间的差异,只存储帧之前的差异,而不是存储全帧。如下,第 2、3、4 帧都没有表盘部分了。

image-20210105202959107

优化后的 APNG 大小如下,可以看出第 2、3、4 帧数据要比第一帧小了很多。

image-20210105203025743

但是这里有个问题便是,第 2、3、4 帧如何绘制呢?如何知道复用哪些元素呢?这个问题会在后面解答。

3. apng-canvas 源码分析

平时我们使用 APNG 方式如下,非常简单:

<img src="xxx.png" />
复制代码

但是直接使用 img 标签存在 2 个问题:

  1. 兼容性问题,APNG 兼容性目前来看还算可以,取决于各个公司希望的兼容程度使用。
  2. 一个非常大的坑,在 Safari for iOS(Safari for macOS正常)预览 APNG 的时候,动图的循环次数为对应原图的 loop + 1。比如 APNG 有 10帧,loop 为 2,那么会循环总计展示 30 帧。如果我们的动画只想播放一次的话,那就糟糕了。

所以一般我们推荐 使用 apng-canvas 这个库。该库需要以下支持才能运行:

接下来带大家看一下 apng-canvas 库是如何实现 APNG 正常播放的,主要分为 3 个步骤:

  1. 解析 APNG 数据格式(按照 1.2 小节的 APNG 图片格式)。
  2. 将解析好的 APNG 数据进行整理。
  3. 按照每一帧的间隔时长,通过 requestAnimationFrame 进行绘制每一帧。

源码 apng-canvas/src 目录结构如下:

├─animation.js // APNG动画逻辑
├─crc32.js // 解码运算相关
├─loader.js //APNG下载
├─main.js // 入口
├─parser.js // 解码
├─support-test.js // 兼容性检查
复制代码

3.1 解析 APNG 数据格式

解码流程如下:

img

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 张图,前面提到过这是优化后的效果:

image-20210105202959107

也可以看出只有第一帧的 widthheightlefttop 比较完整,第 2、3、4 帧的 widthheightlefttop 都是不同的,因为被算法优化过。

那么 blendOpdisposeOp 字段分别代表什么呢?可以看出笔者是没有注释的,这 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为帧间隔时间
};
复制代码

从上面的绘制代码中,我们可以看到 blendOpdisposeOp 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]
}());
复制代码
  1. 加载一张 1x1 像素大小的 Base64 编码图片,图像有 2 帧数据,区分就是每一帧最后一个值不同。

    // frame 1 (skipped on apng-supporting browsers): [0, 0, 0, 255]
    // frame 2: [0, 0, 0, 0]
    复制代码
  2. 将其绘制到画布中,通过 getImageData() 方法去获取该图片的像素数据,主要是获取 data[3] 的 Alpha 透明通道(值的范围:0 - 255)。在不支持 APNG 的浏览器上会降级只显示第一帧,因此 data[3] 会等于 255。在支持 APNG 的浏览器上最终会显示第 2 帧,因此 data[3] 会等于 0,则表示支持 APNG。

5. 总结

  1. 本文介绍了 APNG 的使用、性能、踩坑、兼容性以及检测、 apng-canvas 库源码分析,主要是对笔者个人学习进行总结。

  2. 在实际使用中,由于 Safari for iOS 中 loop 会自动 +1,所以不适合那些只播放一次的动画。

  3. APNG 文件存储多帧数据会很大,所以建议使用比较小的动画场景上。如果场景合适,也可以放一张静态图在底部,待 APNG 加载完毕后替换,不过这种需要第一帧是可以对用户静态展示的。

  4. apng-canvas 解码比较耗时,如果动画是进页面就展示的,会增加页面阻塞时间。笔者尝试过放到Web Worker 中解析,可以节省耗时 100ms 左右。

image-20210106203526901

6. 参考资料

文中图片和相关信息来自以下参考资料: