生成推广海报是营销类小程序的高频需求:商品详情页"分享海报"、邀请好友"专属邀请码"。要在 Canvas 上绘制图片、文字、二维码,再保存到相册或转发,每一步都有跨端陷阱。本文完整实现一个生产级海报生成方案。
一、需求拆解
一张典型推广海报包含四层:
- 背景图(设计师出图,固定模板)
- 商品图/用户头像(网络图片,动态)
- 文案文字(标题、价格、邀请语)
- 小程序码/二维码(后端生成或前端绘制)
┌─────────────────┐
│ 背景图模板 │
│ ┌───────┐ │
│ │ 商品图 │ │
│ └───────┘ │
│ 商品标题 │
│ ¥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)
}两个前置条件:
- 图片域名要加入 downloadFile 合法域名(小程序后台配置),否则真机直接失败
- 开发者工具勾选"不校验合法域名"只在开发阶段有效,上线前必须配置好
三、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)