海报绘制之微信小程序与H5踩坑

453 阅读6分钟

有个海报绘制的需求是这样的:后台返回海报背景图、用户头像、二维码以及用户昵称,前端根据设计图将这些信息组装,并支持保存为图片。

  • 我的方案是用canvas绘制,然后将canvas保存为图片 最初的需求是在微信小程序用,后来为了同时在app和小程序用,改为了H5版本。在这个转换的过程中,遇到并解决了很多问题,记录如下:
  1. 微信小程序直接绘制base64图片真机不显示,需要将base64图片转为本地图片,绘制的时候使用本地临时路径
  2. 微信小程序使用网络图片,必须是https协议,且需要先将图片下载至本地,绘制的时候使用本地临时路径
  3. 微信小程序和H5提供的绘图api以及属性不完全相同,具体看官方文档
  4. 微信小程序和H5获取dom元素宽高的方式不同
  5. 微信小程序和H5根据设计图尺寸计算绘制尺寸的方式不同(这点很重要)
  6. 绘图时,要注意先后顺序,比如先绘制背景图,后绘制其它内容。否则会出现部分内容不显示的情况,其实是先绘制的被后绘制的遮挡住了。这种情况在绘制图片的时候最容易出现,所以最好将依赖与图片的绘制放在图片的onload回调中
  7. 如果使用网络图片,H5使用toDataURL的时候可能会出现跨域的情况,这时候在前端需要设置图片的crossOrigin = 'anonymous'(参照H5版本代码),同时检查图片所在服务器是否允许跨域。如果都设置了,还是报跨域,那么试试换一张图片,或者清理缓存(我在这载了大跟头,同一张图片,第一次访问的时候跨域,然后设置了允许跨域,后面访问的时候还是会跨域,因为用的是缓存)。
  8. 微信小程序绘制的时候使用的都是本地临时路径,不存在跨域的问题

微信小程序版本(uniapp):

export class HB {
  info = {
    canvasId: 'recommendCanvas',
    bgUrl: '', // 背景图
    codeUrl: '', // 固定二维码
    avatarUrl: '', // 用户头像
    userName: '--', // 用户名称
    posterWrap: null
  }
  /**
   * 下载文件
   * @param type 文件类型,用于从info变量获取文件路径
   * @param self 自定义组件的this,画海报需要调用this.createSelectorQuery()
   */
  createPoster(options, self) {
    if (options.avatarUrl) {
      options.avatarUrl = options.avatarUrl.replace('http://', 'https://')
    }
    this.info = Object.assign(this.info, options)

    let promise1 = this.base64ToFile(this.info.codeUrl)
    // 下载背景图
    let promise2 = this.downloadFile('bg', '海报图片')
    // 下载用户头像
    let promise3 = this.downloadFile('avatar', '用户头像')

    Promise.all([promise1, promise2, promise3])
      .then(res => {
        this.paintPosteCanvas(res[0], res[1], res[2])
      })
      .catch(err => {
        console.log(err)
      })
  }
  /**
   * 下载文件
   * @param type 文件类型,用于从info变量获取文件路径
   * @param tips 描述文字,文件下载失败的提示文字
   * @return promise,临时文件路径
   */
  downloadFile(type, tips) {
    uni.showLoading({
      title: '生成中...',
      mask: true
    })
    return new Promise((resolve, reject) => {
      uni.downloadFile({
        url: this.info[type + 'Url'], // 图片路径
        success: res => {
          wx.hideLoading()
          if (res.statusCode === 200) {
            resolve(res.tempFilePath)
          } else {
            resolve('')
            uni.showToast({
              title: tips + '下载失败!',
              icon: 'none',
              duration: 2000
            })
          }
        },
        complete: res => {
          resolve('')
        }
      })
    })
  }

  // 绘制海报
  paintPosteCanvas(codeSrc, bgSrc, avatarSrc) {
    let that = this
    uni.showLoading({
      title: '生成中...',
      mask: true
    })
    const ctx = wx.createCanvasContext(this.info.canvasId) // 创建画布
    wx.createSelectorQuery()
      .select('#recommend-poster-container')
      .boundingClientRect(function(rect) {
        var height = 460 / rect.height
        var width = 300 / rect.width
        // 背景图片
        if (bgSrc) {
          ctx.drawImage(bgSrc, 0, 0, rect.width, 386 / height)
        }
        // 底部背景色
        ctx.setFillStyle('#fff')
        ctx.fillRect(0, 386 / height, rect.width, 74 / height)
        // 用户名
        ctx.setFontSize(14)
        ctx.setFillStyle('#333')
        ctx.fillText(that.info.userName, 49 / width, 417 / height)
        // 宣传语
        ctx.setFontSize(12)
        ctx.setFillStyle('#999')
        ctx.fillText('邀请您领取通关好礼', 16 / width, 445 / height)
        // 扫码领取
        ctx.setFontSize(10)
        ctx.setFillStyle('#999')
        ctx.fillText('扫一扫领取福利', 208 / width, 446 / height)

        //  绘制二维码
        if (codeSrc) {
          ctx.save()
          ctx.drawImage(codeSrc, 208 / width, 362 / height, 70 / width, 70 / width)
          ctx.restore()
        }
        // 绘制头像
        if (avatarSrc) {
          const r = 28 / width / 2
          ctx.save()
          ctx.beginPath() // 开始绘制
          ctx.strokeStyle = '#fff'
          // 先画个圆   前两个参数确定了圆心 (x,y) 坐标  第三个参数是圆的半径  四参数是绘图方向  默认是false,即顺时针
          ctx.arc(15 / width + r, 399 / height + r, r, 0, Math.PI * 2, false)
          ctx.stroke()
          ctx.clip() // 画好了圆 剪切  原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
          ctx.drawImage(avatarSrc, 15 / width, 399 / height, 28 / width, 28 / width) // 推进去图片,必须是https图片
          ctx.restore() // 恢复之前保存的绘图上下文 恢复之前保存的绘图上下午即状态 还可以继续绘制
        }
      })
      .exec()

    setTimeout(function() {
      ctx.draw()
      uni.hideLoading()
    }, 1000)
  }

  // 点击保存到相册
  savePoster() {
    return new Promise((resolve, reject) => {
      this.canvasToTempFilePath().then(tempFilePath => {
        wx.saveImageToPhotosAlbum({
          filePath: tempFilePath,
          success(res) {
            wx.showToast({
              title: '保存到相册成功',
              icon: 'success',
              duration: 2000
            })
            setTimeout(function() {
              resolve()
            }, 2000)
          },
          fail: function(res) {
            wx.showToast({
              title: res.errMsg,
              icon: 'none',
              duration: 2000
            })
            reject()
          }
        })
      })
    })
  }

  // 把当前画布指定区域的内容导出生成指定大小的图片。在 draw() 回调里调用该方法才能保证图片导出成功。
  canvasToTempFilePath() {
    return new Promise((resolve, reject) => {
      // let that = this
      uni.showLoading({
        title: '正在保存',
        mask: true
      })

      wx.canvasToTempFilePath({
        canvasId: this.info.canvasId,
        success: function(res) {
          uni.hideLoading()
          let tempFilePath = res.tempFilePath
          wx.getSetting({
            success(res) {
              if (!res.authSetting['scope.writePhotosAlbum']) {
                wx.authorize({
                  scope: 'scope.writePhotosAlbum',
                  success() {
                    resolve(tempFilePath)
                  }
                })
              } else {
                resolve(tempFilePath)
              }
            }
          })
        }
      })
    })
  }
  // base64图片转本地图片,直接绘制base64真机不显示
  async base64ToFile(data) {
    await this.removeTempFile()
    let reg = new RegExp('^data:image/png;base64,', 'g')
    let base64Data = data.replace(reg, '')
    let filePath = wx.env.USER_DATA_PATH + '/qrcode.png'
    return new Promise((resolve, reject) => {
      wx.getFileSystemManager().writeFile({
        filePath,
        data: base64Data,
        encoding: 'base64',
        success: (res) => {
          wx.getImageInfo({
            src: filePath,
            success(result) {
              resolve(result.path)
            },
            fail(err) {
              console.log('读取图片错误', err)
              reject(err)
            }
          })
        },
        fail(err) {
          reject(err)
        }
      })
    })
  }
  removeTempFile() {
    return new Promise(resolve => {
      let fsm = wx.getFileSystemManager()
      fsm.readdir({
        dirPath: wx.env.USER_DATA_PATH,
        success(res) {
          res.files.forEach(el => {
            if (el !== 'miniprogramLog') {
              fsm.unlink({
                filePath: `${wx.env.USER_DATA_PATH}/${el}`,
                fail(err) {
                  console.log('readdir删除失败', err)
                }
              })
            }
          })
          resolve()
        }
      })
    })
  }
}

H5版本:

import { Toast } from 'vant'
const getPixelRatio = function (context) {
  const backingStore = context.backingStorePixelRatio ||
    context.webkitBackingStorePixelRatio ||
    context.mozBackingStorePixelRatio ||
    context.msBackingStorePixelRatio ||
    context.oBackingStorePixelRatio ||
    context.backingStorePixelRatio || 1
  return (window.devicePixelRatio || 1) / backingStore
}
export class HB {
  info = {
    canvasId: '',
    bgUrl: '', // 背景图
    codeUrl: '', // 固定二维码
    avatarUrl: '', // 用户头像
    userName: '--', // 用户名称
    posterWrap: null
  }

  /**
   * 下载文件
   * @param type 文件类型,用于从info变量获取文件路径
   */
  createPoster (options, self) {
    if (options.avatarUrl) {
      options.avatarUrl = options.avatarUrl.replace('http://', 'https://')
    }
    this.info = Object.assign(this.info, options)
    this.paintPosteCanvas(this.info.codeUrl, this.info.bgUrl, this.info.avatarUrl)
  }

  // 绘制海报
  paintPosteCanvas (codeSrc, bgSrc, avatarSrc) {
    const that = this
    Toast.loading({
      message: '生成中...',
      forbidClick: true
    })

    const canvasEle = document.querySelector('#' + this.info.canvasId)
    const ratio = getPixelRatio(canvasEle)
    canvasEle.width = ratio * canvasEle.width
    canvasEle.height = ratio * canvasEle.height
    const ctx = canvasEle.getContext('2d') // 创建画布

    if (ctx) {
      // 背景图片
      if (bgSrc) {
        const bgImg = new Image()
        bgImg.crossOrigin = 'anonymous'
        bgImg.onload = function () {
          ctx.drawImage(bgImg, 0, 0, 300 * ratio, 386 * ratio)
          // // 底部背景色
          ctx.fillStyle = '#fff'
          ctx.fillRect(0, 386 * ratio, 300 * ratio, 74 * ratio)
          // // 用户名
          ctx.font = 14 * ratio + 'px Arial'
          ctx.fillStyle = '#333'
          ctx.fillText(that.info.userName, 49 * ratio, 417 * ratio)
          // // 宣传语
          ctx.font = 12 * ratio + 'px Arial'
          ctx.fillStyle = '#999'
          ctx.fillText('邀请您领取通关好礼', 16 * ratio, 445 * ratio)
          // // 扫码领取
          ctx.font = 10 * ratio + 'px Arial'
          ctx.fillStyle = '#999'
          ctx.fillText('扫一扫领取福利', 208 * ratio, 446 * ratio)

          // //  绘制二维码
          if (codeSrc) {
            ctx.save()
            const codeImg = new Image()
            codeImg.onload = function () {
              ctx.drawImage(codeImg, 208 * ratio, 362 * ratio, 70 * ratio, 70 * ratio)
            }
            codeImg.src = codeSrc
            ctx.restore()
          }
          // // 绘制头像
          if (avatarSrc) {
            avatarSrc = avatarSrc.replace('xuetian.oss-cn-hangzhou.aliyuncs.com', 'oss.xuetian.cn')
            const r = 28 * ratio / 2
            ctx.save()
            ctx.beginPath() // 开始绘制
            ctx.strokeStyle = '#fff'
            // 先画个圆   前两个参数确定了圆心 (x,y) 坐标  第三个参数是圆的半径  四参数是绘图方向  默认是false,即顺时针
            ctx.arc(15 * ratio + r, 399 * ratio + r, r, 0, Math.PI * 2, false)
            ctx.stroke()
            ctx.clip() // 画好了圆 剪切  原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
            const avatarImg = new Image()
            avatarImg.crossOrigin = 'anonymous'
            avatarImg.onload = function () {
              ctx.drawImage(avatarImg, 15 * ratio, 399 * ratio, 28 * ratio, 28 * ratio)
            }
            avatarImg.src = avatarSrc
            ctx.restore() // 恢复之前保存的绘图上下文 恢复之前保存的绘图上下午即状态 还可以继续绘制
          }
        }
        bgImg.src = bgSrc
      }
    }

    setTimeout(function () {
      Toast.clear()
    }, 1000)
  }
}

 savePoster () {
      const url = document.getElementById(this.info.canvasId).toDataURL()
      const a = document.createElement('a')
      a.download = 'poster'
      a.href = url
      document.body.appendChild(a)
      a.click()
      a.remove()
    }