解锁前端动画新姿势-APNG动画

3,587 阅读7分钟

文章首发个人博客

在 Now 直播新版抽奖内,用户进房之后可以点击任意一个地方(金木水土日月雷)进行抽奖。如下图所示:

动画的播放分为两个部分: 闪电的播放以及奖品部分的播放。

闪电部分动画大约有 25 帧,而奖品部分由于动画华为复杂大约有 500 帧左右。对于帧数如此多的场景使用什么样的方式播放动画是需要我们进行探讨的问题。

# 常见的播放动画方式

做过动画的同学应该都清楚,常见的播放动画的方式有

  • cssanimation或者transition;

    • 优点: 使用比较简单
    • 缺点: 对于 500+帧的情况,动画播放需要依赖于图片是否下载成功,500+图片的下载,势必会导致动画卡顿
  • 使用精灵图+background-position+@keframes播放动画;

    • 优点: 将多个图片组合成一张,会减少 http 请求数量
    • 缺点:
      1. 要求每帧的大小必须一致,需要精确控制位置
      2. 动画的控制较为复杂
      3. 维护起来比较麻烦,如果要新加帧/删除帧涉及到代码的改动
  • 使用 gif 播放动图

    • 优点: 使用简单,只需要设计同学给一张图片即可
    • 缺点:
      1. gif 只支持 256 色调色板,因此,详细的图片和写实摄影图像会丢失颜色信息,而看起来却是经过调色的
      2. gif 支持有限的透明度,没有半透明效果或褪色效果
      3. gif 经过压缩之后播放动画没有那么流畅
      4. 边缘有杂边
  • 使用 video 标签以视频的方式播放动画

    • 优点: 使用简单,利用自带的 video 标签及 api 即可控制动画的播放,暂停等
    • 缺点:
      1. 移动端兼容性不太好,特别是 android 下,可能被各个系统拦截,然后自己去实现了播放器插件会存在兼容性问题;
      2. 需要处理 video 自动播放、影藏控制条等兼容问题
  • 使用 lottie-web 播放动画

    • 优点:

    1. 使用 lottie 方案,json 文件大小会比 gif 文件小很多,性能也会更好;

    2. 前端可以方便的调用动画,并对动画进行控制,减少前端动画工作量

    • 缺点

    1. lottie-web 文件本身仍然比较大,lottie.js 大小为 513k,轻量版压缩后也有 144k,经过 gzip 后,大小为 39k。
    2. 有很少量的 AE 动画效果,lottie 无法实现,有些是因为性能问题,有些是没有做。比如:描边动画等;
    3. 需要设计师在 AE 中导出 json 内容,动画比较复杂情况 json 比较大
  • 使用 apng 播放动图

    • 优点:

    1. 相比 gif 可以容纳更多的色彩;且向下兼容 png 格式图片;

    2. 支持 alpha 透明通道

    3. 图片体积相比 gif 更小

    • 缺点

    1. 兼容性问题较差
  • javascript 利用搭配 requestAnimationFrame+canvas 绘制动画

    • 优点: 可以自己控制动画的播放;
    • 缺点: 需要找到一个合适的搭档,进行逐帧播放

为了更加直观的感受到 gif/apng 的区别,有兴趣的同学可以点击这里查看效果对比 (opens new window)

# 我们的方案

首先我们需要否决逐帧单张加载的方法, 这样会导致一共需要加载 500+张图片, 这显然不合理;

使用 gif 播放有杂边且色彩支持比较少;所以暂时否决;

引入lottie-web会导致工程体积变大,且未来如果动画要更换,如果设计到 lottie 不支持的动画则动画会播放失败; 导出的 json 也比较大。

因此备选的方案有利用 APNG 或者 sprite 图片来解决问题. 为了方便后续的维护以及代码逻辑的简洁性, 项目中选取了 APNG 来解决动画帧的问题.

但需要注意的是, 出于兼容性和可控性的考虑, 项目并没有采用直接播放 apng 图片的方式.

所以我们最终的方案为: apng+requestAnimationFrame+canvas

使用此方案的优点有:

  1. apng 的加持,对每一帧的位置/宽高 没有 精灵图要求那么严格;
  2. apng 相比 gif 体积更小且支持透明通道;且支持更多的色彩;
  3. 使用requestAnimationFrame+canvas的方式可以自己控制什么时候播放动画
  4. requestAnimationFrame+canvas没有兼容性问题

# 动画播放思路

整个动画播放的思路为:

  1. 获取 apng 图片
  2. apng 中解析各个帧
  3. 使用 canvas+requestAnimationFrame 播放动画

# 1、获取 apng 图片

export const fetchApngData = (id: number) => new Promise<ArrayBuffer>((resolve, reject) => {
  const xhr = new XMLHttpRequest();
  xhr.onload = () => {
    resolve(xhr.response as ArrayBuffer);
  };
  xhr.onerror = (err) => {
    reject(err);
  };
  xhr.responseType = 'arraybuffer';
  xhr.open('get', apngs[id]);
  xhr.send();
}));

# 2、 apng 帧的解析

在解析帧之前,我们先简单了解一下 apng 及 png 的组成结构:

其中 PNG 主要包括 PNG Signature``、IHDR、IDAT、IEND 和 一些辅助块:

  • PNG Signature 是文件标示,用于校验文件格式是否为 PNG

PNG Signature为8个字节: const PNGSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

  • IHDR 是文件头数据块,包含图像基本信息,例如图像的宽高等信息;
  • IDAT 是图像数据块,存储具体的图像数据,一个 PNG 文件可能有一个或多个 IDAT 块;
  • IEND 是结束数据块,标示图像结束;辅助块位于 IHDR 之后 IEND 之前

APNG 在 PNG 的基础上增加了 acTL、fcTL 和 fdAT 3 种块:

  • acTL:动画控制块,包含了图片的帧数和循环次数( 0 表示无限循环)
  • fcTL:帧控制块,属于 PNG 规范中的辅助块,包含了当前帧的序列号、图像的宽高及水平垂直偏移量,帧播放时长和绘制方式(dispose_op 和 blend_op)等,每一帧只有一个 fcTL 块
  • fdAT:帧数据块,包含了帧的序列号和图像数据,仅比 IDAT 多了帧的序列号,每一帧可以有一个或多个 fcTL 块。fdAT 的序列号与 fcTL 共享,用于检测 APNG 的序列错误,可选择性的纠正。

所以解析的大致步骤为:

下方代码参考自apng-js (opens new window)

export default function parseAPNG(buffer) {
    const bytes = new Uint8Array(buffer);
    // 1. 校验PNGSignature,如果不是png则直接返回
    if (Array.prototype.some.call(PNGSignature, (b, i) => b !== bytes[i])) {
        return errNotPNG;
    }

    // fast animation test
    let isAnimated = false;
    // 1. 使用acTL校验是否为apng格式,如果不是apng则直接返回
    eachChunk(bytes, type => !(isAnimated = (type === 'acTL')));
    if (!isAnimated) {
        return errNotAPNG;
    }

    const
        preDataParts = [],
        postDataParts = [];
    let
        headerDataBytes = null,
        frame = null,
        frameNumber = 0,
        apng = new APNG();
    // 3. 分别处理不同类型的数据
    eachChunk(bytes, (type, bytes, off, length) => {
        const dv = new DataView(bytes.buffer);
        switch (type) {
            case 'IHDR':
                headerDataBytes = bytes.subarray(off + 8, off + 8 + length);
                apng.width = dv.getUint32(off + 8);
                apng.height = dv.getUint32(off + 12);
                break;
            case 'acTL': // 使用acTL可以获取到apng帧数
                apng.numPlays = dv.getUint32(off + 8 + 4);
                break;
            case 'fcTL': // 使用fcTL获取到所有的帧
                if (frame) {
                    apng.frames.push(frame);
                    frameNumber++;
                }
                frame = new Frame();
                frame.width = dv.getUint32(off + 8 + 4);
                frame.height = dv.getUint32(off + 8 + 8);
                frame.left = dv.getUint32(off + 8 + 12);
                frame.top = dv.getUint32(off + 8 + 16);
                var delayN = dv.getUint16(off + 8 + 20);
                var delayD = dv.getUint16(off + 8 + 22);
                if (delayD === 0) {
                    delayD = 100;
                }
                frame.delay = 1000 * delayN / delayD;
                if (frame.delay <= 10) {
                    frame.delay = 100;
                }
                apng.playTime += frame.delay;
                frame.disposeOp = dv.getUint8(off + 8 + 24);
                frame.blendOp = dv.getUint8(off + 8 + 25);
                frame.dataParts = [];
                if (frameNumber === 0 && frame.disposeOp === 2) {
                    frame.disposeOp = 1;
                }
                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) {
        apng.frames.push(frame);
    }

    if (apng.frames.length == 0) {
        return errNotAPNG;
    }

    const preBlob = new Blob(preDataParts),
        postBlob = new Blob(postDataParts);
    // 组装每一个frame为blob格式
    apng.frames.forEach(frame => {
        var bb = [];
        bb.push(PNGSignature);
        headerDataBytes.set(makeDWordArray(frame.width), 0);
        headerDataBytes.set(makeDWordArray(frame.height), 4);
        bb.push(makeChunkBytes('IHDR', headerDataBytes));
        bb.push(preBlob);
        frame.dataParts.forEach(p => bb.push(makeChunkBytes('IDAT', p)));
        bb.push(postBlob);
        frame.imageData = new Blob(bb, {'type': 'image/png'});
        delete frame.dataParts;
        bb = null;
    });

    return apng;
}

# 3、使用 canvas+requestAnimationFrame 播放动画

  const [frames, setFrames] = useState<Frame[]>([]);

  const tick = useCallback((ptr: number) => {
    // 检查canvas实例与内存中动画帧是否存在
    if (!canvasRef.current) return;
    const { current } = canvasRef;
    const ctx = current.getContext('2d');
    if (!ctx) return;
    const frame = frames[ptr];
    if (!frame.imageElement) return;
    const scaling = (current.parentElement?.clientWidth ?? 0) / 230; // 这个230是标准的帧图片尺寸
    ctx.clearRect(0, 0, current.clientWidth, current.clientHeight);
    ctx.drawImage(
      frame.imageElement,
      frame.left * scaling,
      frame.top * scaling,
      frame.width * scaling,
      frame.height * scaling
    );
  }, [frames]);

  /**
   * 在首次加载与参数id发生变化时会执行, 用以将动画帧加载到内存中
   * @function
   */
  useEffect(() => {
    let timer = -1;
    // 获取指定位置的apng frames
    getOrbApngFrames(id).then(images=>{
        setFrames(images)
    }).catch(()=>{
        timer = window.setTimeout(() => {
          events.launchSubject.complete();
          events.breakSubject.complete();
        }, 1000);
    })
    return () => {
      clearTimeout(timer);
    };
  }, [id, events]);

    useEffect(() => {
    // ... 一些其他处理
    let handle;
    handle = window.setTimeout(() => {
      handle = requestAnimationFrame(function animationTick(t) {
        try {
          if ((t - lastFrameAt) >= (1000 / 30)) { // 30 fps
            tick(ptr);
            // 动画首帧绘制出来之后再让静态的宝珠图片淡出. 以防止出现宝珠消失但动画还没加载出来的情况
            // ... 一切其他处理
          }
          if (ptr < frames.length) {
            handle = requestAnimationFrame(animationTick);
          } else {
            ptr = 0;
            events.breakSubject.complete();
          }
        } catch (err) {
            // ...
        }
      });
    }, 300); // 等激光射过来
    return () => {
      clearTimeout(handle);
      cancelAnimationFrame(handle);
    };
  }, [frames.length, tick, events]);

# 参考

文章首发个人博客