微信小程序canvas生成图片、保存到本地

4,129 阅读9分钟

前提

产品提出了一个新需求:根据用户所选择的年月日的打卡详情,生成打卡海报,用户可保存到本地。

image.png

逻辑整理

接到这个需求后我大概缕出了要完成的步骤:

1、收集海报所需的数据;
2、进行canvas绘制;
3、使用wx的api将canvas生成图片拿到临时图片地址;
4、review代码,优化思路和兼容;

数据这里就不需要说了,我们直接进入主题:

进行canvas绘制

设计稿:

image.png
如图所示,canvas的绘制大概可以拆分为以下几部分:
1.获取canvas元素,生成2D画布;
2.绘制图片:a. 绘制背景图片;b. 二维码图片
3.圆角矩阵;
4.分割线;
5.文字;

为了可以拿到canvas画布的生成状态,多选择使用promise来控制整体的流程和进度

获取canvas元素,并生成2D画布

使用wx.createSelectorQuery()来获取指定的节点,拿到元素信息 例如:

  • canvas是canvas节点;

  • ctx是2D画布;

<!-- 这里我习惯用canvas去直接适配外元素的宽高--根据自己喜好来即可 -->
<view class="poster-main">
    <canvas width="100%" height="100%" type="2d" class="punch-img" canvas-id="punchImg" id="punchImg" class="canvas-box"></canvas>
</view>
wx.createSelectorQuery().in(this)
    .select('#punchImg')
    .fields({ node: true, size: true })
    .exec((res) => {
        if(!res[0].node) return
        <!-- 拿到canvas节点 -->
        canvas = res[0].node 
        <!-- 生成2D画布 -->
        ctx = canvas.getContext('2d') 
        <!-- 拿到设备的像素比--后面用来设置canvas的大小 -->
        dpr = wx.getSystemInfoSync().pixelRatio 
        <!-- 这里是canvas绘制的方法,下面会详细说 -->
        that.drawing() 
        that.setData({
            model_width: wx.getSystemInfoSync().screenWidth,
            model_height: wx.getSystemInfoSync().screenHeight
        })
})

绘制图片

绘制图片的整体逻辑如下: 对节点canvas使用createImage()生成图片,然后设置图片的src, 在图片的onload方法中对画布进行操作。

drawIMG() {
    const that = this
    return new Promise((resolve, reject) => {
        let img = canvas.createImage()
        <!-- 这里可以是本地图片,也可以是线上图片 -->
        img.src = 'XXX.png' 
        img.onload = () => {
            <!-- 
                这里放置在canvas上绘制图片的相关操作代码
             -->
            resolve('小程序二维码渲染完毕')
        }
    })
}

下面我们根据这个思路来绘制背景图片和二维码图片。

背景图片

根据设计稿可以看到背景图要占满整个canvas,所以直接使用drawImage()就可以直接渲染

// 绘制海报背景
drawPoster() {
    const that = this
    return new Promise(function(resolve, reject) {
    let poster = canvas.createImage()
    poster.src = that.data.poster_data.bg_img
    poster.onload = () => {
        that.computeCanvasSize(poster.width, poster.height).then(() => {
        ctx.drawImage(poster, 0, 0, poster.width, poster.height, -2, -1, that.data.canvas_width+3, that.data.canvas_height+1)
        resolve('海报背景生成完毕')
        })
    }
    })
}

此时我们已经生成背景图片了

image.png

小程序二维码图片

根据设计稿,我们可以看到二维码是圆角的,并且位置在右下方,那么我们就要在绘制图片的基础上加上"圆角"和"定位"。
思路应该是:

  1. 绘制矩形
  2. 进行裁剪
  3. 放置图片对其定位

1、绘制矩形 设计稿上可以看到有多个圆角矩阵的绘制,那么我们就将绘制圆角矩阵给单独抽出一个方法出来:

/**
     * @description: 
     * @param {number} x:x轴坐标
     * @param {number} y:y轴坐标
     * @param {number} width:矩阵的宽度
     * @param {number} height:矩阵的高度
     * @param {number} radius:圆角角度
     * @param {string} color:颜色
     * @return {*}
     */    
    handleBox(x:number, y:number, width: number, height: number, radius:number, color: string) {
      ctx.lineWidth = 1
      ctx.strokeStyle = color
      ctx.beginPath()
      ctx.moveTo(x, y+radius)
      ctx.lineTo(x, y + height - radius)
      ctx.quadraticCurveTo(x, y + height, x + radius, y + height);
      ctx.lineTo(x + width - radius, y + height);
      ctx.quadraticCurveTo(x + width, y + height, x + width, y + height - radius);
      ctx.lineTo(x + width, y + radius)
      ctx.quadraticCurveTo(x + width, y, x + width - radius, y)
      ctx.lineTo(x + radius, y);
      ctx.quadraticCurveTo(x, y, x, y + radius);
      ctx.stroke();
    },

圆角矩阵方法创建完毕后,我们就可以对二维码进行操作啦.
2、绘制二维码图片

// 渲染二维码
drawQr() {
  const that = this
  return new Promise((resolve, reject) => {
    let qr = canvas.createImage()
    qr.src = that.data.poster_data.qrcode // 这里是接口返回的线上二维码地址
    qr.onload = () => {
      ctx.save()
      // 创建一个二维码所需的圆角矩阵,并且将其进行定位
      that.handleBox(that.data.canvas_width-80, that.data.canvas_height-80, 66, 66, 6, 'rgba(255,255,255,0)')
      // 对画布进行裁剪
      ctx.clip()
      // 在裁剪的画布中绘制二维码图片并定位
      ctx.drawImage(qr, that.data.canvas_width-80, that.data.canvas_height-80, 66, 66)
      ctx.restore()
      resolve('小程序二维码渲染完毕')
    }
  })
}

image.png
此时我们的所需的图片都已生成完毕。

圆角矩阵

我们需要生成一个圆角矩阵并对其进行填充颜色,上面我们已经展示了需要使用的生成圆角矩阵的方法【handleBox()】了,接下来,我们对填充圆角矩阵进行操作。

/**
 * @description: 
 * @param {number} x:x轴坐标
 * @param {number} y:y轴坐标
 * @param {number} width:矩阵的宽度
 * @param {number} height:矩阵的高度
 * @param {number} radius:圆角角度
 * @param {string} color:颜色
 * @return {*}
 */    
fillRoundRect(x: number, y: number, width: number, height:number, radius:number, color: string) {
    ctx.save();
    ctx.translate(x, y);
    ctx.fillStyle = color || "#000"; //若是给定了值就用给定的值否则给予默认值  
    ctx.fill();
    ctx.restore();
}
// 这里的颜色随手写的
this.handleBox(19, this.data.canvas_height-190, this.data.canvas_width-38, 90, 8, 'rgba(255, 255, 255, 0.7)')
this.fillRoundRect(19, this.data.canvas_height-190, this.data.canvas_width-38, 90, 8, 'rgba(255, 255, 255, 0.4)')

image.png
接下来对文字和分割线进行操作。

分割线

/**
 * @description: 分割线
 * @param {string} color: 颜色
 * @param {any} starPosition:开始坐标
 * @param {any} endPosition:结束坐标
 * @return {*}
 */ 
handleLine(color: string, starPosition: any, endPosition: any) {
  ctx.save()
  ctx.beginPath()
  ctx.moveTo(starPosition.x, starPosition.y)
  ctx.lineTo(endPosition.x, endPosition.y)
  ctx.lineWidth = 1
  ctx.strokeStyle = color
  ctx.stroke()
  ctx.closePath()
  ctx.restore()
}
that.handleLine('rgba(255,255,255,0.5)', {x:78, y: 19}, {x: 78, y: 70})
// 这里的计算只是这次需求所需的计算,仅参考
that.handleLine('rgba(173,173,173,0.72)', {x:(that.data.canvas_width - 31)/2 + 16, y: that.data.canvas_height - 177 }, {x: (that.data.canvas_width - 31)/2 + 16, y: that.data.canvas_height - 127})

文字

值得注意的是整个设计稿中的文案分为2种情况:
短文案、长文案【需要换行和超出省略号】。

文字段长度的判断

对文字要占用的长度可以用【画布.measureText(文案).width】来获取。
然后通过与最长宽度的对比来判断是要使用短文案的绘制方法or长文案的绘制方法。

// 计算文案宽度
computedDaySize(day: string|number, font: string) {
  ctx.font = font 
  let w = ~~(ctx.measureText(day).width) // 这里向上取整or向上取整都可
  return w
}

短文案

短文案直接渲染即可。

/**
 * @description: 对短文字的操作
 * @param {string} font:字体信息
 * @param {string} color:颜色
 * @param {string} text:文案
 * @param {any} position:定位(x,y)
 * @return {*}
 */    
handleText(font: string, color: string, text: string, position: any) {
  ctx.save()
  ctx.font = font
  ctx.fillStyle = color
  ctx.fillText(text, position.x, position.y)
  ctx.closePath()
  ctx.restore() 
}

长文案

文案过长时需要换行和省略号处理。

/**
 * @description: 
 * @param {string} font:文字信息
 * @param {string} color:颜色
 * @param {string} text:文案
 * @param {any} position:定位信息
 * @param {number} count:行数
 * @param {string} sign:用户名/打卡信息文案
 * @return {*}
 */    
handleMaxWidthText(font: string, color: string, text: string, position: any, count: number, sign: string) {
  ctx.save()
  ctx.font = font
  ctx.fillStyle = color
  let endPos = 0, 
      // 文案的最长宽度
      maxW = this.data.canvas_width- (78 - this.computedDaySize(this.data.poster_data.yearMounth, '11px DIN-Medium') - 20 + 78 + 1) - 20, 
      // 文案宽度
      textW = this.computedDaySize(text, font),
      // 文案在最长宽度的限制下可以分的行数
      allRow = Math.ceil(textW / maxW)
      
  for(let j = 0; j < count; j++) {
    let nowStr = text.slice(endPos), rowWid = 0
    if(textW > maxW) {
      for(let m = 0; m < nowStr.length; m++) {
        rowWid += ctx.measureText(nowStr[m]).width
        ctx.fillStyle = color

        // 多行中第一行渲染--要区分姓名还是文案
        // 文案第一行正常渲染
        // 用户名第一行如果超过最长宽度则直接省略号处理
        if(rowWid > maxW && j == 0) {
          if(sign=='name') {
            ctx.fillText(nowStr.slice(0, m - 1) + '...:', position.x, position.y);
          }else {
            ctx.fillText(nowStr.slice(0, m), position.x, position.y);
          }
          endPos += m;//下次截断点
          break;
        }

        // 文案第二行需要省略号渲染情况
        if(allRow > count && rowWid > maxW && j == count-1) {
            ctx.fillText(nowStr.slice(0, m - 1) + '...', position.x, position.y + j + 16);
          endPos += m;//下次截断点
          break;
        }
        
        // 最后一行不需要省略号渲染的情况
        if(allRow <= count && rowWid <= maxW  && j == count-1) {
          ctx.fillText(nowStr.slice(0, m-3), position.x, position.y + j + 16);
          endPos += m;//下次截断点
          break;
        }
      }
    }else {
      ctx.fillText(nowStr.slice(0), position.x, position.y + (j + 1) * 18);
    }
    ctx.closePath()
    ctx.restore() 
  }
}

长文字段换行和省略号处理,效果如下:
image.png

整理代码

这里是生成完整海报canvas的整理。

/**
 * @description: 绘制海报内容:文字、分割线、圆角矩阵
 * @return {*}
 */   
drawText() {
  const that = this
  return new Promise((resolve, reject) => {
    // 日
    that.handleText("bold 40px DIN-Bold", '#fff', that.data.poster_data.day.substring(0,1), {x: 19, y: 50})
    that.handleText("bold 40px DIN-Bold", '#fff', that.data.poster_data.day, {x: 19, y: 50})
    // 年/月
    that.handleText("normal 11px DIN-Medium", '#fff', that.data.poster_data.yearMounth, {x: 19, y: 68})
    // 割线--顶部
    that.handleLine('rgba(255,255,255,0.5)', {x:78, y: 19}, {x: 78, y: 70})
    

    // 处理文字段
    let maxW = that.data.canvas_width-94-17, 
        x = 78 - that.computedDaySize(that.data.poster_data.yearMounth, '11px DIN-Medium') - 19 + 78 + 1 // 计算文案应与分割线的距离
    // 用户打卡渲染
    if(that.data.poster_data.content.length > 0) {
      // 对文案过长的兼容
      let textW = ctx.measureText(that.data.poster_data.content).width, y1 = 39
      // 渲染打卡文案
      if(maxW < textW) {
        that.handleMaxWidthText("12px PingFangSC-Regular", '#fff', that.data.poster_data.content, {x: x, y: 50}, 2, 'content')
        y1 = 30
      }else {
        that.handleText("normal 12px PingFangSC-Regular", '#fff', that.data.poster_data.content, {x: x, y: 64})
      }
      let nameW = that.computedDaySize(that.data.poster_data.user_name,'14px PingFangSC-Semibold')
      // 渲染用户名
      if(maxW < nameW) {
        that.handleMaxWidthText("14px PingFangSC-Semibold", '#fff', that.data.poster_data.user_name + '...', {x: x, y: y1}, 1, 'name')
      }else {
        that.handleText("normal 14px PingFangSC-Semibold", '#fff', that.data.poster_data.user_name + ':', {x: x, y: y1})
      }
    }else {
      that.handleText("16px PingFangSC-Regular", '#fff', that.data.poster_data.chicken_soup_zh, {x: x, y: 39})
      that.handleText("14px PingFangSC-Light", '#fff', that.data.poster_data.chicken_soup_en, {x: x, y: 64})
    }
    // 处理圆角矩阵
    this.handleBox(16, this.data.canvas_height-197, this.data.canvas_width-38, 90, 8, 'rgba(255, 255, 255, 0.7)')
    this.fillRoundRect(16, this.data.canvas_height-197, this.data.canvas_width-38, 90, 8, 'rgba(0, 0, 0, 0.4)')
    
    // 处理圆角矩阵内的文字与分割线
    that.handleLine('rgba(173,173,173,0.72)', {x:(that.data.canvas_width - 31)/2 + 16, y: that.data.canvas_height - 177 }, {x: (that.data.canvas_width - 31)/2 + 16, y: that.data.canvas_height - 127})
      // 绘制连续打卡时间文案
    that.handleText("bold 42px DIN-Bold", '#fff', that.data.poster_data.serial_day, {x: (that.data.canvas_width - 31)/4 - 10, y: that.data.canvas_height - 147})
    that.handleText("normal 12px PingFangSC-Regular", '#fff', '天', {x: (that.data.canvas_width - 31)/4 + that.computedDaySize(that.data.poster_data.serial_day, '42px DIN-Bold') - 9, y: that.data.canvas_height - 147})
    that.handleText("normal 12px PingFangSC-Regular", '#fff', '已连续打卡', {x: (that.data.canvas_width - 31)/4 - 8, y: that.data.canvas_height - 128})

    // 绘制本周打卡次数文案
    that.handleText("bold 42px DIN-Bold", '#fff', that.data.poster_data.week_day, {x: (that.data.canvas_width - 31)*3/4 - 18 , y: that.data.canvas_height - 147})
    that.handleText("normal 12px PingFangSC-Regular", '#fff', '次', {x:(that.data.canvas_width - 31)*3/4 + that.computedDaySize(that.data.poster_data.week_day, '42px DIN-Bold') - 15, y: that.data.canvas_height - 147})
    that.handleText("normal 12px PingFangSC-Regular", '#fff', '本周已打卡', {x: (that.data.canvas_width - 31)*3/4 - 18, y: that.data.canvas_height - 128})
    
    
    // 底部的基本信息
    that.handleText("normal 14px PingFangSC-Semibold", 'rgba(255,255,255,0.7)', '小程序名小程序名', {x: 14, y: that.data.canvas_height - 48})
    // that.handleText("normal 14px PingFangSC-Semibold", 'rgba(255,255,255,0.7)', '橙啦考研星球', {x: 14, y: that.data.canvas_height - 48})
    that.handleText("normal 12px PingFangSC-Regular", 'rgba(255,255,255,0.7)', '欢迎词,扫码进入小程序', {x: 14, y: that.data.canvas_height - 26})

    resolve('渲染完毕')
  })

}
/**
 * @description: 逐步绘制海报
 * @return {*}
 */    
async drawing() {
  const that = this
  canvas.width = that.data.canvas_width * dpr
  canvas.height = that.data.canvas_height * dpr
  ctx.scale(dpr, dpr)
  await that.drawPoster() // 生成海报背景
  await that.drawQr() // 绘制二维码
  await that.drawText().then(res => {
    if(res) {
        // 生成图片----下面介绍
        that.saveImg()
    }
  })
}

此时效果如图所示:

image.png
这个时候我们的海报已经绘制完毕并且已经拿到绘制完毕的结果了,这时就可以将canvas生成临时图片地址,等待用户点击保存图片啦。

canvas生成图片

微信给我们提供了直接将canvas生成图片的api,我们直接进行调用:

/**
 * @description: canvas生成图片
 *               生成图片后存入本地,一天缓存;用户如果点击过保存图片则自动保存;
 *               如果已经有过当天的环境图片路径,则直接保存
 * @return {*}
 */    
saveImg() {
  let that = this
  // posterPath 是临时图片的地址,如果已经有过地址,直接保存即可;没有再调用api生成临时地址
  if(!this.data.posterPath) {
    // canvas生成图片
    wx.canvasToTempFilePath({
      // 由于我canvas标签直接设置了2D,所以直接将节点传过去即可。
      canvas: canvas,
      width: that.data.canvas_width,
      height: that.data.canvas_height,
      // 输出的宽高设置了4倍,一定程度上解决了canvas模糊的问题
      destWidth: that.data.model_width * dpr * 4,
      destHeight: (that.data.model_width * dpr * 4) * (that.data.canvas_height / that.data.canvas_width ),
      fileType: 'png',
      success: (res) => {
        if(res) {
          that.setData({
            posterPath: res.tempFilePath,
            canvasSign: true
          })
          // 将生成的图片临时地址保存到storage中
          let pathArr = [app.globalData.userInfo.userName + that.data.poster_data.yearMounth + that.data.poster_data.day, res.tempFilePath]
          wx.setStorageSync('punchPoster_cl', pathArr)
        }
        
        // 图片未生成之前用户点击过“保存图片”按钮,则图片生成后自动下载到本地
        if(that.data.userSaved) {
            that.downImg(res.tempFilePath)
        }
      },
      fail: (err) => {
        console.log(err,'err--canvasToTempFilePath');
      }
    })
  }else {
    // 下载图片到本地
    that.downImg(that.data.posterPath)
  }
}

保存图片到本地

微信提供了api可以将临时图片给保存到本地。

/**
 * @description: 保存canvas生成的图片,将部分变量恢复初始值
 * @param {string} path
 * @return {*}
 */    
downImg(path: string) {
  let that = this
  wx.saveImageToPhotosAlbum({
    filePath: path,
    success: (res) => {
      that.setData({
        saveSign: true // 用户保存图片成功标识
      })
    },
    fail: (err) => {
      wx.showToast({
        title: '保存失败',
        icon: 'none',
        duration: 2000
      })
    },
    complete: (res) => {
      // 将部分状态标识给初始化
      that.setData({
        userSaved: false,
        haveSaved: false
      })
    }
  })
}

优化

其实上面代码中已经有部分优化了,但是我们对其总结一下:

临时图片地址保存到storage

let pathArr = [app.globalData.userInfo.userName + that.data.poster_data.yearMounth + that.data.poster_data.day, res.tempFilePath]
wx.setStorageSync('punchPoster_cl', pathArr)

在成功生成图片临时地址后,将临时地址保存到storage中,存放的结构为数组[用户名+年月+日, 临时地址]。

  • 我们在每次获取打卡信息时,获取storage中的punchPoster_cl
  • 通过对比punchPoster_cl[0]的信息来判断是否为用户选择的海报图片。
  • 如果不是:直接清除数据重新生成canvas;
  • 如果是:则直接使用已生成的临时地址。

此优化在一定程度上让用户不必频繁生成canvas,充分利用了已生成的临时图片地址,优化了用户体验。

生成假海报

将canvas定位在用户看不到的地方,写一个假海报;
解决了由于生成canvas需要一定的时间,会出现空白区的情况,让用户体验感更好。

使用promise来控制和获取进度

image.png

image.png

用promise来控制进度,保证了代码按照逻辑顺序有序进行;
同时也解决了用户在生成图片之前就点击“保存图片按钮”时的情况:图片未生成完毕时,进行toast提示告知用户图片正在生成中。 同时用变量控制用户频繁点击“保存图片按钮”,解决了用户会一次性保存多次图片的情况。

总结

以上就是我对于这次需求的完成和优化,如果有不完善或者不对的地方请大佬们多多指点~~