玩转apng实现动画效果

双12前接了一个小项目“我的小纸条”,合作方中间各种延期改需求,好在两位师弟很给力很靠谱,一起把设计师的需求给完美实现,最终项目顺利上线。

最终效果如下(Gif图片有6MB,稍微多等会儿):
最终效果
(如果看不了,可以尝试点击这里在页面观看效果图)

前期技术调研

  1. 存在着大量的图片和3个动画效果,这意味着需要使用大量的动图,那么gif、apng和webp这三种格式需要如何抉择?

    答:首先,gif格式的图片体积相对来说比较大,且gif图片每个像素只有8bit,显示效果不好[1] ,而webp的兼容性暂时没有apng好,最终在考虑了显示效果、性能、兼容性这三种情况[2],我们决定使用apng格式,且考虑到低版本的手淘不支持apng,所以引入了apng-js这个库来对apng的图片做处理,并使用canvas作为画布来把我们所需要的结果绘制出来,将使得我们可以放心大胆地使用apng。

  2. 页面性能如何兼顾?

    答:考虑飞机特效比较复杂,所以使用apng动图来实现,一开头的文字和后面的纸条展示使用transition并结合Promise做异步流程控制。

整体执行顺序图

成功
失败
获取apng图片
配置canvas运行时环境
获取player对象, 等待播放动画
降级为png纯图片
异步加载剩余的图片资源
用Promise控制整个动画流程

代码分析

项目是在weex环境下用Rax语法写的,Rax[3]相当于一个DSL,语法和React类。

这里要说到是,我们对普通的图片和apng图的处理方式是不同的,普通图片可以通过JS代码中Image对象来加载,而对于apng图片,我们需要将其转换为ArrayBuffer对象[4],这是一种通用的、固定长度的原始二进制数据格式,可用来处理声音、视频等其他信息。这里我们封装一个loadImg方法来处理这两种情况。

同时为了实现顺序的异步控制,我们用Promisethen方法来做异步控制,具体情况详见代码:

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;
}

总结

  1. 兼容性问题是个大坑,apng的效果还是杠杠的。
  2. 处理嵌套的Promise时,善用Promise.resolve

引用资料

  1. APNG 那些事

  2. UC内核支持更好的动画格式

  3. Rax介绍

  4. ArrayBuffer对象

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值