年后有个需求,要在微信小程序、H5和App里生成一张带二维码和用户信息的分享海报。原以为用Canvas画个图再保存,半天搞定的事,结果我在三端来回调试,硬是花了两个工作日。今天把完整的实现过程和几个大坑写出来,给遇到过同样问题的兄弟们一个参考。
我用的是uni-app + Vue3 + TypeScript,直接上Canvas 2D接口。为什么不用旧版canvas?因为新版接口在性能和写法上统一,以后好维护。下面从画海报到保存相册全流程拆开说。
先搭一个简单的页面
页面里只需要一个canvas元素和一个“保存海报”的按钮。canvas需要设置id,因为我们用的是Canvas 2D,需要通过uni.createSelectorQuery拿到节点后获取context。
<template>
<view class="container">
<canvas type="2d" id="posterCanvas" class="poster-canvas"></canvas>
<button @click="savePoster">保存图片到相册</button>
</view>
</template>
这里有一个最关键的样式问题:canvas的宽高一定要写死,而且在uni.getSystemInfoSync里拿到的屏幕宽度需要用px。千万别用rpx,因为canvas内部坐标单位是px,rpx在不同设备转换会出现模糊或者偏移。我一开始就是用rpx,结果在iPhone 14 Pro Max上整个图歪了。
画海报的完整函数
在onReady里调用绘制方法。因为Canvas 2D的节点查询是异步的,必须等节点渲染完成才拿得到context。
async function drawPoster() {
const query = uni.createSelectorQuery().in(instance)
const canvasNode = await new Promise((resolve) => {
query.select('#posterCanvas').fields({ node: true, size: true }).exec((res) => {
resolve(res[0])
})
})
if (!canvasNode) {
uni.showToast({ title: '画布初始化失败', icon: 'none' })
return
}
const { node: canvas, width, height } = canvasNode
const ctx = canvas.getContext('2d')
// 设置画布尺寸,确保像素比正确
const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
// 绘制背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
// 绘制用户头像(圆形裁剪)
const avatarUrl = userInfo.avatarUrl
await drawAvatar(ctx, avatarUrl, 40, 40, 50)
// 绘制昵称
ctx.fillStyle = '#333333'
ctx.font = 'bold 16px sans-serif'
ctx.fillText(userInfo.nickname, 110, 65)
// 绘制二维码
const qrPath = await generateQrCode(userInfo.inviteCode)
await drawQrCode(ctx, qrPath, width - 120, height - 120, 80)
// 绘制提示文案
ctx.fillStyle = '#999999'
ctx.font = '12px sans-serif'
ctx.fillText('长按识别二维码,领取专属福利', 20, height - 30)
}
这段代码本质很简单,但是有坑:如果头像和二维码是网络图片,直接用ctx.drawImage会画不出来,因为图片没加载完。必须用uni.getImageInfo先把图片下载成本地路径,再画到canvas上。
头像绘制:必须处理网络图片
function drawAvatar(ctx, url, x, y, r) {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: (res) => {
const img = canvas.createImage()
img.onload = () => {
ctx.save()
ctx.beginPath()
ctx.arc(x + r, y + r, r, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(img, x, y, r * 2, r * 2)
ctx.restore()
resolve()
}
img.onerror = reject
img.src = res.path
},
fail: reject
})
})
}
这里有一个很容易忽略的问题:在微信小程序里,canvas.createImage()返回的是小程序专用图片对象,而在H5端,它其实是HTMLImageElement。但从uni-app的角度看,这两个对象都有onload和src属性,所以这样写能兼容三端。千万不要用new Image(),在微信小程序里不认识这个构造函数。
绘制二维码也是同样的套路
二维码我是用插件生成的,先得到临时文件路径,再画上去。注意二维码图片四周要留白,否则用户扫码容易失败。
function drawQrCode(ctx, qrPath, x, y, size) {
return new Promise((resolve, reject) => {
const img = canvas.createImage()
img.onload = () => {
ctx.drawImage(img, x, y, size, size)
resolve()
}
img.onerror = reject
img.src = qrPath
})
}
保存到相册,这才是重头戏
绘制完成后,调用uni.canvasToTempFilePath把canvas转成临时图片,再调用uni.saveImageToPhotosAlbum保存。但在微信小程序里,用户必须先授权相册权限,否则会直接失败。
async function savePoster() {
try {
// 先绘制,确保内容是最新的
await drawPoster()
// 获取canvas节点(需要重新获取一次,因为之前可能已经销毁或上下文变化)
const query = uni.createSelectorQuery().in(instance)
const canvasNode = await new Promise((resolve) => {
query.select('#posterCanvas').fields({ node: true, size: true }).exec((res) => resolve(res[0]))
})
if (!canvasNode) {
throw new Error('Canvas节点不存在')
}
// 将canvas导出为临时文件
const tempFilePath = await new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvas: canvasNode.node,
success: (res) => resolve(res.tempFilePath),
fail: reject
})
})
// 检查并请求相册权限
await ensureAlbumPermission()
// 保存到相册
await new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: resolve,
fail: (err) => {
if (err.errMsg.includes('auth deny') || err.errMsg.includes('authorize')) {
uni.showModal({
title: '提示',
content: '需要您授权相册权限,请在设置中打开',
success: (res) => {
if (res.confirm) {
uni.openSetting()
}
}
})
}
reject(err)
}
})
})
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e) {
console.error('保存失败', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
权限处理:每个端的权限请求方式不一样
小程序里要用uni.authorize配合scope.writePhotosAlbum,但有些用户之前拒绝过,直接调用authorize会返回fail,所以更稳妥的办法是直接调uni.saveImageToPhotosAlbum,如果失败了再根据错误码处理。上面的ensureAlbumPermission是我封装的,其实内部什么都没干,只是做个提示。
在App端,需要配置manifest.json里的权限声明,并在调用保存前检查是否已授权。H5端就没有这个烦恼,浏览器会自动处理。
function ensureAlbumPermission() {
return new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.getSetting({
success: (res) => {
if (!res.authSetting['scope.writePhotosAlbum']) {
uni.authorize({
scope: 'scope.writePhotosAlbum',
success: resolve,
fail: () => resolve() // 等待saveImageToPhotosAlbum那边统一处理
})
} else {
resolve()
}
},
fail: resolve
})
// #endif
// #ifdef APP-PLUS
plus.android.requestPermissions(
['android.permission.WRITE_EXTERNAL_STORAGE'],
() => resolve(),
() => resolve()
)
// #endif
// #ifdef H5
resolve()
// #endif
})
}
注意App端如果用的是iOS,不需要授权相册,因为系统弹窗是自动的。但Android 13以上需要申请READ_MEDIA_IMAGES权限,不过那个是读取相册,保存到相册只需要WRITE_EXTERNAL_STORAGE,而且部分新机型已经不需要了。反正代码里判断一下,能跑就行。
关于Canvas的尺寸和清晰度
手机屏幕的像素比(dpr)不同,导致同样的CSS尺寸下,实际渲染的物理像素不同,所以必须把canvas的buffer尺寸设置成CSS尺寸乘以dpr,否则保存出来的图片会模糊得像马赛克。
const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
这里有个关键点:query.select('#posterCanvas').fields({ node: true, size: true })返回的width和height是CSS的尺寸,单位是px。在H5端,如果页面里没有给canvas设置宽度,拿到的就是0。所以我建议在样式中写死canvas的尺寸,或者用uni.upx2px换算。
另外画布背景要自己画,canvas默认是透明的,直接保存的话背景是黑色的(在某些App端)。我每次都先画一个白色背景,这样即使文字错位也不会难看。
字体加载的坑
在微信小程序里,默认不支持加载本地字体文件,用自定义字体需要额外配置,而且Canvas里字体如果不支持,会变成默认的sans-serif。我的做法是直接使用sans-serif,避免麻烦。
ctx.font = 'bold 16px sans-serif'
如果项目里确实需要特殊字体,H5端可以直接用CSS加载,小程序端就得用wx.loadFontFace,但Canvas里使用加载后的字体会遇到各种兼容问题。我建议别折腾,就系统默认字体,干净利落。
真机调试时Canvas内容为空?
我遇到过两次这种情况,几乎让我砸手机。后来发现是因为在onReady里调用绘制方法时,canvas节点还没完全渲染完成。解决方法是把绘制放到setTimeout里延迟一下,或者用nextTick。但我更推荐在onReady里使用uni.createSelectorQuery().in(this).select('#posterCanvas').fields({node: true, size: true}).exec()的回调里绘制,注意.in(this)一定不能少,特别是组件里。
onReady() {
// 使用nextTick确保渲染完成
this.$nextTick(() => {
this.drawPoster()
})
}
在页面里写没问题,在自定义组件里必须加上in(this)。我从页面抽到组件后,忘了加这个,结果Canvas节点一直拿不到。
如何调试Canvas绘制结果
不要只盯着模拟器,模拟器里Canvas的表现和真机差很多。我会在模拟器上先画出大概位置,然后真机预览,重点看底部是否被刘海屏遮挡,以及图片是否偏移。为了快速定位,可以在绘制时临时加一些辅助线。
ctx.strokeStyle = '#ff0000'
ctx.strokeRect(0, 0, width, height)
还能把绘制过程每一步的日志打出来,确认图片有没有成功加载。但千万别用console.log打印canvas对象,会刷屏。
从失败中整理的完整代码结构
最后我把drawPoster里所有的逻辑抽成了一个独立的composition函数,方便多个页面复用。
// usePoster.js
export function usePoster() {
const drawPoster = (canvasNode, userInfo) => {
// ... 所有绘制逻辑
}
const savePoster = () => {
// ... 保存逻辑
}
return { drawPoster, savePoster }
}
然后在页面组件里调用,这样不会让整个页面代码变成一团乱麻。
今天写的这些东西,都是我一行一行调出来的。如果你正准备做类似功能,可以直接把上面代码拷贝过去改一改。如果能帮到你,我就觉得没白写。
还有一点,保存相册后最好把临时文件清理掉,可以用uni.removeSavedFile或者uni.deleteFile,但是实测不清理也没事,系统会定期回收。
目前这套方案已经在微信小程序、H5、安卓App上验证过,iOS也找人测过,都没问题。如果你们有遇到Canvas保存后白屏、画布模糊、权限获取失败的情况,欢迎在下面留言,我看到了会回复。

