小程序 canvas 绘制海报(踩坑)

最近有一个关于小程序的需求——集卡。这个需求有两个比较难搞定的地方:1. 卡牌翻转动画。2. 生成海报。

小程序基础库:2.9.0

卡牌翻转

这个实现的逻辑很简单,一个容器,里面两个容器代表正反,通过 backface-visibility 设置背面可见性。卡牌制好后添加动画(JavaScript 和 css 都可以)就可以实现卡牌翻转效果了。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="divport" content="width=device-width, initial-scale=1.0">
  <title>backface-visibility</title>
  <style>
    .card-wrapper {
      width: 100px;
      height: 130px;
      position: relative;
      transform-style: preserve-3d;
    }

    .cover-image,
    .back-image {
      width: 100%;
      height: 100%;
      position: absolute;
      left: 0;
      top: 0;
      backface-visibility: hidden;
    }

    .cover-image {
      transform: rotateY(180deg);
      transform-origin: center;
    }

    .card-image {
      width: 100%;
      height: 100%;
    }

    .card-text {
      display: block;
      width: 100%;
      line-height: 1;
      margin-top: -50px;
      text-align: center;
      color: white;
      font-size: 36rpx;
      text-shadow:1px 2px 4px rgba(27,95,168,0.6);
    }

    .back-image .card-text {
      opacity: 0.4;
      margin-top: -58px;
    }
  </style>
</head>
<body>
  <div class="card-wrapper">
    <div class="cover-image">
      <img class="card-image" src="xxx1.png" />
      <span class="card-text">分类</span>
    </div>
    <div class="back-image">
      <img class="card-image" src="xxx2.png"></img>
      <span class="card-text">分类</span>
    </div>
  </div>
</body>
</html>

以上代码在 chrome 里运行良好。如果等效的小程序代码也运行良好我就没必要写“卡牌翻转”这一节了。(在小程序里我用的 this.animate 动画函数。)

在小程序里碰到的第一个问题是,翻转过来的卡牌背面字体也会显示(即正反面文字都会显示)。

小程序里,“圾”和“类”翻转后两面文字都可以看到

网上搜了一下也没找到解决方案,可能因为这类需求太少了。后来,只能对文字条件渲染。即在翻转动画的前半程和后半程各渲染一面的文字。

翻转动画的第二个问题是 iOS 有黑影

小程序iOS翻转卡牌bug

这个问题我百思不得其解,至今未解决。

生成海报

我需要用 canvas 将后台给的海报(一个链接)和活动二维码(即时生成)合并到一起,然后显示并可以保存到相册。

目前官网上关于 canvas 的文档都是新版的(竟然没给旧版文档入口),我也就按照新版的来写了。然后,各种意想不到的问题就来了。

首先,querySelector 获取不到 canvas 元素。我的海报生成工作是在组件里做的。根据网上找到的答案,在组件里需要加上 in :const query = wx.createSelectorQuery().in(this)。然而,我还是没能获取到 canvas。后来找到一篇解答说 canvas 元素需要添加 type=”2d”。试了下,真的解决了。总结一下:in 解决的是组件内选择元素的问题。type=”2d” 解决的是选择 canvas 的问题。

然后,就可以绘制了。对于网络图片,需要先下载下来。在 web 端,可以使用 new Image() 实现。但是,小程序没有这个构造函数。百度后发现,可以先通过相关 api 把图片下载下来。

// 于是有了这段代码
wx.getImageInfo({
  src: this.properties.posterUrl,
  success: (res) => {
    console.log(res)
    this.setData({
      tempPosterWidth: res.width,
      tempPosterHeight: res.height,
      posterImage: res.path
    })
  },
}

获取到本地路径后就可以绘制了。但是,调用 ctx.drawImage 失败了。原来新版 canvas 的 drawImage 已经不支持本地临时路径了。没办法,只能再将临时路径加载成图片。

const image = canvas.createImage()
image.src = this.data.posterImage
// 以下两行代码手机端报错,因为 style 为 undefined。直接设置 width 和 height 也报错,因为 width 只有 getter 属性——只读。
// image.style.width = TPW + 'px'
// image.style.height = TPH + 'px'

image.onload = () => {
  ……
}

经过这一番折腾,可以绘制海报主体了。接着就是把二维码加上。

生成小程序二维码的接口只能服务端调用,并且 wxacode.getUnlimited 这个接口的 scene 有长度限制(32位),而我的参数是肯定超过 32 位的。没办法,只能让后端接收参数后先保存起来,生成 uuid 作为 scene,扫码进入页面后我再通过 uuid 请求保存的参数,然后再进入正常流程(真的想放声高歌,声嘶力竭的那种)。

不管怎样,总算可以拿到二维码了。为了方便处理(我以为),我让后端先转成 base64 再给我(I am too young~~)。我百度了下小程序的 canvas 怎么绘制 base64,惊奇地发现需要先保存成本地图片,然后才能绘制。没办法,从网上复制了一段保存 basa64 为本地图片的代码。

const fsm = wx.getFileSystemManager();
const FILE_BASE_NAME = 'tmp_base64src';

export const base64src = function(base64data) {
  return new Promise((resolve, reject) => {
    const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64data) || [];
    if (!format) {
      reject(new Error('ERROR_BASE64SRC_PARSE'));
    }
    const filePath = `${wx.env.USER_DATA_PATH}/${FILE_BASE_NAME}.${format}`;
    const buffer = wx.base64ToArrayBuffer(bodyData);
    fsm.writeFile({
      filePath,
      data: buffer,
      encoding: 'binary',
      success() {
        resolve(filePath);
      },
      fail() {
        reject(new Error('ERROR_BASE64SRC_WRITE'));
      },
    });
  });
};
// 获取二维码
this.getWxacode()
  .then(res => {
    base64src('data:image/png;base64,' + res)
      .then(path => {
        this.setData({
          qrcodeImage: path
        })
        setTimeout(() => {
          this.generatePoster()
        }, 1000)
      })
  })

用和海报主体一样的方法,将二维码绘制到 canvas 上。

保存海报需要先通过 wx.canvasToTempFilePath 将海报转成临时图片,然后,再通过 wx.saveImageToPhotosAlbum 保存到相册。这里会有文件名太长并无法自定义的问题。不过这无法解决。

以上的流程在开发者工具和我的安卓手机是可以的,但是在 iOS 上就不可以。iOS 微信小程序的 vConsole 里看不出错误,但就是不绘制。至于真机调试,嗯~~ type=’2d’ 和 type=’webgl’ 的 canvas 不支持真机调试。

因为实在不知道为什么无法绘制,只能不使用新版 canvas(去掉 type=’2d’)。

使用旧版 canvas 碰到的第一个问题是 querySelector 无法获取 canvas 元素。具体原因未知,应该是不支持旧版 canvas。旧版 canvas 获取绘制上下文是通过 wx.createCanvasContext 实现的。

const ctx = wx.createCanvasContext('share-canvas', this)

ctx.beginPath()
ctx.drawImage(this.data.posterImage, 0, 0, TPW, TPH)

let tempWidth = Math.floor(TPW / 3)
let tempHeight = Math.floor(TPH / 3)
tempWidth = tempWidth < tempHeight ? tempWidth : tempHeight

ctx.drawImage(this.data.qrcodeImage, Math.floor(TPW / 2) - Math.floor(tempWidth / 2), Math.floor(TPH / 2 - tempWidth / 2), tempWidth, tempWidth)
ctx.draw()

this.setData({
  showLoading: false,
  posterWidth: TPW,
  posterHeight: TPH,
})

旧版 canvas 在开发者工具、安卓、iOS 三端都可以成功绘制图片(谢天谢地,总算可以了)。但是保存图片后发现还有一个问题:图片模糊

移动端存在 dpr 问题。在 web 开发中,可以通过 ctx.scale 解决问题。可在小程序里,也许新版可以通过 ctx.scale 解决,但旧版就无能为力了。并且旧版 canvas 不支持设置 ctx 的宽高(在网上有一段设置 ctx 宽高的代码)。要生成高清的海报,需要绘制大图,然后通过 css 样式 zoom 缩放显示。如果三端都支持 zoom 的话这篇文章就可以到此结束了。

由于安卓不支持 zoom,所以就图片模糊问题只能换种解决方法:canvas 定位到屏外,大图绘制。绘制结束后保存成临时图片,然后通过 image 显示。

<!-- 显示的海报 -->
<image
  wx:if="{{ !showLoading }}"
  style="width: {{ posterWidth / systemInfo.pixelRatio }}px; height: {{ posterHeight / systemInfo.pixelRatio }}px;"
  src="{{ posterShowUrl }}"
></image>
<!-- 实际绘制的 canvas -->
<canvas
  id="share-canvas"
  class="share-canvas"
  canvas-id="share-canvas"
  style="width: {{ posterWidth }}px; height: {{ posterHeight }}px;"
/>
.share-canvas {
  position: fixed;
  left: 2000%;
  top: 2000%;
}

由于 ctx.draw 是异步的,setData 后的渲染也是异步的,所以将 canvas 保存成临时图片的操作需要在 ctx.draw 的回调里完成。

ctx.draw(true, () => {
  setTimeout(this.handleSavePoster.bind(this), 300)
})

至此,绘制海报算是完成了。其中坎坷一言难尽。

最后,祝愿小程序早日倒闭。


注意点

  • 不同二维码临时保存后绘制出的结果相同
    在开发者工具上,不同二维码的 base64 临时保存为图片后进行绘制,得到的结果二维码是不同的。但在手机上,出现了不同二维码绘制结果相同的现象。暂且推测为手机系统缓存问题。解决方法是每次保存时指定不同的路径(文件名)const filePath = `${wx.env.USER_DATA_PATH}/${FILE_BASE_NAME}${Date.now()}.${format}`;
  • 二维码绘制不出来或者海报一直绘制不出来的问题
    暂且推断为网络原因。并无解决方法。