微信小程序《海报生成》

698 阅读5分钟

简介

本文主要介绍微信小程序常用的海报生成并保存到用户手机的常用逻辑,包含以下常用场景:

  • 绘制背景
  • 绘制头像
  • 绘制矩形
  • 绘制文本
  • 保存海报

一. 通用的绘制方法

1.1 canculateRatio(计算像素比例)

有时候 canvas 需要进行适配或 canvas 的宽高是基于 rpx 去计算的,这时候绘制的单位也需要通过比例去计算,所以定义了一个像素转换的公共方法

/**
 * 计算像素比例
 * @description 通过像素转换成适应宽度
 * @param {Number} pixel 当前像素
 * @return {number} 处理后的像素大小
 */
canculateRatio(pixel) {
  const designWidth = 750
  const windowWidth = wx.getSystemInfoSync().windowWidth
  const ratio = windowWidth / designWidth
  return ratio * pixel
}

1.2 createImageNode(创建图片节点)

/**
 * 创建图片对象
 * @param {Object} canvas (canvas 对象)
 * @param {String} imageUrl 图片链接
 */
createImageNode(canvas, imageUrl) {
  return new Promise((resolve) => {
    const imageNode = canvas.createImage()
    imageNode.src = imageUrl
    imageNode.onload = () => resolve(imageNode)
  })
}

1.3 circleImage(绘制圆形图片)

/**
 * 绘制圆形图片
 * @param {Object} ctx (canvas 实例)
 * @param {Object} img 图片节点
 * @param {Number} x (x 坐标)
 * @param {Number} y (y 坐标)
 * @param {Number} r 半径
 */
circleImage(ctx, img, x, y, r) {
  const d = 2 * r
  const cx = x + r
  const cy = y + r

  ctx.save()
  ctx.arc(cx, cy, r, 0, 2 * Math.PI)
  ctx.clip()
  ctx.drawImage(img, x, y, d, d)
  ctx.restore()
}

1.4 roundRect(绘制矩形)

/**
 * 绘制矩形
 * @param {Object} ctx (canvas 实例)
 * @param {Number} x (x 坐标)
 * @param {Number} y (y 坐标)
 * @param {Number} w 宽度
 * @param {Number} h 高度
 * @param {Number} r 半径
 */
roundRect(ctx, x, y, w, h, r) {
  if (w < 2 * r) r = w / 2
  if (h < 2 * r) r = h / 2

  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.arcTo(x + w, y, x + w, y + h, r)
  ctx.arcTo(x + w, y + h, x, y + h, r)
  ctx.arcTo(x, y + h, x, y, r)
  ctx.arcTo(x, y, x + w, y, r)
  ctx.closePath()
}

1.5 drawText、textBreakLines(绘制文本、文本换行)

/**
 * 绘制文本
 * @param {Object} ctx (canvas 实例)
 * @param {Object} textData 文本配置对象
 * @param {String} textData.text 绘制的文本
 * @param {Number} [textData.x = 0] (x 坐标)
 * @param {Number} [textData.y = 0] (y 坐标)
 * @param {Number} [textData.lineHeight = 0] 行高(多行文本传)
 * @param {Number} [textData.maxWidth = 0] 限定宽度(多行文本传)
 * @param {String} [textData.font = '14px san-self'] (font 属性)
 * @param {String} [textData.color = '#FFF'] 字体颜色
 * @param {String} [textData.textAlign = 'center'] 文本对齐方式
 * @param {Boolean} [textData.fontWeight = false] 是否加粗
 */
drawText(ctx, textData) {
  const { 
    text,
    x = 0,
    y = 0,
    lineHeight = 0,
    maxWidth = 0, 
    font = '14px san-self',
    color = '#FFF',
    textAlign = 'center',
    fontWeight = false
  } = textData
  const textWidth =  ctx.measureText(text).width

  ctx.save()
  ctx.font = font
  ctx.fillStyle = color
  ctx.textAlign = textAlign
  
  // 如果没有限定宽度或文本宽度小于限定宽度,则不需要换行
  if (!maxWidth || textWidth < maxWidth) {
    ctx.fillText(text, x, y)

    if (fontWeight) {
      ctx.fillText(text, x + 0.5, y + 0.5)
    }
  } else {
    const texts = this.textBreakLines(ctx, text, maxWidth) // 处理文本换行
    // 绘制文本换行
    texts.forEach((text, index) => {
      ctx.fillText(text, x, y + (index * lineHeight))

      if (fontWeight) {
        ctx.fillText(text, x + 0.5, y + (index * lineHeight) + 0.5)
      }
    })
  }

  ctx.restore()
}
/**
 * 处理文本换行
 * @param {Object} ctx (canvas 实例)
 * @param {String} text 绘制的文本
 * @param {Number} width 最大宽度
 * @return {Array} texts 换行后的文本数组
 */
textBreakLines(ctx, text, width) {
  const texts = []
  let handledText = text
  
  // 当前处理的文本是否大于限定宽度
  while (ctx.measureText(handledText).width > width) {
    let min = 0;
    let max = handledText.length - 1;

    while (min <= max) {
      const middleIndex = Math.floor((min + max) / 2)
      const middleWidth = ctx.measureText(text.substring(0, middleIndex - 1)).width
      const diffWidth = ctx.measureText(text.substring(0, middleIndex)).width
      
      // 判断换行的裁切点
      if (middleWidth <= width && diffWidth > width) {
        texts.push(handledText.substring(0, middleIndex - 1))
        handledText = handledText.substring(middleIndex - 1)
        break
      }
       
      // 如果中间文本宽度小于限定宽度则增加一个文字、否则减少一个文字
      if (middleWidth < width) {
        min = middleIndex + 1
      } else {
        max = middleIndex - 1
      }
    }
  }
  
  // 剩余的文本也需要插入到最后
  if (handledText.length) {
    texts.push(handledText)
  }

  return texts
}

二. 绘制海报

image.png

2.1 WXML、WXSS 准备

<view class="poster-container">
  <canvas id="poster-canvas" type="2d"></canvas>
  <view class="save-btn" bind:tap="savePoster">保存海报</view>
</view>
page {
  min-height: 100%;
  padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
  background-color: #F6F6F6;
  box-sizing: border-box;
}

.poster-container {
  width: 600rpx;
  height: 1000rpx;
  margin: 0 auto;
  padding-top: 40rpx;
}

#poster-canvas {
  width: 100%;
  height: 100%;
}

.save-btn {
  position: fixed;
  left: 50%;
  bottom: env(safe-area-inset-bottom);
  width: 320rpx;
  height: 100rpx;
  line-height: 100rpx;
  text-align: center;
  border-radius: 12rpx;
  background-color: green;
  font-size: 32rpx;
  font-weight: bold;
  color: #FFF;
  transform: translate(-50%, 0);
}

2.2 数据结构准备

const posterBackgroundUrl = 'https://c-ssl.duitang.com/uploads/item/201702/05/20170205201210_dxNE8.thumb.1000_0.jpeg'
const avatar = 'https://p6-passport.byteacctimg.com/img/user-avatar/8143cdd5fe8f12e40034844eb4293d7e~300x300.image'
const userIntro = '当我看到他离开你身边的时候,我就知道,爱一个人要大胆去尝试。当我躲进衣柜的那一刻,我就知道,一个更有资格爱你的人回来了。当我被从衣柜拖出来的那一刻,我就知道,爱一个人是根本藏不住的。当我被暴揍的那一刻,我就知道,爱一个人就要承受她的痛苦'

Page({
  /**
   * 页面的初始数据
   */
  data: {
    canvasWidth: 0,
    canvasHeight: 0,
    canvas: null,
    ctx: null,
  },
})

2.3 获取海报实例并绘制内容

Page({
  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  async onReady() {
    const { 
      canvas, 
      ctx, 
      canvasWidth, 
      canvasHeight
    } = await this.getCanvasInfo()

    this.data.canvas = canvas
    this.data.ctx = ctx
    this.data.canvasWidth = canvasWidth
    this.data.canvasHeight = canvasHeight

    await this._drawPosterBackground()
    this._drawUserIntroRect()
    await this._drawUserInfo()
  },

  /**
   * 获取 Canvas
   */
  getCanvasInfo() {
    return new Promise((resolve) => {
      const query = wx.createSelectorQuery()
      query.select('#poster-canvas').fields({ node: true, size: true }).exec((res) => {
        const canvas = res[0].node
        const canvasWidth = res[0].width
        const canvasHeight = res[0].height
        const ctx = canvas.getContext('2d')
        const dpr = wx.getSystemInfoSync().pixelRatio

        canvas.width = res[0].width * dpr
        canvas.height = res[0].height * dpr
        ctx.scale(dpr, dpr)
        resolve({
          canvas,
          ctx,
          canvasWidth,
          canvasHeight
        })
      })
    })
  },

  /**
   * 绘制背景图片
   */
  async _drawPosterBackground() {
    const { ctx, canvas, canvasWidth, canvasHeight } = this.data
    const backgroundNode = await this.createImageNode(canvas, posterBackgroundUrl)
    ctx.drawImage(backgroundNode, 0, 0, canvasWidth, canvasHeight)
  },

  /**
   * 绘制矩形
   */
  _drawUserIntroRect() {
    const { ctx, canvasWidth } = this.data
    ctx.save()
    ctx.translate(canvasWidth / 2, 0),
    ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'
    this.roundRect(
      ctx,
      -this.canculateRatio(500) / 2, 
      this.canculateRatio(240), 
      this.canculateRatio(500), 
      this.canculateRatio(550), 
      this.canculateRatio(10)
    )
    ctx.fill()
    ctx.restore()
  }, 
  
  /**
   * 绘制用户信息
   */
  async _drawUserInfo() {
    const { ctx, canvas, canvasWidth } = this.data
    const avatarSize = this.canculateRatio(60)
    const avatarNode = await this.createImageNode(canvas, avatar)

    ctx.save()
    ctx.translate(canvasWidth / 2, 0)

    this.circleImage(ctx, avatarNode, -avatarSize, this.canculateRatio(40), avatarSize) // 绘制头像
    this.drawText(ctx, {
      text: '老王语录',
      font: '18px san-self',
      x: 0,
      y: this.canculateRatio(210),
      fontWeight: true
    })
    this.drawText(ctx, {
      text: userIntro,
      font: '18px san-self',
      textAlign: 'left',
      x: -this.canculateRatio(440) / 2,
      y: this.canculateRatio(300),
      lineHeight: this.canculateRatio(48),
      maxWidth: this.canculateRatio(460),
    })

    ctx.restore()
  },
})

三. 保存海报

Page({
  /**
   * 保存海报
   */
  async savePoster() {
    const { canvas, canvasWidth, canvasHeight } = this.data

    try {
      const canvasResult = await wx.canvasToTempFilePath({
        width: canvasWidth,
        height: canvasHeight,
        canvas,
      })
      await wx.saveImageToPhotosAlbum({
        filePath: canvasResult.tempFilePath,
      })

      wx.showToast({
        title: '生成海报成功',
        icon: 'success',
        mask: true,
        duration: 2000
      })
    } catch (err) {
      if (err?.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
        wx.showModal({
          title: '你已拒绝授权',
          content: '如果没有授权弹出,请点击右上角-->设置打开授权',
          showCancel: false,
        })
      } else {
        wx.showModal({
          title: '提示',
          content: '生成海报失败',
          showCancel: false,
        })
      }
    }
  }, 
})

image.png

四. 最后

上面的绘制场景只符合比较正常的海报绘制流程,海报的宽度和高度比较小,下面说一下以前遇到一个绘制超出手机屏幕高度海报的问题:

wx.canvasToTempFilePath() 调用时如果 canvas 的宽高大于手机的屏幕,在 Android 可能会报 write file failconvert native buffer parameter fail.native buffer exceed size limit,并且还可能会出现闪退情况。 IOS 的话比 Android 的内存还大一些,但是过大也会出现保存一张白屏的 canvas 情况

出现以上的情况可以通过 canvas 按比例缩放进行绘制和导出处理,经过本人的实践,就算是缩放了保存到手机相册中缩放查看清晰度也不会模糊多少

解决方案:

  1. 使用元素构建一个与海报一模一样的样式展示给用户,海报使用 opacity: 0 隐藏(不要使用 visibility: hidden,本人测试 IOS 隐藏不了)
  2. 如果 canvas 的高度超出屏幕高度,则固定到屏幕高度,然后绘制内容时候按比例缩小
  3. 调用 wx.canvasToTempFilePath() 将隐藏的 canvas 生成一张图片,然后保存到手机
<canvas 
  id="poster-canvas" 
  type="2d"
  style="width: 100%; height: {{ canvasHeight > windowHeight ? windowHeight : canvasHeight }}px"
 ></canvas>
/**
 * 计算像素比例
 * @description 通过像素转换成适应宽度
 * @param {number} pixel 当前像素
 * @return {number} 处理后的像素大小
 */
canculateRatio(pixel) {
  const designWidth = 750
  const canvasHeight = this.data.canvasHeight
  const { windowWidth, windowHeight } = wx.getSystemInfoSync()
  let handledSize = windowWidth / designWidth * pixel

  // 如果海报高度大于手机屏幕高度,则按高度比例缩放
  if (canvasHeight > windowHeight) {
    handledSize = Number((windowHeight / canvasHeight * handledSize).toFixed(3))
  }

  return handledSize
}