前提
产品提出了一个新需求:根据用户所选择的年月日的打卡详情,生成打卡海报,用户可保存到本地。
逻辑整理
接到这个需求后我大概缕出了要完成的步骤:
1、收集海报所需的数据;
2、进行canvas绘制;
3、使用wx的api将canvas生成图片拿到临时图片地址;
4、review代码,优化思路和兼容;
数据这里就不需要说了,我们直接进入主题:
进行canvas绘制
设计稿:
如图所示,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('海报背景生成完毕')
})
}
})
}
此时我们已经生成背景图片了
小程序二维码图片
根据设计稿,我们可以看到二维码是圆角的,并且位置在右下方,那么我们就要在绘制图片的基础上加上"圆角"和"定位"。
思路应该是:
- 绘制矩形
- 进行裁剪
- 放置图片对其定位
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('小程序二维码渲染完毕')
}
})
}
此时我们的所需的图片都已生成完毕。
圆角矩阵
我们需要生成一个圆角矩阵并对其进行填充颜色,上面我们已经展示了需要使用的生成圆角矩阵的方法【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)')
接下来对文字和分割线进行操作。
分割线
/**
* @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()
}
}
长文字段换行和省略号处理,效果如下:
整理代码
这里是生成完整海报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()
}
})
}
此时效果如图所示:
这个时候我们的海报已经绘制完毕并且已经拿到绘制完毕的结果了,这时就可以将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来控制和获取进度
用promise来控制进度,保证了代码按照逻辑顺序有序进行;
同时也解决了用户在生成图片之前就点击“保存图片按钮”时的情况:图片未生成完毕时,进行toast提示告知用户图片正在生成中。 同时用变量控制用户频繁点击“保存图片按钮”,解决了用户会一次性保存多次图片的情况。
总结
以上就是我对于这次需求的完成和优化,如果有不完善或者不对的地方请大佬们多多指点~~