项目中有一个需求:用户生成一张打卡海报,里面有二维码、用户头像、昵称、打卡天数,保存到相册,然后分享到微信好友。听起来很简单,结果我在uniapp里用canvas做这张海报时,前后踩了五六个坑,从canvas绘制到不同平台的API差异,再到权限处理,整整搞了一天半。今天把我的完整实现思路和踩坑细节写出来,给你当个参考。
一、我为什么不用传统DOM方式生成海报
有人可能会说,用html2canvas之类的库不就行了吗?但我在小程序里试过,小程序没有DOM和BOM,html2canvas没法用。然后我用的是uniapp,小程序端只能用canvas原生绘制,H5端可以用html2canvas,但为了统一跨端,我决定还是用uniapp提供的uni.createCanvasContext来绘制。
二、整体实现思路
需要把用户头像、昵称、二维码、背景图等元素绘制到canvas上,然后调用uni.canvasToTempFilePath把canvas导出为临时图片,再调用uni.saveImageToPhotosAlbum保存到相册。整个过程分三步:
- 在页面放一个canvas画布,尺寸按照设计稿来。
- 异步加载图片(头像、背景),用canvas API绘制。
- 生成图片,保存相册。
前两步最繁琐的是图片加载。canvas的drawImage方法不能直接传入网络路径,需要先通过uni.getImageInfo拿到图片的本地路径,否则在小程序里画不出来。
三、页面模板结构
<template>
<view class="poster-container">
<canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas"></canvas>
<button @click="generatePoster">生成海报</button>
<button @click="savePoster">保存相册</button>
</view>
</template>
注意,canvas-id是给canvasContext用的,id是给uni.createSelectorQuery用的。两个都要有。canvas本身要设置宽高,我放在样式文件里,不过这里为了文章简洁就不写样式了。
四、绘制核心代码
我的绘制逻辑封装在drawPoster()方法里。这里有几个关键点:
- 先创建canvasContext对象:
const ctx = uni.createCanvasContext('posterCanvas', this) - 绘制背景色或背景图。
- 绘制头像需要先加载图片,用
uni.getImageInfo。 - 绘制文字(昵称、天数)用
ctx.fillText。 - 最后调用
ctx.draw(false, callback)把绘制内容渲染到canvas上。
这里有一个很大的坑:在H5端,uni.createCanvasContext返回的ctx没有setFillStyle这类名称?其实有的,但有差异。然而我们的业务代码要兼容两端,最好统一用uni提出的API,比如ctx.setFillStyle代替ctx.fillStyle。这是uniapp官方推荐的兼容写法。
下面是我简化的代码段:
methods: {
async drawPoster() {
const that = this;
const ctx = uni.createCanvasContext('posterCanvas');
// 绘制白色背景
ctx.setFillStyle('#FFFFFF');
ctx.fillRect(0, 0, 300, 400);
// 绘制头像
await this.loadImage(this.userInfo.avatarUrl).then(res=>{
ctx.drawImage(res.path, 20, 20, 60, 60);
});
// 绘制昵称
ctx.setFillStyle('#333333');
ctx.setFontSize(16);
ctx.fillText(this.userInfo.nickName, 100, 50);
// 绘制二维码
await this.loadImage(this.qrCodeUrl).then(res=>{
ctx.drawImage(res.path, 200, 200, 80, 80);
});
ctx.draw(false, () => {
console.log('绘制完成');
that.canvasReady = true;
});
},
loadImage(url) {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
});
}
}
你会发现我用了async/await,因为绘制过程中图片加载是异步的,如果不在绘制之前把图片都加载好,直接ctx.drawImage画的是空白。当然也可以把所有图片加载完之后再开始绘制,但我个人喜欢边加载边绘制,配合ctx.draw(false)的false参数表示不延迟绘制,而是立即将之前的操作推入队列。
五、绘制完成之后导出图片
当ctx.draw的回调触发后,canvas上的内容已经渲染完成。这时候就可以用uni.canvasToTempFilePath把canvas导出成图片。
canvasToImage() {
const that = this;
uni.canvasToTempFilePath({
canvasId: 'posterCanvas',
success: (res) => {
that.tempFilePath = res.tempFilePath;
uni.showToast({ title: '生成成功' });
},
fail: (err) => {
console.error(err);
}
});
}
在小程序里,可以直接用canvasId。在H5里,需要额外传canvas参数,指向canvas的DOM元素。我为了兼容,都试过,后来发现uniapp官方在H5端会自动从canvasId找到对应的canvas,但有时候需要延时一点点执行。所以我在ctx.draw回调里加了setTimeout 500ms再导出。
踩坑点:如果你在ctx.draw的回调里立刻调用canvasToTempFilePath,在H5端可能会拿到一张空白图片,因为canvas渲染还没真正结束。我后来改成:
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'posterCanvas',
success: (res) => {
that.tempFilePath = res.tempFilePath;
}
});
}, 500);
});
500毫秒是个经验值,具体可以测试。但注意别太长,用户会等急。
六、保存到相册和权限处理
保存相册的API:uni.saveImageToPhotosAlbum。这个API在很多平台上会要求用户授权相册权限。第一次调用时,uniapp会自动弹窗询问。但如果用户拒绝,下次再调就会直接走fail。所以要在fail里检查错误信息,引导用户去设置页打开权限。
savePoster() {
if (!this.tempFilePath) {
uni.showToast({ title: '请先生成海报', icon: 'none' });
return;
}
uni.saveImageToPhotosAlbum({
filePath: this.tempFilePath,
success: () => {
uni.showToast({ title: '已保存到相册' });
},
fail: (err) => {
// 用户拒绝授权或系统权限关闭
if (err.errMsg && err.errMsg.includes('auth deny')) {
uni.showModal({
title: '提示',
content: '需要您授权保存相册权限',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
uni.openSetting();
}
}
});
} else {
uni.showToast({ title: '保存失败', icon: 'none' });
}
}
});
}
这里有个大坑:安卓手机和苹果手机的错误信息格式不一样。安卓返回的errMsg可能是“saveImageToPhotosAlbum:fail auth deny”,iOS可能是“saveImageToPhotosAlbum:fail:auth denied”。所以不能只判断一种,我用includes('auth')和includes('deny')组合判断,能覆盖大多数情况。
另外,在H5端,saveImageToPhotosAlbum是不支持的。这是uniapp的一个限制,因为浏览器没有直接保存到系统相册的能力。我的处理是:在H5端改成下载图片或者复制图片地址,让用户长按保存。
// #ifdef H5
uni.showModal({
title: '长按图片保存',
content: '请长按图片,选择“保存图片”',
showCancel: false,
success: () => {
// 可以在这里显示一个预览图片
uni.previewImage({
urls: [this.tempFilePath],
current: this.tempFilePath
});
}
});
// #endif
这样虽说不完美,但至少给了用户一个操作路径。
七、关于canvas尺寸以及在手机上的模糊问题
canvas的宽高如果设置小了,导出图片会模糊。比如设计稿是375×667,但你在css里写的canvas宽高也是375px,导出图片分辨率就只有375×667,在大部分手机上清晰度还行,但在大屏手机上有点模糊。更专业一点的做法是:把canvas的宽高设置为设计稿的2倍,然后通过ctx.scale(2, 2)来绘制,最后导出图片也是2倍大小,这样会清晰很多。
不过要注意,uniapp的canvas在H5端和微信小程序中,canvas-id对应的canvas元素的实际尺寸,可能和你css里的尺寸不一致。我建议直接通过uni.createSelectorQuery获取canvas节点的宽度和高度,然后动态设置canvas的宽度和高度属性(注意不是css)。这个属性在小程序里是canvas组件的宽高,在H5里是canvas画布的实际像素尺寸。
getCanvasSize() {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(this);
query.select('#posterCanvas').boundingClientRect((rect) => {
resolve({
width: rect.width,
height: rect.height
});
}).exec();
});
}
拿到宽度后,再用canvas.width = width * 2; canvas.height = height * 2设置(H5端)。小程序里不能直接改canvas组件的属性,所以我只能在小程序里用固定的宽高值。在实际项目中,我直接用了一个固定的画布尺寸:300×420,适配了大部分机型。
八、网络图片失效怎么办
海报里的二维码是后端返回的链接,有可能是动态生成的,但偶尔会过期。当uni.getImageInfo加载失败时,我们的loadImage会reject,导致后续绘制中断。我加了catch,如果头像加载失败,就用本地一张默认图替代。
this.loadImage(this.userInfo.avatarUrl).then(res => {
ctx.drawImage(res.path, 20, 20, 60, 60);
}).catch(() => {
// 使用默认头像
ctx.setFillStyle('#CCCCCC');
ctx.beginPath();
ctx.arc(50, 50, 30, 0, Math.PI * 2);
ctx.fill();
});
这里也提示一点:在微信小程序中,uni.getImageInfo要求图片域名是白名单里的,否则会fail。和普通业务请求一样,要在小程序后台配置downloadFile合法域名。
九、最终的完整流程
整理一下我的业务逻辑:用户在页面点击“生成海报”按钮,先请求后端获取二维码和基本信息,拿到后用canvas绘制,绘制完成后导出临时图片并显示在页面上,同时不可点击保存按钮。之后用户点击保存到相册,触发权限判断。
所以generatePoster方法做的流程是:先请求数据,然后调用drawPoster,等ctx.draw回调后调用canvasToImage,最后设置一个showSaveBtn为true。
async generatePoster() {
uni.showLoading({ title: '生成中' });
const data = await this.fetchPosterData(); // 获取二维码和用户信息
this.qrcodeUrl = data.qrcode;
this.userInfo.avatarUrl = data.avatar;
await this.drawPoster(); // 这里内部已经用await等待图片加载了
// 等待绘制完成
this.canvasToImage();
uni.hideLoading();
this.saveBtnVisible = true;
}
这里注意:drawPoster方法内部我用的ctx.draw的回调来触发下一步。如果直接在整个async方法里调用canvasToImage,可能绘制还没有完成。所以最好把ctx.draw封装成一个Promise:
drawPoster() {
return new Promise((resolve) => {
// 绘制...
ctx.draw(false, () => {
resolve();
});
});
}
然后在generatePoster里使用await this.drawPoster(),接着调用canvasToImage,才能保证顺序正确。
十、遇到的奇葩错误汇总
1. 在微信小程序里,canvas-id不能和其他页面重复,否则会找不到canvas。我在公共组件里封装海报,就遇到多个页面同时使用同一组件导致canvas-id冲突,后来随机生成canvas-id解决。
2. H5端,如果canvas初始是隐藏的(v-show=false),创建ctx的时候会拿到一个宽高为0的canvas,绘制无效。后来我把canvas移出屏幕,但保持可见,用position: fixed; left: -9999px;处理。
3. ctx.drawImage如果图片路径是http的,在某些安卓浏览器上会报错,用uni.getImageInfo转成本地临时路径就没问题了。
4. 在iOS上,一次性绘制多张网络图片,偶尔会出现部分图片没画上去。原因是getImageInfo并发请求过多,即使Promise都resolve了,最后绘制时有些图片的缓存还没到位。我的解决方法是控制并发,用Promise.all也可以,但最好把图片一个个加载完再绘制。
5. uni.canvasToTempFilePath在小程序中导出的图片默认不带透明背景,如果需要透明背景,需要在绘制时注意不要fillRect整个画布。
十一、总结
用uniapp做海报生成,核心思路就是canvas那套API。但跨端会有很多细枝末节,尤其是H5和小程序的差异、图片加载权限、保存相册授权。我的建议是:先在一个端跑通,再考虑另一个端。千万不要想着写一份代码两边完美兼容,除非你花大量时间去适配。我的最终代码里,H5端和MP端对于保存相册的处理就不一致,这是正常的。毕竟不同平台的能力边界不一样。
如果你只是要实现一个简单的分享图,canvas已经够用。但如果要复杂排版,比如圆角、渐变、多行文字,canvas的代码量会变得很大。你可以尝试把常用绘制函数封装起来,比如drawRoundRect、drawAvatar、drawMultiLineText,这样业务代码会清爽很多。
希望我踩过的这些坑,能帮你在做海报功能时少走几步弯路。

