uni-app Canvas 2D 分享海报:多端差异清单与一套能跑通的生成方案

2026-10-14 0 909

去年帮一个商城项目做分享海报,需求简单得不能再简单:一张背景图 + 商品主图 + 标题 + 二维码,合在一张图上给用户保存。我以为是半天活儿,结果在小程序、H5、App 三个端各栽了一跟头。小程序真机上文字糊得看不清,H5 上传到后端是空白图,App 端保存相册按钮点了没反应。

问题不在业务逻辑,全在 canvas 各端实现的差异上。这篇文章把当时踩过的坑整理成一份清单,配上现在能直接用的代码,省得后来人再摸一遍。

先别碰旧版 canvas

uni-app 早期的 uni.createCanvasContext(canvasId) 已经明确不推荐用了。它有几个硬伤:

  • 绘制 API 是异步队列,所有指令提交后要调 ctx.draw() 才会真正执行。这意味着你不能在绘制中途读取中间状态。
  • 小程序端会产生额外的临时 canvas,真机上文字和图片容易糊。
  • 不支持 ctx.measureText(),文字换行只能靠猜。

现在的方案是 type="2d" 的 canvas,配合 W3C 标准的 2D 上下文。除了多端加载图片的方式不统一,其他 API 都跟浏览器里的 canvas 一致。

<canvas
  type="2d"
  id="poster"
  canvas-id="poster"
  class="poster-canvas"
/>

canvas-id 在新 API 下其实用不到了,但留着没坏处,某些低版本组件库还会读它。

拿到 canvas 节点:组件内和页面内不一样

页面里可以直接用 uni.createSelectorQuery(),但如果 canvas 写在自定义组件里,必须加 .in(instance),否则选不到节点——而且不报错,只是回调里 res[0] 是 null。

import { getCurrentInstance } from 'vue'

function getCanvasNode(selector = '#poster') {
  return new Promise((resolve, reject) => {
    const instance = getCurrentInstance()

    // 页面内不需要 .in(),组件内必须要
    // 统一写上也不会有副作用
    const query = uni.createSelectorQuery()
    // #ifdef MP-WEIXIN || APP-PLUS
    if (instance) query.in(instance.proxy)
    // #endif

    query
      .select(selector)
      .fields({ node: true, size: true })
      .exec(res => {
        if (!res || !res[0] || !res[0].node) {
          reject(new Error('canvas 节点没找到,检查选择器和 .in()'))
          return
        }
        resolve({
          canvas: res[0].node,
          width: res[0].width,
          height: res[0].height
        })
      })
  })
}

回调里返回的 res[0] 有 node、width、height 三个字段。node 就是真正的 canvas 对象,不同端返回的东西类型不一样(小程序是原生节点,H5 是 HTMLCanvasElement),但用法一致。

尺寸初始化:顺序不能错

const dpr = uni.getSystemInfoSync().pixelRatio || 2

// 设置物理分辨率
canvas.width = width * dpr
canvas.height = height * dpr

const ctx = canvas.getContext('2d')
// 缩放坐标系,之后所有绘制都用逻辑像素
ctx.scale(dpr, dpr)

两个要点。第一,先设置 width/height,再 scale。设置尺寸会重置整个上下文状态,先 scale 会被清掉。第二,scale 之后,绘制坐标全部按逻辑像素来,也就是你在 CSS 里看到的尺寸,不用再乘 dpr 了。

这个 dpr 缩放是真机不糊的关键。不缩放的话,物理分辨率只有屏幕的一半,导出的图放大一看全是毛边。

加载图片:三端三个写法

这是踩得最深的一个坑。三端加载图片的方式完全不一样。

function loadImage(canvas, src, timeout = 10000) {
  return new Promise((resolve, reject) => {
    let img = null

    // 小程序和 App 走 canvas.createImage
    // #ifdef MP-WEIXIN || APP-PLUS
    img = canvas.createImage()
    // #endif

    // H5 端直接 new Image()
    // #ifdef H5
    img = new Image()
    img.crossOrigin = 'anonymous'
    // #endif

    if (!img) {
      reject(new Error('当前端无法创建 Image 对象'))
      return
    }

    const timer = setTimeout(() => {
      reject(new Error(`图片加载超时: ${src}`))
    }, timeout)

    img.onload = () => {
      clearTimeout(timer)
      resolve(img)
    }
    img.onerror = (e) => {
      clearTimeout(timer)
      reject(new Error(`图片加载失败: ${src}`))
    }

    img.src = src
  })
}

几个说明。

crossOrigin = 'anonymous' 是 H5 端必须加的。不加的话,只要图片来自不同域名,canvas 就被”污染”,之后 toDataURL() 会抛 SecurityError,甚至 canvasToTempFilePath 直接返回空白图。这个错误非常隐蔽,因为绘制阶段看不出任何异常。

小程序端的网络图片要在后台配置 downloadFile 白名单。不配置的话,真机上加载会失败,但开发工具里能加载——因为开发工具默认关了域名校验。这个坑让很多人误以为代码没问题,一上真机才发现。

App 端图片路径写错了也很麻烦。网络图直接用 URL,本地 static 目录下的图要写相对路径 /static/xxx.png。绝对路径在 App 端加载不了。

canvas.createImage() 返回的 Image 对象在微信小程序里没有 setAttribute,别手滑去调它。

批量加载

async function loadAll(canvas, sources) {
  const list = await Promise.all(
    sources.map(src => loadImage(canvas, src))
  )
  return list
}

不要用 for 循环里 await 串行加载,五张图能慢出半秒。用 Promise.all 并发跑,网络好的时候五张图几乎同时回来。

绘制圆角图片

canvas 没有原生的圆角图片绘制,需要先裁剪再绘制。

function drawRoundImage(ctx, img, x, y, w, h, r) {
  ctx.save()

  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.arcTo(x + w, y, x + w, y + h, r)
  ctx.arcTo(x + w, y + h, x, y + h, r)
  ctx.arcTo(x, y + h, x, y, r)
  ctx.arcTo(x, y, x + w, y, r)
  ctx.closePath()

  ctx.clip()
  ctx.drawImage(img, x, y, w, h)

  ctx.restore()
}

save 和 restore 一定要配对。裁剪会一直生效,不恢复的话后面所有绘制都被裁进这个圆角矩形里。

还有一个细节:arcTo 的四个参数是先画到拐角,再画到下一点。顺序必须是顺时针一圈,写成逆时针图形会歪掉。上面这个顺序对应的是”从左上角开始,顺时针绕一圈”。

文字换行:measureText 是关键

中文和英文混排的时候,按字符宽度硬换行会很难看。用 measureText 逐个测量是目前最稳的方案。

function wrapText(ctx, text, options) {
  const {
    x, y,
    maxWidth,
    lineHeight,
    maxLines = 2
  } = options

  const chars = String(text).split('')
  const lines = []
  let current = ''

  for (let i = 0; i < chars.length; i++) {
    const next = current + chars[i]
    const width = ctx.measureText(next).width

    if (width > maxWidth && current) {
      lines.push(current)
      current = chars[i]

      if (lines.length >= maxLines) break
    } else {
      current = next
    }
  }

  if (lines.length < maxLines && current) {
    lines.push(current)
  }

  // 超出行数时最后一行加省略号
  if (lines.length === maxLines && chars.slice(-1).length > 0) {
    const lastIndex = lines.length - 1
    let last = lines[lastIndex]
    while (ctx.measureText(last + '…').width > maxWidth && last.length) {
      last = last.slice(0, -1)
    }
    lines[lastIndex] = last + '…'
  }

  lines.forEach((line, i) => {
    ctx.fillText(line, x, y + i * lineHeight)
  })

  return lines.length
}

几个注意点。

ctx.measureText 只在 type="2d" 模式下可用。旧版的 canvas context 没有这个方法,这也是必须切新 API 的原因之一。

字体必须先设置好。测量依赖当前 ctx.font,如果先测量再设字体,宽度是按默认字体算的,换行位置会错。

ctx.font = 'bold 16px sans-serif'
ctx.fillStyle = '#222'
const lineCount = wrapText(ctx, title, {
  x: 24,
  y: 400,
  maxWidth: 260,
  lineHeight: 24,
  maxLines: 2
})

小程序端字号只支持数字 + px,ctx.font = '16px' 或者带 weight 的写法都行。App 端如果用了自定义字体,得先通过 uni.loadFontFace 加载完毕再测量,否则宽度测量不准。

导出图片:三种格式三种坑

绘制完成后,导出成临时文件路径。

function exportCanvas(canvas, instance, options = {}) {
  return new Promise((resolve, reject) => {
    uni.canvasToTempFilePath({
      canvas,
      // 组件内必须传组件实例
      ...(instance ? { component: instance.proxy } : {}),
      fileType: 'png',
      quality: 1,
      ...options,
      success: res => resolve(res.tempFilePath),
      fail: err => reject(new Error(JSON.stringify(err)))
    })
  })
}

这里 canvas 参数是 canvas 节点对象,不是 canvasId。用新 API 就必须传节点,传 canvasId 会报找不到组件的错误。组件内调用时 component 参数也是必须的。

H5 端的特殊情况

H5 端调 canvasToTempFilePath,返回值是一个 blob URL,形如 blob:http://localhost:8080/xxx。这个 URL 在后端是没法直接下载的,上传到服务器之前要先转成 Blob 或者 base64。

async function h5ExportBlob(canvas) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(blob => {
      if (!blob) {
        reject(new Error('导出失败,检查图片是否跨域污染了 canvas'))
        return
      }
      resolve(blob)
    }, 'image/png', 0.95)
  })
}

如果 toBlob 回调里的 blob 是 null,八成是图片跨域污染。检查项包括:图片服务器是否返回了正确的 CORS 头,crossOrigin 有没有设置,两个是不是一致。

保存到相册:三端权限各不同

小程序端保存到相册需要用户授权,而且拒绝之后不能反复弹窗骚扰,必须走 openSetting 引导。

async function saveToAlbum(tempFilePath) {
  // H5 端没有相册概念,走下载
  // #ifdef H5
  const a = document.createElement('a')
  a.href = tempFilePath
  a.download = `poster_${Date.now()}.png`
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  return
  // #endif

  // 小程序和 App
  // #ifdef MP-WEIXIN || APP-PLUS
  try {
    await new Promise((resolve, reject) => {
      uni.saveImageToPhotosAlbum({
        filePath: tempFilePath,
        success: resolve,
        fail: reject
      })
    })
    uni.showToast({ title: '已保存到相册', icon: 'success' })
  } catch (err) {
    // 用户主动拒绝授权
    if (err && (err.errMsg || '').includes('auth deny') ||
        (err && (err.errMsg || '').includes('authorize'))) {
      uni.showModal({
        title: '需要相册权限',
        content: '保存海报需要访问相册,请在设置中开启',
        confirmText: '去设置',
        success: res => {
          if (res.confirm) uni.openSetting()
        }
      })
    } else {
      uni.showToast({ title: '保存失败', icon: 'none' })
    }
  }
  // #endif
}

几点说明。

App 端需要在 manifest.json 里配置权限。app-plus 节点下的 distribute.android.permissions 里加上 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>,iOS 端要配置 NSPhotoLibraryAddUsageDescription 文案,不然提交审核会被打回来。

H5 端的下载提示。有些浏览器(尤其是微信内置浏览器)不支持 a.download 属性,点了没反应。更稳妥的做法是显示一张预览图,提示用户长按保存。检测方式可以在 UA 里查 MicroMessenger。

小程序端的错误对象。saveImageToPhotosAlbum 拒绝时的 err.errMsg 里包含 auth deny 或者 authorize,不同的基础库版本字符串略有差别,两个都判断一下比较稳。

完整案例:把上面这些拼成一首歌

写一个生成海报的完整函数,输入是数据,输出是临时文件路径。

import { getCurrentInstance } from 'vue'

const POSTER_W = 300
const POSTER_H = 500

export async function generatePoster(payload) {
  const instance = getCurrentInstance()

  const { canvas, width, height } = await getCanvasNode('#poster')
  const dpr = uni.getSystemInfoSync().pixelRatio || 2

  canvas.width = width * dpr
  canvas.height = height * dpr

  const ctx = canvas.getContext('2d')
  ctx.scale(dpr, dpr)

  // 背景
  ctx.fillStyle = '#ffffff'
  ctx.fillRect(0, 0, POSTER_W, POSTER_H)

  // 加载所有图片
  const [cover, qrcode] = await Promise.all([
    loadImage(canvas, payload.coverUrl),
    loadImage(canvas, payload.qrcodeUrl)
  ])

  // 主图
  drawRoundImage(ctx, cover, 16, 16, POSTER_W - 32, 240, 12)

  // 标题
  ctx.font = 'bold 16px sans-serif'
  ctx.fillStyle = '#222222'
  ctx.textBaseline = 'top'
  wrapText(ctx, payload.title, {
    x: 16,
    y: 274,
    maxWidth: POSTER_W - 32,
    lineHeight: 22,
    maxLines: 2
  })

  // 价格
  ctx.font = 'bold 20px sans-serif'
  ctx.fillStyle = '#ff4d4f'
  ctx.fillText(`¥${payload.price}`, 16, 330)

  // 二维码
  ctx.drawImage(qrcode, POSTER_W - 96, POSTER_H - 96, 80, 80)

  // 底部提示
  ctx.font = '12px sans-serif'
  ctx.fillStyle = '#999999'
  ctx.fillText('长按识别二维码查看', 16, POSTER_H - 40)

  const tempFilePath = await exportCanvas(canvas, instance)

  return tempFilePath
}

调用侧:

async function onShare() {
  try {
    uni.showLoading({ title: '生成中' })
    const path = await generatePoster({
      coverUrl: 'https://cdn.example.com/cover.png',
      qrcodeUrl: 'https://cdn.example.com/qrcode.png',
      title: '这是一段比较长的商品标题,测试一下换行和省略号的效果',
      price: '199.00'
    })
    // H5 端 path 是 blob URL,小程序/App 是临时文件路径
    await saveToAlbum(path)
  } catch (e) {
    uni.showToast({ title: e.message || '生成失败', icon: 'none' })
  } finally {
    uni.hideLoading()
  }
}

几个零碎的坑

canvas 尺寸一旦设置就固定了。小程序端 canvas.width 只能设置一次,重复设置会被忽略。如果海报尺寸需要变化,得重新创建 canvas 节点(用 v-if 控制销毁重建)。这点和浏览器里的 canvas 不一样。

导出时机。所有图片必须等待 onload 之后再绘制,否则画上去的是一张空图。前文代码里 Promise.all 已经保证了这一点。如果绘制逻辑复杂,建议在每个 drawImage 之前都确认 img.complete 或者手动记个标记。

小程序端 ctx.filter 不支持。想加个模糊效果、灰度滤镜之类的,得自己在像素层面操作 getImageData 和 putImageData,性能很差。能不用就不用。

App 端 canvas 的层级。type="2d" 的 canvas 是原生组件,层级天然比普通 view 高。如果需要在 canvas 上盖一个 loading,得用 cover-view 之类的原生组件,普通 view 会被 canvas 挡在后面。

H5 端 canvas 元素定位。因为它是普通 DOM,会被父容器的 overflow: hidden 裁剪,也可能受 transform 影响产生模糊。type="2d" 的 canvas 最好脱离文档流,把宽高设成固定值,放在屏幕外或者用 visibility: hidden 藏起来。

反复生成时的内存。每次生成都会创建一个新的 canvas 上下文,如果用户连续点五次,内存会叠上去。做法是生成完成后手动把 canvas.width = 0 清一下(部分端支持),或者干脆用 v-if 控制,生成完就销毁。

最后

uni-app 的 canvas 2D 现在足够稳定,坑主要集中在多端行为差异上。把图片加载、导出、保存相册这三处的条件编译写清楚,剩下的绘制逻辑就是标准 canvas API,跟写网页时没区别。

如果项目里这些多端差异反复出现,建议把它们统一收进一个工具方法,各端差异封在工具里,业务代码只调一次生成函数。这样即使哪天再踩到新坑,也只改一个地方。

uni-app Canvas 2D 分享海报:多端差异清单与一套能跑通的生成方案
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app Canvas 2D 分享海报:多端差异清单与一套能跑通的生成方案 https://www.taomawang.com/web/uniapp/2939.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务