有个海报绘制的需求是这样的:后台返回海报背景图、用户头像、二维码以及用户昵称,前端根据设计图将这些信息组装,并支持保存为图片。
- 我的方案是用canvas绘制,然后将canvas保存为图片
最初的需求是在微信小程序用,后来为了同时在app和小程序用,改为了H5版本。在这个转换的过程中,遇到并解决了很多问题,记录如下:
- 微信小程序直接绘制base64图片真机不显示,需要将base64图片转为本地图片,绘制的时候使用本地临时路径
- 微信小程序使用网络图片,必须是https协议,且需要先将图片下载至本地,绘制的时候使用本地临时路径
- 微信小程序和H5提供的绘图api以及属性不完全相同,具体看官方文档
- 微信小程序和H5获取dom元素宽高的方式不同
- 微信小程序和H5根据设计图尺寸计算绘制尺寸的方式不同(这点很重要)
- 绘图时,要注意先后顺序,比如先绘制背景图,后绘制其它内容。否则会出现部分内容不显示的情况,其实是先绘制的被后绘制的遮挡住了。这种情况在绘制图片的时候最容易出现,所以最好将依赖与图片的绘制放在图片的onload回调中
- 如果使用网络图片,H5使用toDataURL的时候可能会出现跨域的情况,这时候在前端需要设置图片的crossOrigin = 'anonymous'(参照H5版本代码),同时检查图片所在服务器是否允许跨域。如果都设置了,还是报跨域,那么试试换一张图片,或者清理缓存(我在这载了大跟头,同一张图片,第一次访问的时候跨域,然后设置了允许跨域,后面访问的时候还是会跨域,因为用的是缓存)。
- 微信小程序绘制的时候使用的都是本地临时路径,不存在跨域的问题
微信小程序版本(uniapp):
export class HB {
info = {
canvasId: 'recommendCanvas',
bgUrl: '', // 背景图
codeUrl: '', // 固定二维码
avatarUrl: '', // 用户头像
userName: '--', // 用户名称
posterWrap: null
}
/**
* 下载文件
* @param type 文件类型,用于从info变量获取文件路径
* @param self 自定义组件的this,画海报需要调用this.createSelectorQuery()
*/
createPoster(options, self) {
if (options.avatarUrl) {
options.avatarUrl = options.avatarUrl.replace('http://', 'https://')
}
this.info = Object.assign(this.info, options)
let promise1 = this.base64ToFile(this.info.codeUrl)
// 下载背景图
let promise2 = this.downloadFile('bg', '海报图片')
// 下载用户头像
let promise3 = this.downloadFile('avatar', '用户头像')
Promise.all([promise1, promise2, promise3])
.then(res => {
this.paintPosteCanvas(res[0], res[1], res[2])
})
.catch(err => {
console.log(err)
})
}
/**
* 下载文件
* @param type 文件类型,用于从info变量获取文件路径
* @param tips 描述文字,文件下载失败的提示文字
* @return promise,临时文件路径
*/
downloadFile(type, tips) {
uni.showLoading({
title: '生成中...',
mask: true
})
return new Promise((resolve, reject) => {
uni.downloadFile({
url: this.info[type + 'Url'], // 图片路径
success: res => {
wx.hideLoading()
if (res.statusCode === 200) {
resolve(res.tempFilePath)
} else {
resolve('')
uni.showToast({
title: tips + '下载失败!',
icon: 'none',
duration: 2000
})
}
},
complete: res => {
resolve('')
}
})
})
}
// 绘制海报
paintPosteCanvas(codeSrc, bgSrc, avatarSrc) {
let that = this
uni.showLoading({
title: '生成中...',
mask: true
})
const ctx = wx.createCanvasContext(this.info.canvasId) // 创建画布
wx.createSelectorQuery()
.select('#recommend-poster-container')
.boundingClientRect(function(rect) {
var height = 460 / rect.height
var width = 300 / rect.width
// 背景图片
if (bgSrc) {
ctx.drawImage(bgSrc, 0, 0, rect.width, 386 / height)
}
// 底部背景色
ctx.setFillStyle('#fff')
ctx.fillRect(0, 386 / height, rect.width, 74 / height)
// 用户名
ctx.setFontSize(14)
ctx.setFillStyle('#333')
ctx.fillText(that.info.userName, 49 / width, 417 / height)
// 宣传语
ctx.setFontSize(12)
ctx.setFillStyle('#999')
ctx.fillText('邀请您领取通关好礼', 16 / width, 445 / height)
// 扫码领取
ctx.setFontSize(10)
ctx.setFillStyle('#999')
ctx.fillText('扫一扫领取福利', 208 / width, 446 / height)
// 绘制二维码
if (codeSrc) {
ctx.save()
ctx.drawImage(codeSrc, 208 / width, 362 / height, 70 / width, 70 / width)
ctx.restore()
}
// 绘制头像
if (avatarSrc) {
const r = 28 / width / 2
ctx.save()
ctx.beginPath() // 开始绘制
ctx.strokeStyle = '#fff'
// 先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针
ctx.arc(15 / width + r, 399 / height + r, r, 0, Math.PI * 2, false)
ctx.stroke()
ctx.clip() // 画好了圆 剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
ctx.drawImage(avatarSrc, 15 / width, 399 / height, 28 / width, 28 / width) // 推进去图片,必须是https图片
ctx.restore() // 恢复之前保存的绘图上下文 恢复之前保存的绘图上下午即状态 还可以继续绘制
}
})
.exec()
setTimeout(function() {
ctx.draw()
uni.hideLoading()
}, 1000)
}
// 点击保存到相册
savePoster() {
return new Promise((resolve, reject) => {
this.canvasToTempFilePath().then(tempFilePath => {
wx.saveImageToPhotosAlbum({
filePath: tempFilePath,
success(res) {
wx.showToast({
title: '保存到相册成功',
icon: 'success',
duration: 2000
})
setTimeout(function() {
resolve()
}, 2000)
},
fail: function(res) {
wx.showToast({
title: res.errMsg,
icon: 'none',
duration: 2000
})
reject()
}
})
})
})
}
// 把当前画布指定区域的内容导出生成指定大小的图片。在 draw() 回调里调用该方法才能保证图片导出成功。
canvasToTempFilePath() {
return new Promise((resolve, reject) => {
// let that = this
uni.showLoading({
title: '正在保存',
mask: true
})
wx.canvasToTempFilePath({
canvasId: this.info.canvasId,
success: function(res) {
uni.hideLoading()
let tempFilePath = res.tempFilePath
wx.getSetting({
success(res) {
if (!res.authSetting['scope.writePhotosAlbum']) {
wx.authorize({
scope: 'scope.writePhotosAlbum',
success() {
resolve(tempFilePath)
}
})
} else {
resolve(tempFilePath)
}
}
})
}
})
})
}
// base64图片转本地图片,直接绘制base64真机不显示
async base64ToFile(data) {
await this.removeTempFile()
let reg = new RegExp('^data:image/png;base64,', 'g')
let base64Data = data.replace(reg, '')
let filePath = wx.env.USER_DATA_PATH + '/qrcode.png'
return new Promise((resolve, reject) => {
wx.getFileSystemManager().writeFile({
filePath,
data: base64Data,
encoding: 'base64',
success: (res) => {
wx.getImageInfo({
src: filePath,
success(result) {
resolve(result.path)
},
fail(err) {
console.log('读取图片错误', err)
reject(err)
}
})
},
fail(err) {
reject(err)
}
})
})
}
removeTempFile() {
return new Promise(resolve => {
let fsm = wx.getFileSystemManager()
fsm.readdir({
dirPath: wx.env.USER_DATA_PATH,
success(res) {
res.files.forEach(el => {
if (el !== 'miniprogramLog') {
fsm.unlink({
filePath: `${wx.env.USER_DATA_PATH}/${el}`,
fail(err) {
console.log('readdir删除失败', err)
}
})
}
})
resolve()
}
})
})
}
}
H5版本:
import { Toast } from 'vant'
const getPixelRatio = function (context) {
const backingStore = context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio || 1
return (window.devicePixelRatio || 1) / backingStore
}
export class HB {
info = {
canvasId: '',
bgUrl: '', // 背景图
codeUrl: '', // 固定二维码
avatarUrl: '', // 用户头像
userName: '--', // 用户名称
posterWrap: null
}
/**
* 下载文件
* @param type 文件类型,用于从info变量获取文件路径
*/
createPoster (options, self) {
if (options.avatarUrl) {
options.avatarUrl = options.avatarUrl.replace('http://', 'https://')
}
this.info = Object.assign(this.info, options)
this.paintPosteCanvas(this.info.codeUrl, this.info.bgUrl, this.info.avatarUrl)
}
// 绘制海报
paintPosteCanvas (codeSrc, bgSrc, avatarSrc) {
const that = this
Toast.loading({
message: '生成中...',
forbidClick: true
})
const canvasEle = document.querySelector('#' + this.info.canvasId)
const ratio = getPixelRatio(canvasEle)
canvasEle.width = ratio * canvasEle.width
canvasEle.height = ratio * canvasEle.height
const ctx = canvasEle.getContext('2d') // 创建画布
if (ctx) {
// 背景图片
if (bgSrc) {
const bgImg = new Image()
bgImg.crossOrigin = 'anonymous'
bgImg.onload = function () {
ctx.drawImage(bgImg, 0, 0, 300 * ratio, 386 * ratio)
// // 底部背景色
ctx.fillStyle = '#fff'
ctx.fillRect(0, 386 * ratio, 300 * ratio, 74 * ratio)
// // 用户名
ctx.font = 14 * ratio + 'px Arial'
ctx.fillStyle = '#333'
ctx.fillText(that.info.userName, 49 * ratio, 417 * ratio)
// // 宣传语
ctx.font = 12 * ratio + 'px Arial'
ctx.fillStyle = '#999'
ctx.fillText('邀请您领取通关好礼', 16 * ratio, 445 * ratio)
// // 扫码领取
ctx.font = 10 * ratio + 'px Arial'
ctx.fillStyle = '#999'
ctx.fillText('扫一扫领取福利', 208 * ratio, 446 * ratio)
// // 绘制二维码
if (codeSrc) {
ctx.save()
const codeImg = new Image()
codeImg.onload = function () {
ctx.drawImage(codeImg, 208 * ratio, 362 * ratio, 70 * ratio, 70 * ratio)
}
codeImg.src = codeSrc
ctx.restore()
}
// // 绘制头像
if (avatarSrc) {
avatarSrc = avatarSrc.replace('xuetian.oss-cn-hangzhou.aliyuncs.com', 'oss.xuetian.cn')
const r = 28 * ratio / 2
ctx.save()
ctx.beginPath() // 开始绘制
ctx.strokeStyle = '#fff'
// 先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针
ctx.arc(15 * ratio + r, 399 * ratio + r, r, 0, Math.PI * 2, false)
ctx.stroke()
ctx.clip() // 画好了圆 剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 这也是我们要save上下文的原因
const avatarImg = new Image()
avatarImg.crossOrigin = 'anonymous'
avatarImg.onload = function () {
ctx.drawImage(avatarImg, 15 * ratio, 399 * ratio, 28 * ratio, 28 * ratio)
}
avatarImg.src = avatarSrc
ctx.restore() // 恢复之前保存的绘图上下文 恢复之前保存的绘图上下午即状态 还可以继续绘制
}
}
bgImg.src = bgSrc
}
}
setTimeout(function () {
Toast.clear()
}, 1000)
}
}
savePoster () {
const url = document.getElementById(this.info.canvasId).toDataURL()
const a = document.createElement('a')
a.download = 'poster'
a.href = url
document.body.appendChild(a)
a.click()
a.remove()
}