上图中图片底部白色区域为canvas主要画图区, 其他为背景图片. 要求能导出图片, 便于用户分享给朋友或者分享朋友圈.
新版 Canvas 2D 接口与 Web 一致, 但是微信小程序的文档又不完善, 导致会多一些调试时间, 还需要自己查一些api的调用方式.
- 新版使用了wx.createSelectorQuery()的方式, 组件内调用要用this.createSelectorQuery()替代(重点)
- 新版不再用canvas-id了,而是直接使用canvas标签上的id
- 新版要在canvas标签上声明type="2d"
- 新版其他内容可以看文档
// 通过 SelectorQuery 获取 Canvas 节点
wx.createSelectorQuery()
.select(`#${id}`)
.fields({
node: true,
size: true,
})
.exec((res) => {
if (res[0]) {
// 业务代码
}else {
// 业务代码
}
});
重点是怎么才能把设计图完全的画到canvas中,并在不同设备上显示正常
-
- canvas中,画出来的任何图形都是物理宽度, 比如以iphone6为例, 屏幕物理宽度是375px, 但是以rpx为单位的宽度是750rpx; 可见dpr为2, 比较好计算.
-
- 单位换算是将设计图上的图形宽度根据屏幕像素比(dpr)换算为物理宽度,最终画出来,才能保证各机型完整的还原设计图(换算代码见下文代码).
-
- canvas内的坐标都是计算机内通行的坐标系,即左上角为原点,所以画任何图形取的坐标都应该是相对于canvas的左上角的.
-
- 在750宽度的设计图中,如果出现canvas的设计宽度不满750, 比如650的宽度, 那么思路就是设置canvas的宽度为650rpx, canvas内的图形的宽高等仍然以750为基准度量宽度. 比如, 650的canvas内画一个20x30的矩形, 此时只需要根据2中的单位换算, 将20和30换算为物理宽度, 并画到canvas上即可.
-
- 加载本地图片和加载网络图片
loadImg(canvas, imgUrl) {
return new Promise((resolve, reject) => {
const img = canvas.createImage();
img.src = imgUrl;
img.onload = () => {
resolve(img);
}
img.onerror = () => {
wx.showToast({
title: '加载海报图片失败, 请稍后重试~',
icon: 'none'
})
reject(null);
}
});
},
-
- 加载base64图片 思路是将base64的数据,转换为ArrayBuffer后,写入到本地微信路径, 然后得到一个filepath, 最后调用1中的loadImg方法即可.
loadImgBase64(data) {
return new Promise((resolve, reject) => {
const fsm = wx.getFileSystemManager();
const FILE_BASE_NAME = 'tmp_base64src';
const filePath = `${wx.env.USER_DATA_PATH}/${FILE_BASE_NAME}`;
//base64 数据转换为 ArrayBuffer 数据
const buffer = wx.base64ToArrayBuffer(data);
fsm.writeFile({
filePath: filePath,
data: buffer,
encoding: 'binary',
success: () => {
resolve(filePath);
},
fail: err => {
console.log('loadImgBase64失败', err);
reject(null);
},
});
});
},
-
- 保存图片到本地 首先调用canvasToTempFilePath来生成一个临时的图片路径, 然后调用saveImageToPhotosAlbum保存到相册.
createHaibaoUrl(canvas) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
x: 0,
y: 0,
width: canvas.width/dpr,
height: canvas.height/dpr,
destWidth: canvas.width,
destHeight: canvas.height,
canvas: canvas,
fileType: 'png',
success(res) {
// 得到的临时图片路径
resolve(res.tempFilePath);
},
fail(error) {
reject(error);
}
})
});
},
// 保存图片
_saveImg(tempFilePath, cb) {
wx.saveImageToPhotosAlbum({
filePath: tempFilePath,
success(res) {
cb('success');
},
fail(e) {
cb('fail');
}
})
},
思路是, 先算出实际运行机器的屏幕宽度和设计图750的宽度的缩放比率(如果设计图是别的宽度,此处就需要更改). 然后再得出要转换的单位的物理长度, 最后相乘得出实际画到canvas的长度.
/**
* 获取屏幕和dpr后计算的数值
* */
computedWAndD(number) {
// 屏幕缩放比率
const zoomRate = windowWidth*dpr/750;
// 物理长度
const physicalLength = number/dpr;
return zoomRate*physicalLength;
},
微信并未提供相关的api.因此思路是, 一个带圆角的矩形, 只能一点点的拼接, 用直线和圆角完成.直线用lineTo方法, 圆角用arcTo方法.
另外,下面方法调用完成后, 如需圆角填充,则需要调用ctx.fill(); 如需圆角边不填充, 则需要调用ctx.stroke();
/**
* 画圆角矩形、圆角边框和圆角图片所用到的方法
* @param params
* @param ctx
*/
toDrawRadiusRect(params, ctx) {
const {
left,
top,
width,
height,
borderRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius
} = params
ctx.beginPath()
if (borderRadius) {
// 全部有弧度
const br = borderRadius / 2
ctx.moveTo(left + br, top) // 移动到左上角的点
ctx.lineTo(left + width - br, top) // 画上边的线
ctx.arcTo(left + width, top, left + width, top + br, br) // 画右上角的弧
ctx.lineTo(left + width, top + height - br) // 画右边的线
ctx.arcTo(left + width, top + height, left + width - br, top + height, br) // 画右下角的弧
ctx.lineTo(left + br, top + height) // 画下边的线
ctx.arcTo(left, top + height, left, top + height - br, br) // 画左下角的弧
ctx.lineTo(left, top + br) // 画左边的线
ctx.arcTo(left, top, left + br, top, br) // 画左上角的弧
} else {
const topLeftBr = borderTopLeftRadius ? borderTopLeftRadius / 2 : 0
const topRightBr = borderTopRightRadius ? borderTopRightRadius / 2 : 0
const bottomRightBr = borderBottomRightRadius ? borderBottomRightRadius / 2 : 0
const bottomLeftBr = borderBottomLeftRadius ? borderBottomLeftRadius / 2 : 0
ctx.moveTo(left + topLeftBr, top)
ctx.lineTo(left + width - topRightBr, top)
if (topRightBr) { // 画右上角的弧度
ctx.arcTo(left + width, top, left + width, top + topRightBr, topRightBr)
}
ctx.lineTo(left + width, top + height - bottomRightBr) // 画右边的线
if (bottomRightBr) { // 画右下角的弧度
ctx.arcTo(left + width, top + height,
left + width - bottomRightBr, top + height, bottomRightBr)
}
ctx.lineTo(left + bottomLeftBr, top + height)
if (bottomLeftBr) {
ctx.arcTo(left, top + height, left, top + height - bottomLeftBr, bottomLeftBr)
}
ctx.lineTo(left, top + topLeftBr)
if (topLeftBr) {
ctx.arcTo(left, top, left + topLeftBr, top, topLeftBr)
}
}
},
难点在于头像方形头像图片怎么才能切成圆形的. 思路是: 调用CanvasContext.clip()方法, 微信文档解释为,从原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。可以在使用 clip 方法前通过使用 save 方法对当前画布区域进行保存,并在以后的任意时间通过restore方法对其进行恢复。
// 画头像
ctx.save();
ctx.beginPath();
// 画出圆形, 同理可以画出方形等其他图形
ctx.arc(haibaoUtil.computedWAndD(110), haibaoUtil.computedWAndD(1145), haibaoUtil.computedWAndD(50), 0, 2*Math.PI);
ctx.clip();
ctx.drawImage(imgAvatar, haibaoUtil.computedWAndD(60), haibaoUtil.computedWAndD(1095), haibaoUtil.computedWAndD(100), haibaoUtil.computedWAndD(100));
ctx.restore();
微信没有相关的api. 思路是算出文字的长度, 到达执行的行数, 指定的宽度后显示缩略符.
/**
* 画多行文本
* 思路: 利用measureText计算文本最终渲染时的长度, 计算文本何时换行
* @param {文本} str
* @param {文本行高} lineHeight
* @param {共画多少行文本} rows
* @param {是否需要展示折叠符合(3个点)} needFold
* @param {文本每行长度} maxWidth
* @param {文本x坐标} x
* @param {文本y坐标} y
* return 实际画了多少行
*/
drawTextWrapper(ctx, str, lineHeight, rows, needFold, maxWidth, x, y) {
let strArray = str.split('');
let renderStrArray = [];
let tempStr = '';
const maxWidth1 = this.computedWAndD(maxWidth);
for (let index = 0; index < strArray.length; index++) {
const item = strArray[index];
tempStr = tempStr + item;
const itemLength = ctx.measureText(tempStr).width;
if (itemLength >= maxWidth1) {
renderStrArray.push(tempStr);
tempStr = '';
} else if ((index + 1) === strArray.length){
renderStrArray.push(tempStr);
}
}
// 并未达到一行的长度
if (renderStrArray.length === 0) {
renderStrArray.push(tempStr);
}
const flag = Math.min(renderStrArray.length, rows);
for (let index = 0; index < flag; index++) {
let item = renderStrArray[index];
// 最后一行
if ((index + 1) === rows && needFold && ctx.measureText(item).width >= maxWidth1) {
// 减掉3个点的长度
item = item.substr(0, item.length - 1);
item = item + '...';
}
ctx.fillText(item, this.computedWAndD(x), this.computedWAndD(y + lineHeight*index), maxWidth1);
}
return flag;
}
}
下面是一个完整的封装好的js工具文件
/**
* 海报util
*/
let dpr = 0;
let windowWidth = 0;
function getSystemInfo() {
const systemInfo = wx.getSystemInfoSync();
dpr = systemInfo.pixelRatio;
windowWidth = systemInfo.windowWidth;
}
getSystemInfo();
module.exports = {
dpr: dpr,
windowWidth: windowWidth,
/**
* 获取canvas实例和上下文
* @param {canvas的id} canvasId
*/
createHaibao(canvasId) {
return new Promise((resolve, reject) => {
// 通过 SelectorQuery 获取 Canvas 节点
wx.createSelectorQuery()
.select(`#${canvasId}`)
.fields({
node: true,
size: true,
})
.exec((res) => {
if (res[0]) {
const width = res[0].width;
const height = res[0].height;
const canvas = res[0].node;
this.canvas = canvas;
const ctx = canvas.getContext('2d');
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
resolve({canvas, ctx});
}else {
// 生成海报失败
wx.showToast({
title: '生成海报失败, 请稍后重试~',
icon: 'none'
});
reject({});
}
});
});
},
/**
* 生成canvas后,获取canvas生成的图片的临时路径
* @param {canvas实例} canvas
*/
createHaibaoUrl(canvas) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
x: 0,
y: 0,
width: canvas.width/dpr,
height: canvas.height/dpr,
destWidth: canvas.width,
destHeight: canvas.height,
canvas: canvas,
fileType: 'png',
success(res) {
resolve(res.tempFilePath);
},
fail(error) {
reject(error);
}
})
});
},
/**
* 保存canvas到本地图片
*/
saveHaibao(tempPath) {
const _this = this;
return new Promise((resolve, reject) => {
wx.getSetting({
success: (res) => {
let authSetting = res.authSetting
if (authSetting['scope.writePhotosAlbum']) {
// 已授权
_this._saveImg(tempPath, (type) => {
if (type === 'success') {
resolve(type);
} else {
reject(type);
}
});
} else if (!res.authSetting['scope.writePhotosAlbum']) {
wx.hideLoading();
wx.authorize({
scope: 'scope.writePhotosAlbum',
success() {
_this._saveImg(tempPath, (type) => {
if (type === 'success') {
resolve(type);
} else {
reject(type);
}
});
},
fail(e) {
wx.hideLoading();
wx.showModal({
title: '您未开启保存到相册的权限,是否去开启?',
success: res => {
console.log(res)
if (res.confirm) {
wx.openSetting()
}
}
})
}
})
}
},
fail(e) {
console.log(e)
}
});
});
},
// 保存图片
_saveImg(tempFilePath, cb) {
wx.saveImageToPhotosAlbum({
filePath: tempFilePath,
success(res) {
cb('success');
},
fail(e) {
cb('fail');
}
})
},
/**
* 画圆角矩形、圆角边框和圆角图片所用到的方法
* @param params
* @param ctx
*/
toDrawRadiusRect(params, ctx) {
const {
left,
top,
width,
height,
borderRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius
} = params
ctx.beginPath()
if (borderRadius) {
// 全部有弧度
const br = borderRadius / 2
ctx.moveTo(left + br, top) // 移动到左上角的点
ctx.lineTo(left + width - br, top) // 画上边的线
ctx.arcTo(left + width, top, left + width, top + br, br) // 画右上角的弧
ctx.lineTo(left + width, top + height - br) // 画右边的线
ctx.arcTo(left + width, top + height, left + width - br, top + height, br) // 画右下角的弧
ctx.lineTo(left + br, top + height) // 画下边的线
ctx.arcTo(left, top + height, left, top + height - br, br) // 画左下角的弧
ctx.lineTo(left, top + br) // 画左边的线
ctx.arcTo(left, top, left + br, top, br) // 画左上角的弧
} else {
const topLeftBr = borderTopLeftRadius ? borderTopLeftRadius / 2 : 0
const topRightBr = borderTopRightRadius ? borderTopRightRadius / 2 : 0
const bottomRightBr = borderBottomRightRadius ? borderBottomRightRadius / 2 : 0
const bottomLeftBr = borderBottomLeftRadius ? borderBottomLeftRadius / 2 : 0
ctx.moveTo(left + topLeftBr, top)
ctx.lineTo(left + width - topRightBr, top)
if (topRightBr) { // 画右上角的弧度
ctx.arcTo(left + width, top, left + width, top + topRightBr, topRightBr)
}
ctx.lineTo(left + width, top + height - bottomRightBr) // 画右边的线
if (bottomRightBr) { // 画右下角的弧度
ctx.arcTo(left + width, top + height,
left + width - bottomRightBr, top + height, bottomRightBr)
}
ctx.lineTo(left + bottomLeftBr, top + height)
if (bottomLeftBr) {
ctx.arcTo(left, top + height, left, top + height - bottomLeftBr, bottomLeftBr)
}
ctx.lineTo(left, top + topLeftBr)
if (topLeftBr) {
ctx.arcTo(left, top, left + topLeftBr, top, topLeftBr)
}
}
},
/**
* 获取屏幕和dpr后计算的数值
* */
computedWAndD(number) {
// 屏幕缩放比率
const zoomRate = windowWidth*dpr/750;
// 物理长度
const physicalLength = number/dpr;
return zoomRate*physicalLength;
},
loadImg(canvas, imgUrl) {
return new Promise((resolve, reject) => {
const img = canvas.createImage();
img.src = imgUrl;
img.onload = () => {
resolve(img);
}
img.onerror = () => {
wx.showToast({
title: '加载海报图片失败, 请稍后重试~',
icon: 'none'
})
reject(null);
}
});
},
// load base64 img
loadImgBase64(data) {
return new Promise((resolve, reject) => {
const fsm = wx.getFileSystemManager();
const FILE_BASE_NAME = 'tmp_base64src';
const filePath = `${wx.env.USER_DATA_PATH}/${FILE_BASE_NAME}`;
//base64 数据转换为 ArrayBuffer 数据
const buffer = wx.base64ToArrayBuffer(data);
fsm.writeFile({
filePath: filePath,
data: buffer,
encoding: 'binary',
success: () => {
resolve(filePath);
},
fail: err => {
console.log('loadImgBase64失败', err);
reject(null);
},
});
});
},
/**
* 画多行文本
* 思路: 利用measureText计算文本最终渲染时的长度, 计算文本何时换行
* @param {文本} str
* @param {文本行高} lineHeight
* @param {共画多少行文本} rows
* @param {是否需要展示折叠符合(3个点)} needFold
* @param {文本每行长度} maxWidth
* @param {文本x坐标} x
* @param {文本y坐标} y
* return 实际画了多少行
*/
drawTextWrapper(ctx, str, lineHeight, rows, needFold, maxWidth, x, y) {
let strArray = str.split('');
let renderStrArray = [];
let tempStr = '';
const maxWidth1 = this.computedWAndD(maxWidth);
for (let index = 0; index < strArray.length; index++) {
const item = strArray[index];
tempStr = tempStr + item;
const itemLength = ctx.measureText(tempStr).width;
if (itemLength >= maxWidth1) {
renderStrArray.push(tempStr);
tempStr = '';
} else if ((index + 1) === strArray.length){
renderStrArray.push(tempStr);
}
}
// 并未达到一行的长度
if (renderStrArray.length === 0) {
renderStrArray.push(tempStr);
}
const flag = Math.min(renderStrArray.length, rows);
for (let index = 0; index < flag; index++) {
let item = renderStrArray[index];
// 最后一行
if ((index + 1) === rows && needFold && ctx.measureText(item).width >= maxWidth1) {
// 减掉3个点的长度
item = item.substr(0, item.length - 1);
item = item + '...';
}
ctx.fillText(item, this.computedWAndD(x), this.computedWAndD(y + lineHeight*index), maxWidth1);
}
return flag;
}
}
详见以上代码