前言
我们在开发基于微信小程序类似电商业务时,常常会遇到分享裂变的需求.典型的场景就是各类信息的海报展示.站在技术的角度来看就是将一些信息(图片,文字)整合成一张图片.而纯手工的调用微信提供的单个画图API费事费力,开发效率低,不易维护.因此happy-poster绘图工具从此孕育而生.
技术架构
开发历程简介
源起
在以往纯手工绘图工作中,发现两大痛点. 一是绘制信息采集的痛点,绘制信息的每个位置都需要人工测量,信息越多工作量越大; 二是绘制信息过程的痛点,每绘制一个元素都需要查询一连串的API,遇到某些特殊要求时(比如:图片裁切/文字换行处理等)涉及到一些算法... 导致实际业务开发时间少,canvas绘图占比大.容易引发研发进度滞后的现象.
挖掘
经过仔细研读微信小程序的官方技术文档,得到了一些灵感以及兴奋点.WXML系列API能够解决上述的痛点一的问题.通过NodesRef.fieldsAPI就能够拿去到页面各个元素的相关信息,当然还有一些是获取不到,我将它推到应用层来处理(如:文字信息等).有了这个基石之后,我便可以通过自己的流程设计来封装一个简单易用的绘图工具,节省了开发时间和维护成本.(大约压缩90%的工作量)
目标
一行代码完成海报绘图,当然页面布局和样式还是得自己手工编写写.
this.$refs.poster.exec(options, {
getFilePath: (data) => {
wx.previewImage({
current: '', // 当前显示图片的http链接
urls: [data] // 需要预览的图片http链接列表
})
}
})
组件输出层介绍
原本设计是想打掉这一层的存在,因为想着可以通过纯js的调用即可.但后来经过实际真机测试发现还是比不可少的.其他经过大量的测试发现小程序canvas系列API在不同的机型上表现还不一致.所以,我对这一层的设计定位在兼容的作用.
主要是为了解决IOS机型canvas绘图图片输出的问题.
IOS机型图片输出有两大问题,
第一个问题就是canvas元素不给实际大小部分机型导出的图片会发生变形;
第二个问题就是在使用wx.canvasToTempFilePath这个API导出图片时,绘制第一遍图片时百分百变形的,但是绘制第两篇的时候图片输出是正常的,之前尝试过把导出的时间弄长点,试验后发现没用,就是画2次就好;
async loadImage(options) {
options = this._initOptions(options)
try {
this.elementsWithImages = await HappyPoster.logImage(options)
return true
} catch (e) {
console.error(e, 'happy-poster-component error 加载图片失')
}
},
async exec(options, methods) {
if (!this.__systemInfo__) {
this.__systemInfo__ = wx.getSystemInfoSync()
}
// ios 画两次,画第二次的时候才输出结果
if (/ios/ig.test(this.__systemInfo__.system) && !this.styles) {
await this._doExec(options).catch(e => console.error(e))
}
await this._doExec(options, methods).catch(e => console.error(e))
},
async _doExec(options, methods) {
options = this._initOptions(options)
const happyPoster = new HappyPoster(options)
let data = {
methods,
elementsWithImages: this.elementsWithImages,
callbackEnd: this._resetCanvas,
callbackGetCanvasInfo: this._setCanvasStyles
}
await happyPoster.exec(data)
},
_setCanvasStyles({width, height}) {
// 需要给canvas元素添加上样式,也许可能是mpvue的原因?
this.styles = `width:${width}px;height:${height}px;`
},
_resetCanvas() {
// canvas画完之后需要重置掉原来的canvas元素,否下次画布会出现倍增现象
return new Promise(resolve => {
this.isShowCanvas = false
setTimeout(() => {
this.isShowCanvas = true
resolve()
}, 100)
})
},
_initOptions(options) {
if (!options) {
throw new Error('happy-poster-component error 请传入配置文件')
}
options.id = '#' + this.canvasId
options.useRegExpDownloadFile = `common/file/qrcode/miniprogram-load`
return options
}
}
引擎层介绍
该层的目标就是处理自动获取绘制信息,然后根据这些信息完成图片绘制的工作.
- index.js 出口以及流程控制器
- selector.js 元素选择器
- pianter.js 绘图器
- tools.js 工具箱
index
主要函数有:图片加载/执行绘图
图片加载logImage函数,关键API有Canvas.createImage()
static async logImage(options) {
// 通过API拿取绘制信息,以及图片加载所需要的环境
const selectors = await Selector(options.els).catch(err => console.error(err))
const {canvas} = await Tools.getCanvas(id).catch(err => console.error(err))
// 图片资源的下载
const elements = await Tools.loadImage(canvas, selectors, options.useRegExpDownloadFile).catch(err => console.error(err))
return elements
}
执行绘图exec函数, 这里给了几个图片输出的钩子函数getImageBase64,getFilePath
大致流程:
获取元素 --> 获取canvas上下文 --> 设置画布大小 --> 下载图片? --> 画图 --> 输出结果
async exec({methods = {}, elementsWithImages, callbackEnd, callbackGetCanvasInfo}) {
// 选择元素
let selectors = await Selector(options.els).catch(err => console.error(err))
// 获取canvas
const {canvas, ctx} = await Tools.getCanvas(id).catch(err => console.error(err))
// 实例化画家
this.painter = new Paniter({panel: selectors[0], canvas, ctx})
// 设置画布大小
this.painter.init()
if (typeof callbackGetCanvasInfo === 'function') {
await callbackGetCanvasInfo(this.painter.canvasInfo)
}
this.selectors = selectors
this.canvas = canvas
if (typeof methods.getCanvasInfo === 'function') {
await methods.getCanvasInfo(this.painter.canvasInfo)
}
// 下载图片
let elements = {}
if (elementsWithImages && elementsWithImages.pop && elementsWithImages.length) {
elements = Tools.mergeImage(this.selectors, elementsWithImages)
} else {
elements = await Tools.loadImage(this.canvas, this.selectors, options.useRegExpDownloadFile).catch(err => console.error(err))
}
// 画图
this.painter.drawExec(elements)
// 输出base64
let imageBase64
if (typeof methods.getImageBase64 === 'function') {
imageBase64 = this.canvas.toDataURL('image/jpeg', 1)
await methods.getImageBase64(imageBase64)
}
let filePath = ''
// 输出图片临时路径
if (typeof methods.getFilePath === 'function') {
const res = await this.painter.canvasToTempFilePath()
filePath = res.tempFilePath
methods.getFilePath(filePath)
}
if (typeof callbackEnd === 'function') {
await callbackEnd()
}
}
selector.js
选择器主要为了获取各个元素的信息,这次的设计为了节省应用层的负担(减少传入的配置属性),决定在引擎这里多下功夫, 目前设计的画图主要有三画rect画图片画文字,其实这三块已经能够满足99%的业务需求了.如果元素上有src这个属性就判定为图片,data-innerText有值就判定为文字,其他的都为矩形rect
// 指定属性名列表,返回节点对应属性名的当前属性值(只能获得组件文档中标注的常规属性值,id class style 和事件绑定的属性值不可获取)
const properties = [
'mode',
'src'
]
// 指定样式名列表,返回节点对应样式名的当前值
const computedStyle = [
'backgroundColor',
'background',
'color',
'fontFamily',
'fontSize',
'textAlign',
'lineHeight',
// 'padding',
'borderRadius',
'boxShadow',
'fontWeight'
]
function selectAllElements(arr) {
const query = wx.createSelectorQuery()
const selectArray = arr.map(item => {
return selectAll(query, item.el)
})
return Promise.all(selectArray).then(res => {
return res.reduce((accumulator, currentValue) => accumulator.concat(currentValue))
})
}
function selectAll(query, el) {
return new Promise((resolve, reject) => {
query
.selectAll(el)
.fields({
size: true,
dataset: true,
mark: true,
rect: true,
properties,
computedStyle
}, resolve)
.exec()
})
}
export default function (arr) {
if (!(arr instanceof Array)) {
throw new Error('els 应为为数组')
}
return selectAllElements(arr)
}
painter.js
绘制器有个_setCanvasSize设置画布大小,需要注意的是canvas实例的pixelRatio缩放倍数要和canvas上下文ctx.scale缩放的倍数一致,如果ctx上下文没有缩放,你画的元素就会变大反之则反.
其他需要主要的就是reviseDrawData函数,这个函数集中处理的单位转换和尺寸转换的工作
// 设置画布大小
// 计算偏移量
export default class Painter {
constructor(options) {
this.panel = {...options.panel}
this.canvas = options.canvas
this.ctx = options.ctx
this.systemInfo = wx.getSystemInfoSync()
}
init() {
this._setCanvasSize()
}
get canvasInfo() {
return {
width: this.canvasWidth,
height: this.canvasHeight,
canvas: this.canvas,
ctx: this.ctx
}
}
_setCanvasSize() {
const {panel, canvas, ctx, systemInfo} = this
const {height, width} = panel
let {windowWidth, pixelRatio} = systemInfo
const targetWidth = windowWidth
const targetHeight = height / width * targetWidth
// 部分andorid机型超过2倍会出现闪退的现象
if (!/ios/ig.test(systemInfo)) {
pixelRatio = 2
}
canvas.width = targetWidth * pixelRatio
canvas.height = targetHeight * pixelRatio
ctx.scale(pixelRatio, pixelRatio)
this.canvasDpr = targetWidth / width
this.canvasWidth = targetWidth
this.canvasHeight = targetHeight
}
canvasToTempFilePath() {
return new Promise((resolve, reject) => {
const {width, height} = this.canvasInfo
const {canvas} = this
// 图片导出4倍为最佳实践
wx.canvasToTempFilePath({
x: 0,
y: 0,
width,
height,
destWidth: width * 4,
destHeight: height * 4,
fileType: 'jpg',
quality: 1,
canvas,
success: resolve,
fail: reject
})
})
}
destroy() {
if (this.systemInfo && this.ctx) {
const dpr = 1 / this.systemInfo.pixelRatio
this.ctx.scale(dpr, dpr)
}
this.canvas = null
this.ctx = null
this.systemInfo = null
this.canvasWidth = null
this.canvasHeight = null
this.canvasDpr = null
}
drawExec(elements) {
elements.forEach((el, index) => {
el = this.reviseDrawData(el)
if (el.Image) {
this.drawImage(el)
} else if (el.dataset.innertext) {
this.drawText(el)
} else {
this.drawRect(el)
}
})
}
// 调整绘图数据
reviseDrawData(el) {
const {canvasDpr, panel} = this
const originLeft = panel.left
const originTop = panel.top
el.top = (el.top - originTop) * canvasDpr
el.left = (el.left - originLeft) * canvasDpr
el.width *= canvasDpr
el.height *= canvasDpr
// 处理阴影
if (el.boxShadow !== 'none') {
try {
const shadows = el.boxShadow.replace(/, /g, ',').split(' ')
el.shadowInfo = {
shadowColor: shadows[0],
shadowOffsetX: this.reviseUnit(shadows[1]),
shadowOffsetY: this.reviseUnit(shadows[2]),
shadowBlur: this.reviseUnit(shadows[3])
}
} catch (e) {
console.error(e)
}
}
// 处理文本
if (el.dataset.innertext) {
el.fontSize = this.reviseUnit(el.fontSize)
}
return el
}
// 转换单位
reviseUnit(unit) {
const {systemInfo, canvasDpr} = this
const {windowWidth} = systemInfo
if (/px/.test(unit)) {
return unit.replace('px', '') * canvasDpr
}
if (/rpx/.test(unit)) {
return unit.replace('px', '') / 2 * canvasDpr
}
if (/vw/.test(unit)) {
return unit.replace('px', '') / 100 * windowWidth * canvasDpr
}
return unit
}
// 裁切圆形
drawClip(el, callback) {
const {ctx} = this
const { top, left, width } = el
let r = width / 2
let x = r + left
let y = r + top
ctx.save()
ctx.beginPath()
ctx.fillStyle = '#ffffff'
ctx.strokeStyle = '#ffffff'
ctx.arc(x, y, r, 0, 2 * Math.PI)
ctx.fill()
ctx.clip()
callback && callback()
ctx.restore()
}
// 绘制图片
drawImage(el) {
// border-raduis 单位为%时才会触发画圆的情况
if (/%$/.test(el.borderRadius)) {
this.drawClip(el, () => {
this.drawImageDefault(el)
})
} else {
this.drawImageDefault(el)
}
}
drawImageDefault(el) {
const {ctx} = this
let { Image, top, left, width, height, mode } = el
let x = left
let y = top
let w = width
let h = height
if (!mode) {
ctx.save()
ctx.beginPath()
ctx.fillStyle = '#ffffff'
ctx.drawImage(Image, x, y, w, h)
ctx.restore()
return
}
let sW = Image.width
let sH = Image.height
let sWH = sW / sH
let nx, ny, nw, nh
switch (mode) {
case 'aspectFill': {
if ((w <= h && sW <= sH) || (w > h && sW <= sH)) {
nw = w
nh = nw / sWH
ny = y - (nh - h) / 2
nx = x
} else {
nh = h
nw = nh * sWH
nx = x - (nw - w) / 2
ny = y
}
break
}
case 'widthFix': {
nw = w
nh = nw / sWH
ny = y
nx = x
break
}
default:
break
}
ctx.save()
ctx.beginPath()
ctx.fillStyle = '#ffffff'
ctx.strokeStyle = '#ffffff'
ctx.moveTo(x, y)
ctx.lineTo(x, y)
ctx.lineTo(x + w, y)
ctx.lineTo(x + w, y + h)
ctx.lineTo(x, y + h)
ctx.closePath()
ctx.stroke()
ctx.fill()
ctx.closePath()
ctx.clip()
ctx.drawImage(Image, nx, ny, nw, nh)
ctx.restore()
}
// 绘制矩形
drawRect(el) {
const {ctx} = this
const {backgroundColor, left, top, width, height, shadowInfo} = el
ctx.save()
ctx.beginPath()
ctx.fillStyle = backgroundColor
if (shadowInfo) {
ctx.shadowColor = shadowInfo.shadowColor
ctx.shadowOffsetX = shadowInfo.shadowOffsetX
ctx.shadowOffsetY = shadowInfo.shadowOffsetY
ctx.shadowBlur = shadowInfo.shadowBlur
}
ctx.fillRect(left, top, width, height)
ctx.restore()
}
drawText(el) {
const {ctx} = this
let {color, fontSize, textAlign, fontWeight, fontFamily} = el
ctx.save()
ctx.beginPath()
ctx.textBaseline = 'top'
ctx.fillStyle = color
ctx.textAlign = textAlign
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`
this._drawTextMeasure(el)
ctx.restore()
}
_drawTextMeasure(el) {
const {ctx} = this
const {dataset, top, left, width, height, fontSize, textAlign} = el
const lineHeight = 1
const metrics = ctx.measureText(dataset.innertext)
let leftWithTextAlign = left
if (textAlign === 'center') {
leftWithTextAlign = left + width / 2
} else if (textAlign === 'right') {
leftWithTextAlign = left + width
}
const fontHeight = fontSize
if (metrics.width > width) {
let innerTextArray = createMeasureTextArray(ctx, dataset.innertext, width)
for (let i = 0; i < innerTextArray.length; i++) {
let text = innerTextArray[i]
const lineTop = top + i * fontHeight * lineHeight
text && ctx.fillText(text, leftWithTextAlign, lineTop, width)
}
} else {
const lineTop = top + (height - fontHeight) / 2
dataset.innertext && ctx.fillText(dataset.innertext, leftWithTextAlign, lineTop)
}
}
}
function createMeasureTextArray(ctx, string, width) {
const array = []
let lineText = ''
let lineLength = 0
for (let i = 0; i < string.length; i++) {
const char = string.charAt(i)
const charWidth = ctx.measureText(char).width
lineLength += charWidth
if (lineLength < width) {
lineText += char
} else {
array.push(lineText)
lineText = ''
lineLength = 0
lineText += char
lineLength += charWidth
}
}
if (lineText) {
array.push(lineText)
}
return array
}
tools.js
// 获取canvas, canvas context
export function getCanvas(id) {
if (!id) {
throw new Error('Tools getCanvas error 未传入canvas id:' + id)
}
return new Promise((resolve, reject) => {
const query = wx.createSelectorQuery()
query
.select(id)
.fields({
node: true
})
.exec((res) => {
if (!res[0] || !res[0].node) {
throw new Error('getCanvas 未找到canvas:' + id)
}
const canvas = res[0].node
const ctx = canvas.getContext('2d')
resolve({canvas, ctx})
})
})
}
// 合并图片元素
export function mergeImage(elements, elementsWithImages) {
let arr = elements.map((item, index) => {
const el = elementsWithImages.find(v => v.src === item.src)
if (el && el.Image) {
item.Image = el.Image
}
return item
})
return arr
}
// 下载图片:
// 需要注意,canvas.createImage, 实际上是对 wx.downloadFile的一层封装,
// 当image.onload失败时,需要尝试wx.downloadFile进行下载
// 此处因业务需求采用regExp匹配相关src资源时优先采取wx.downloadFile下载策略
export function loadImage(canvas, elements, regExp) {
const arr = []
elements.forEach((item) => {
if (item.src) {
const promise = new Promise(async (resolve, reject) => {
const image = canvas.createImage()
let imageSrc = item.src
if (regExp && new RegExp(regExp).test(item.src)) {
imageSrc = await new Promise((resolve, reject) => {
wx.downloadFile({
url: item.src, // 仅为示例,并非真实的资源
success (res) {
// 只要服务器有响应数据,就会把响应内容写入文件并进入 success 回调,业务需要自行判断是否下载到了想要的内容
if (res.statusCode === 200) {
resolve(res.tempFilePath)
}
}
})
})
}
image.src = imageSrc
image.onload = function () {
resolve(image)
}
image.onerror = function () {
resolve(null)
}
})
arr.push(promise)
}
})
return Promise.all(arr).then(res => {
let index = 0
return elements.map((item) => {
if (item.src) {
item.Image = res[index]
index++
}
return item
})
})
}
结尾
通过上述的介绍,应该对此有个大致的了解,希望能够对您有所帮助!