【青训营】- 小程序 · 绘图工具封装

579 阅读3分钟

前言

我们在开发基于微信小程序类似电商业务时,常常会遇到分享裂变的需求.典型的场景就是各类信息的海报展示.站在技术的角度来看就是将一些信息(图片,文字)整合成一张图片.而纯手工的调用微信提供的单个画图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
    })
  })
}

结尾

通过上述的介绍,应该对此有个大致的了解,希望能够对您有所帮助!

参考: