微信小程序生成分享海报

1,796 阅读9分钟

这是我参与8月更文挑战的第4天,活动详情查看: 8月更文挑战


背景

    今天跟看掘金酱的聊天记录时,发现去年年度创作者大赛的拉票海报是设计小哥哥小姐姐手动生成的,正好最近在做一个微信裂变的项目,仿照掘金海报写个8月更文海报demo,为下次活动解放一下设计小姐姐的双手。

如下图,这张年度创作者大赛的海报其实是程序员比较喜欢的类型了,所有需要动态填写的位置都预留了足够空白位置,绘制一张背景图,然后在对应的坐标位置绘制文字就可以。

我想来的点技术点较多的,结合目前正在开发的微信裂变需求以及8月更文活动,设计了一张8月更文活动的海报,下面来一起手操实现一下。

手操实现

1. 需求分析

拿到这个设计图后,我们首先分析一下都涉及到哪些绘制的功能点(因为后绘制的会覆盖上一个绘制,类似z-index层上的布局,所以我们先绘制层级较低的比如背景,再按照从上往下从左至右的顺序绘制):

  1. 绘制 540*900的白色背景;
  2. 绘制顶部 540*650 的头图;
  3. 绘制 我正在参加xxx这几个文字;
  4. 绘制分隔线;
  5. 绘制头像,昵称,更文天数;
  6. 绘制二维码或者小程序码;
  7. 绘制底部的长按查看提醒

2. 代码实现

1.创建canvas容器

首先是第一步,我们先创建Canvas容器,用来绘制海报,这里有个小技巧,直接把Canvas容器展示出来的话会因为像素比例、单位换算(小程序rpx转px)问题,在绘制时候需要不断计算,所以我们可以将Canvas容器使用定位布局移动到页面看不到的地方,在页面的位置放置image标签,src执行Canvas生成的图片地址。这样Canvas容器的大小就可以设置的跟设计图一样大,image标签自己去适配单位换算。

index.wxml

<view class="share-box">
  <view class="main">
    <view class="canvas-box">
      <image src="{{imgSrc}}"></image>
    </view>
    <canvas canvas-id="shareCanvas" style="width: 540px;height:900px;position: fixed;top: -10000px;" ></canvas>
    <view class="btn-box">
      <view class="btn" bindtap="download">保存图片</view>
    </view>
  </view>
</view>

index.wxss

.share-box {
  background: rgba(14, 13, 13, .8);
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 100;
}
.main {
  position: relative;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 101;
}
.canvas-box {
  width: 540rpx;
  height: 900rpx;
  position: fixed;
  top: 100rpx;
  left: 50%;
  margin-left: -270rpx;
  box-shadow: 0rpx 5rpx 10rpx 0rpx rgba(0, 12, 32, 0.17);
  z-index: 9999;
}
.canvas-box image {
  width: 100%;
  height: 100%;
}
.btn-box{
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 150rpx;
  background-color: #fff;
  padding-top: 32rpx;
}
.btn{
  width: 686rpx;
  height: 90rpx;
  margin: 0 auto;
  background: linear-gradient(180deg, #8EADE9 0%, #4965B3 100%);
  border-radius: 12rpx;
  font-size: 36rpx;
  font-weight: 500;
  color: #FFFFFF;
  line-height: 90rpx;
  text-align: center;
}

index.js

Page({
  data: {
    imgSrc: "",
  },
  onLoad() {
    this.init()
  },
  init() {
    wx.showLoading({
      title: '正在生成...',
    })
    const ctx = wx.createCanvasContext('shareCanvas')
    ctx.draw(false, () => {
      this.canvasToImage()
    })
  },
  canvasToImage() {
    wx.canvasToTempFilePath({
      canvasId: 'shareCanvas',
      success: res => {
        wx.hideLoading()
        this.setData({
          imgSrc: res.tempFilePath
        })
      }
    })
  },
  //保存
  download() {
    wx.showLoading({
      title: '正在保存'
    });
    wx.saveImageToPhotosAlbum({
      filePath: this.data.imgSrc,
      success: function () {
        wx.showToast({
          title: '保存成功'
        });
      },
      fail: function (e) {
        wx.showToast({
          title: '保存失败'
        });
      },
      complete: function () {
        wx.hideLoading()
      }
    });
  }
})

wx.canvasToTempFilePath()可以将当前Canvas容器内容生成图片,需要在draw()方法的回调里调用。这样就会得到如下图的页面:

wx.saveImageToPhotosAlbum()可以将图片保存到相册。

2. 绘制 540*900的白色背景

setFillStyle()设置填充色为 #fff fillRect()从坐标(0,0)开始绘制宽540*高900的矩形

    ctx.setFillStyle("#fff")
    ctx.fillRect(0, 0, 540, 900)
3. 绘制顶部 540*650 的头图
ctx.drawImage("../../img/bg.png", 0, 0, 540, 650)

这里存在一个问题,因为小程序2M的限制,所以图片基本都是网络资源,drawImage()绘制网络图片时必须先 getImageInfo()/downloadFile()先把网络图片下载下来,而getImageInfo()/downloadFile()是异步操作,加载图片多了会陷入回调地狱,所以我用Promise.all()封装了一下网络资源的加载。 ps:getImageInfo()/downloadFile()操作网络资源时需要在小程序后台配置downloadFile合法域名,我使用的是掘金富文本的图片路径,所以配置了掘金的域名。

  getImgInfo: src => {
    return new Promise((resolve, reject) => {
      wx.getImageInfo({
        src: src,
        success: resolve,
        fail: reject
      })
    })
  },
  init() {
    wx.showLoading({
      title: '正在生成...',
    })
    Promise.all([
      this.getImgInfo("https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dcb71be2634641849853eef58675e9c8~tplv-k3u1fbpfcp-watermark.image"),
    ]).then(res => {
      const ctx = wx.createCanvasContext('shareCanvas')
      // 绘制背景色
      ctx.setFillStyle("#fff")
      ctx.fillRect(0, 0, 540, 900)
      
      // 绘制头图
      ctx.drawImage(res[0].path, 0, 0, 540, 650)

      ctx.draw(false, () => {
        this.canvasToImage()
      })
    })
  },

这样我们的第一的大步就完成了,得到如下的图片:

4.绘制 我正在参加xxx这几个文字
      ctx.setTextBaseline('top')
      ctx.setTextAlign('left')
      ctx.setFillStyle('#333')
      ctx.setFontSize(26)
      ctx.fillText("我正在参加掘金8月更文活动", 30, 680)
      ctx.fillText("快来帮我点赞吧!", 30, 710)

setTextBaseline()设置文字在垂直方向的对齐方式,可选值为topbottommiddlenormal。4者的区别如下:(都是在同样的y轴距离绘制的) 我们在设置时可以将其值设置为top,这样的好处是设计图上文字距离顶部的距离就是要绘制的y轴距离。

setTextAlign()设置文字水平对齐方式,可选值为leftcenterright。3者的区别如下:(都是在同样的x轴距离绘制的) 我们在设置时可以将其值设置为left,这样的好处是设计图上文字距离左边的距离就是要绘制的x轴距离。不定宽度的文字想要水平居中的话可以设置对齐方式为center,然后绘制的x轴位置为总宽度的1/2。 setFontSize()设置文字字号,单位默认px。 fillText()在坐标位置(30px, 680px)处开始绘制文字。

5.绘制分割线
      ctx.moveTo(30, 750)
      ctx.lineTo(510, 750)
      ctx.setStrokeStyle('#eeeeee')
      ctx.stroke()

moveTo()在(30px,750px)位置创建路径起始点。 lineTo()在(510px,750px)新增一个点。 setStrokeStyle()设置描边的颜色。 stroke()按照点的顺序描绘出路径。

6.绘制头像,昵称,更文天数
      ctx.save()
      ctx.setFillStyle('#fff')
      ctx.beginPath()
      ctx.arc(70, 800, 40, 0, 2 * Math.PI)
      ctx.clip()
      ctx.drawImage(res[1].path, 30, 760, 80, 80)
      ctx.restore()

      ctx.font = 'normal bold 26px sans-serif';
      ctx.fillText("于五五", 130, 770)
      ctx.font = 'normal normal 20px sans-serif';
      ctx.setFillStyle('#616165')
      ctx.fillText("已更文", 130, 810)
      let wordWidth = ctx.measureText("已更文").width
      ctx.setFillStyle('#333')
      ctx.fillText("4", 130 + wordWidth + 5, 810)
      let numWidth = ctx.measureText("4").width
      ctx.setFillStyle('#616165')
      ctx.fillText("天", 130 + wordWidth + 5 + numWidth + 5, 810)

因为要绘制出一个圆形的头像,Canvas不支持直接操作图片,所以只能使用其他方法:先绘制圆形的Canvas区域,在这个区域内绘制图片。先使用save()方法保存上下文,然后beginPath()开始创建一个新的路径,arc()方法在圆心坐标为(70px,800px)位置绘制半径为40px的圆,再然后clip()裁剪,只保留圆形区域。这时候在Promise.all([])里添加头像的网络地址,drawImage绘制图片,因为圆的坐标是以圆心开始的,图片绘制是以左上角作为起始点的,所以图片绘制的起点坐标应该为(圆心x距离-半径,圆心y距离-半径),宽高为圆的直径 ,绘制完成后restore()方法恢复刚才保存的上下文,以便我们可以继续绘制。 再看昵称这几个字是加粗的,目前只找到了对font设置实现加粗的方案,其他有点bug。 已更文xx天这里,因为xx是个变量,宽度不固定,所以调用了measureText()方法,可以获取到将要绘制内容的宽度,因此 "天"字绘制的x距离="已更文"的x坐标+"已更文"的宽度+"xx"的左右字间距+"xx"的宽度。

7.绘制二维码和底部提醒

Promise.all()里新增两张网络图片

      this.getImgInfo("https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8d94a69ca49249278f95c2391cb5dc7d~tplv-k3u1fbpfcp-watermark.image"),//二维码或小程序码
      this.getImgInfo("https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/45e02d3d598e4fe8ab83f30e25fbc08b~tplv-k3u1fbpfcp-watermark.image")//icon
      ctx.drawImage(res[2].path, 410, 750, 100, 100)
      ctx.drawImage(res[3].path,30, 860, 20, 20)
      ctx.setFillStyle('#AAABAD')
      ctx.fillText("长按查看", 58, 860)
8.运行结果

至此,小程序内绘制海报就结束了。可以用自己的个人资料生成自己微信裂变图片。

总结

1.使用image代替Canvas显示到页面上,可以避免Canvas单位换算问题;
2.canvasToTempFilePath()必须放到draw()的回调里面;
3.drawImage()绘制网络图片时需要使用getImageInfo()/downloadFile()先把网络图片下载下来;
4.getImageInfo()/downloadFile()下载网络图片时需要配置安全域名,开发环境可以先使用微信开发者工具勾选不校验合法域名;
5.使用Promise.all()解决回调地狱;
6.设置Canvas对齐方式为setTextBaseline('top')、setTextAlign('left'),这样要绘制的元素(x,y)距离坐标就是设计图上元素的位置;
7.绘制文字水平居中可以设置setTextAlign('center'),fillText()的x坐标位置为总宽度的一半;
8.绘制圆形头像可以先绘制圆形区域,在圆形区域上绘制图片;
9.setFillStyle()设置文字颜色、setFontSize()设置文字大小、font = 'normal bold 26px sans-serif'实现文字加粗;
10.measureText()可以获取文字内容所占的宽度;

最后,附上完整js代码:

Page({
  data: {
    imgSrc: "",
  },
  onLoad() {
    this.init()
  },
  getImgInfo: src => {
    return new Promise((resolve, reject) => {
      wx.getImageInfo({
        src: src,
        success: resolve,
        fail: reject
      })
    })
  },
  init() {
    wx.showLoading({
      title: '正在生成...',
    })
    Promise.all([
      this.getImgInfo("https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dcb71be2634641849853eef58675e9c8~tplv-k3u1fbpfcp-watermark.image"), //头图
      this.getImgInfo("https://sf6-ttcdn-tos.pstatp.com/img/user-avatar/eccbd6c74379889aee23eff8569c815c~300x300.image"), //头像
      this.getImgInfo("https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8d94a69ca49249278f95c2391cb5dc7d~tplv-k3u1fbpfcp-watermark.image"), //二维码或小程序码
      this.getImgInfo("https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/45e02d3d598e4fe8ab83f30e25fbc08b~tplv-k3u1fbpfcp-watermark.image") //icon
    ]).then(res => {
      const ctx = wx.createCanvasContext('shareCanvas')
      // 绘制背景色
      ctx.setFillStyle("#fff")
      ctx.fillRect(0, 0, 540, 900)

      // 绘制头图
      ctx.drawImage(res[0].path, 0, 0, 540, 650)

      // 绘制文字
      ctx.setTextBaseline('top')
      ctx.setTextAlign('left')
      ctx.setFillStyle('#333')
      ctx.setFontSize(26)
      ctx.fillText("我正在参加掘金8月更文活动", 30, 680)
      ctx.fillText("快来帮我点赞吧!", 30, 710)

      // 绘制分割线
      ctx.moveTo(30, 750)
      ctx.lineTo(510, 750)
      ctx.strokeStyle = '#eeeeee'
      ctx.stroke()

      // 绘制头像、个人信息
      ctx.save()
      ctx.setFillStyle('#fff')
      ctx.beginPath()
      ctx.arc(70, 800, 40, 0, 2 * Math.PI)
      ctx.clip()
      ctx.drawImage(res[1].path, 30, 760, 80, 80)
      ctx.restore()

      ctx.font = 'normal bold 26px sans-serif';
      ctx.fillText("于五五", 130, 770)
      ctx.font = 'normal normal 20px sans-serif';
      ctx.setFillStyle('#616165')
      ctx.fillText("已更文", 130, 810)
      let wordWidth = ctx.measureText("已更文").width
      ctx.setFillStyle('#333')
      ctx.fillText("4", 130 + wordWidth + 5, 810)
      let numWidth = ctx.measureText("4").width
      ctx.setFillStyle('#616165')
      ctx.fillText("天", 130 + wordWidth + 5 + numWidth + 5, 810)

      // 绘制二维码
      ctx.drawImage(res[2].path, 410, 750, 100, 100)
      ctx.drawImage(res[3].path, 30, 860, 20, 20)
      ctx.setFillStyle('#AAABAD')
      ctx.fillText("长按查看", 58, 860)

      ctx.draw(false, () => {
        this.canvasToImage()
      })
    })
  },
  canvasToImage() {
    wx.canvasToTempFilePath({
      canvasId: 'shareCanvas',
      success: res => {
        wx.hideLoading()
        this.setData({
          imgSrc: res.tempFilePath
        })
      }
    })
  },
  download() {
    wx.showLoading({
      title: '正在保存'
    });
    wx.saveImageToPhotosAlbum({
      filePath: this.data.imgSrc,
      success: function () {
        wx.showToast({
          title: '保存成功'
        });
      },
      fail: function (e) {
        wx.showToast({
          title: '保存失败'
        });
      },
      complete: function () {
        wx.hideLoading()
      }
    })
  }
})