uni-app Canvas 海报生成实战:绘制、二维码、保存相册与分享

uni-app Canvas 海报生成实战:绘制、二维码、保存相册与分享

admin
2026-08-01 / 0 评论 / 4 阅读

生成推广海报是营销类小程序的高频需求:商品详情页"分享海报"、邀请好友"专属邀请码"。要在 Canvas 上绘制图片、文字、二维码,再保存到相册或转发,每一步都有跨端陷阱。本文完整实现一个生产级海报生成方案。

一、需求拆解

一张典型推广海报包含四层:

  1. 背景图(设计师出图,固定模板)
  2. 商品图/用户头像(网络图片,动态)
  3. 文案文字(标题、价格、邀请语)
  4. 小程序码/二维码(后端生成或前端绘制)
┌─────────────────┐
│   背景图模板     │
│  ┌───────┐      │
│  │ 商品图 │      │
│  └───────┘      │
│  商品标题        │
│  ¥99.00         │
│  ┌────┐ 长按识别 │
│  │二维码│         │
│  └────┘         │
└─────────────────┘

二、踩坑第一关:网络图片必须先下载

Canvas 的 drawImage 在小程序端不能直接绘制网络图片——必须先 uni.getImageInfo 下载到本地临时路径:

async function loadImages(urls) {
  const promises = urls.map(url =>
    new Promise((resolve, reject) => {
      uni.getImageInfo({
        src: url,
        success: resolve,
        fail: reject
      })
    })
  )
  return Promise.all(promises)
}

两个前置条件:

  1. 图片域名要加入 downloadFile 合法域名(小程序后台配置),否则真机直接失败
  2. 开发者工具勾选"不校验合法域名"只在开发阶段有效,上线前必须配置好

三、Canvas 绘制核心代码

模板与画布初始化

<template>
  <view class="poster-mask" v-if="visible" @click="visible = false">
    <view class="poster-wrap" @click.stop>
      <canvas
        canvas-id="posterCanvas"
        id="posterCanvas"
        class="poster-canvas"
        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
      />
      <view class="poster-actions">
        <button class="action-btn" @click="saveToAlbum">保存到相册</button>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref } from 'vue'

const visible = ref(false)

// 以 750x1200 设计稿为例,画布用 2 倍图保证清晰度
const DESIGN_WIDTH = 750
const DESIGN_HEIGHT = 1200
const canvasWidth = ref(0)
const canvasHeight = ref(0)

async function show(posterData) {
  visible.value = true

  // 按屏幕宽度等比缩放画布,但内部坐标系按设计稿
  const { windowWidth } = uni.getSystemInfoSync()
  canvasWidth.value = windowWidth * 0.9
  canvasHeight.value = canvasWidth.value * (DESIGN_HEIGHT / DESIGN_WIDTH)

  await draw(posterData)
}
defineExpose({ show })
</script>

绘制函数

async function draw(data) {
  const scale = canvasWidth.value / DESIGN_WIDTH
  // 微信新版 canvas 2d 接口更清晰,旧 canvas-id 接口在新基础库中表现不稳
  const ctx = uni.createCanvasContext('posterCanvas')

  // 1. 预下载所有网络图片
  const [bg, goodsImg, avatar, qrCode] = await loadImages([
    data.bgUrl, data.goodsUrl, data.avatarUrl, data.qrUrl
  ])

  ctx.drawImage(bg.path, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT)

  // 2. 商品图(圆角矩形裁剪)
  drawRoundImage(ctx, goodsImg.path, 40, 200, 670, 500, 16)

  // 3. 标题:手动换行
  drawWrappedText(ctx, data.title, {
    x: 40, y: 740, maxWidth: 670,
    lineHeight: 44, maxLines: 2, fontSize: 34, color: '#333'
  })

  // 4. 价格
  ctx.setFillStyle('#e93b3d')
  ctx.setFontSize(28)
  ctx.fillText('¥', 40, 860)
  ctx.setFontSize(44)
  ctx.fillText(data.price, 70, 860)

  // 5. 头像 + 昵称
  drawRoundImage(ctx, avatar.path, 40, 1000, 72, 72, 36)
  ctx.setFillStyle('#666')
  ctx.setFontSize(26)
  ctx.fillText(`${data.nickname} 邀请你`, 130, 1045)

  // 6. 二维码
  ctx.drawImage(qrCode.path, 560, 980, 160, 160)
  ctx.setFillStyle('#999')
  ctx.setFontSize(22)
  ctx.fillText('长按识别', 570, 1175)

  // 7. 绘制(scale 到实际画布大小)
  ctx.scale(scale, scale)
  ctx.draw(false, () => {
    // 绘制完成回调,可以导出了
    posterReady.value = true
  })
}

圆角图片裁剪

Canvas 1.0 接口没有原生圆角,用路径裁剪实现:

function drawRoundImage(ctx, path, 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(path, x, y, w, h)
  ctx.restore()
}

文字自动换行

Canvas 1.0 的 fillText 不换行,手动按宽度切分:

function drawWrappedText(ctx, text, { x, y, maxWidth, lineHeight, maxLines, fontSize, color }) {
  ctx.setFillStyle(color)
  ctx.setFontSize(fontSize)

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

  for (const char of chars) {
    const test = line + char
    if (ctx.measureText(test).width > maxWidth) {
      lines.push(line)
      line = char
      if (lines.length === maxLines) break
    } else {
      line = test
    }
  }
  if (lines.length < maxLines && line) lines.push(line)

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

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

四、导出与保存相册

async function saveToAlbum() {
  if (!posterReady.value) {
    return uni.showToast({ title: '海报生成中', icon: 'none' })
  }

  // 1. 导出画布为图片
  const [err, res] = await new Promise((resolve) => {
    uni.canvasToTempFilePath({
      canvasId: 'posterCanvas',
      // 重要:destWidth/Height 用设计稿尺寸,解决导出模糊问题
      destWidth: DESIGN_WIDTH * 2,
      destHeight: DESIGN_HEIGHT * 2,
      success: resolve,
      fail: resolve
    })
  })
  if (err) {
    return uni.showToast({ title: '导出失败', icon: 'none' })
  }

  // 2. 保存到相册(需要授权)
  const [saveErr] = await new Promise((resolve) => {
    uni.saveImageToPhotosAlbum({
      filePath: res.tempFilePath,
      success: () => resolve([null]),
      fail: (e) => resolve([e])
    })
  })

  if (saveErr) {
    // 授权被拒的处理
    if (saveErr.errMsg.includes('auth deny') || saveErr.errMsg.includes('authorize')) {
      showModalThenOpenSetting()
    } else {
      uni.showToast({ title: '保存失败', icon: 'none' })
    }
    return
  }

  uni.showToast({ title: '已保存到相册', icon: 'success' })
}

function showModalThenOpenSetting() {
  uni.showModal({
    title: '提示',
    content: '需要相册权限才能保存海报,请在设置中开启',
    success: (res) => {
      if (res.confirm) {
        uni.openSetting({})
      }
    }
  })
}

保存相册授权被拒是必踩的坑:第一次拒绝后,后续调用 saveImageToPhotosAlbum 会直接 fail,必须引导用户去 openSetting 手动开启。这段兜底代码一定要有。

五、小程序码的两种来源

方式一:后端生成(推荐)

带参小程序码必须由后端调 wxacode.getUnlimited 接口生成:

// 前端只要传参
const qrUrl = await request.post('/qrcode/generate', {
  scene: `uid=${userStore.userInfo.id}`, // 最大 32 字符
  page: 'pages/goods/detail'
})

scene 参数会出现在 onLoad 的 options.query 里,注意 32 字符上限和仅支持数字/字母/部分符号。

方式二:前端绘制普通二维码

不需要小程序码(H5 分享场景)时,可以纯前端生成。引一个小巧的 QR 库(如 weapp-qrcode),把矩阵点画到 canvas 上即可,零网络请求。

六、新版 Canvas 2D 接口(进阶)

微信基础库 2.9+ 提供 Canvas 2D 接口,类型对齐 Web 标准,清晰度控制更好:

async function drawWithCanvas2d(data) {
  // 获取 canvas 节点
  const query = uni.createSelectorQuery()
  query.select('#posterCanvas').fields({ node: true, size: true })
  const { node, width } = await new Promise(r => query.exec(res => r(res[0])))

  const dpr = uni.getSystemInfoSync().pixelRatio
  node.width = width * dpr      // 物理像素
  node.height = canvasHeight.value * dpr

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

  // 之后就是标准 Web Canvas API:roundRect、drawImage(Image 实例)...
  const img = node.createImage()
  img.src = bgTempPath
  await new Promise(r => { img.onload = r })
  ctx.drawImage(img, 0, 0, width, canvasHeight.value)
}

新版接口的图片要用 node.createImage() 创建 Image 实例加载,支持 ctx.roundRect() 原生圆角,长望建议迁移。旧接口(canvas-id + createCanvasContext)目前仍广泛使用,本文两种都给了方案。

七、完整流程串联

用户点击"生成海报"
    → show(posterData) 打开弹层
    → loadImages 预下载(失败 toast + 埋点)
    → ctx 绘制各图层
    → draw 回调标记 ready
    → 用户点"保存"
    → canvasToTempFilePath 导出(destWidth 2倍防模糊)
    → saveImageToPhotosAlbum(授权被拒 → openSetting 引导)
    → toast 成功

总结

  • 网络图片绘制前必须 getImageInfo 下载,域名要配白名单
  • 导出防模糊:destWidth/destHeight 设为逻辑尺寸 × 2
  • Canvas 1.0 圆角靠 clip 裁剪,文字换行靠 measureText 手动切
  • 保存相册被拒授权后必须 openSetting 引导,这是审核与差评高发点
  • 带参小程序码后端生成,scene 限 32 字符
  • 新项目直接上 Canvas 2D 接口,对齐 Web 标准

海报功能链路长、坑密集,把本文的流程清单存好,下次实现能省一半时间。

0

评论 (0)

取消
0:00