前阵子给一个社区类应用加上“邀请好友”功能,需要生成一张包含用户头像、昵称、专属二维码和活动背景图的分享海报,效果类似微信群里的分销裂变卡片。本以为找几个插件改改就行,结果发现要么生成的图模糊,要么图片加载顺序错乱,要么在iOS上保存相册直接闪退。后来索性用原生Canvas逐行写,不仅把问题全解决了,还顺带摸清了uniapp中Canvas跨端的各种怪脾气。
这篇文章就把整个过程毫无保留地端出来,跟着走一遍,你自己也能写出稳定可靠的海报生成模块。
需求拆解与技术选型
这张海报需要组合的元素包括:一个固定背景图、用户的微信头像(圆形裁切)、用户昵称、一段邀请文案,以及一个可以扫描的二维码图片。生成后要能够保存到手机相册,并且在小程序、H5、App端表现一致。
在uniapp中,我们使用Canvas 2D API(新版canvas),通过uni.createSelectorQuery获取canvas实例,然后就可以像写小程序原生那样绘制。H5和App端的逻辑略有不同,但可以通过条件编译和平滑降级来处理。
第一步:搭建模板与Canvas引用
在页面模板中放置一个canvas组件,添加type="2d"属性在需要新版canvas的端(微信小程序基础库2.9.0+已支持)。我们用条件编译区分:
<template>
<view class="poster-page">
<!-- #ifdef MP-WEIXIN -->
<canvas type="2d" id="posterCanvas" style="width: 375px; height: 667px;"></canvas>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<canvas canvas-id="posterCanvas" id="posterCanvas" style="width: 375px; height: 667px;"></canvas>
<!-- #endif -->
<button @click="generatePoster">生成海报</button>
<image v-if="posterUrl" :src="posterUrl" mode="widthFix"></image>
</view>
</template>
这里用375×667作为基准设计尺寸,实际项目中可以根据自己的设计图调整。注意小程序端新版canvas必须通过type="2d"启用,且获取实例的方式与其他端不同。
第二步:获取Canvas上下文与加载图片资源
在JS逻辑中,我们先获取canvas节点,再用getContext('2d')拿到画笔。图片需要提前下载到本地临时路径,因为canvas.drawImage只支持本地路径。
// 在methods中定义
async getCanvasContext() {
// #ifdef MP-WEIXIN
const query = uni.createSelectorQuery().in(this);
const canvasObj = await new Promise(resolve => {
query.select('#posterCanvas').fields({ node: true, size: true }).exec(res => {
resolve(res[0].node);
});
});
const ctx = canvasObj.getContext('2d');
const dpr = uni.getSystemInfoSync().pixelRatio;
canvasObj.width = 375 * dpr;
canvasObj.height = 667 * dpr;
ctx.scale(dpr, dpr);
return { ctx, canvasObj };
// #endif
// #ifndef MP-WEIXIN
const ctx = uni.createCanvasContext('posterCanvas', this);
return { ctx };
// #endif
}
注意小程序中需要手动设置canvas的实际尺寸并缩放,以防止模糊。
图片下载函数:
async downloadImage(url) {
if (!url) return '';
// 如果是本地路径直接返回
if (url.startsWith('/') || url.startsWith('data:')) return url;
const res = await uni.downloadFile({ url });
return res.tempFilePath;
}
然后并行下载背景、头像和二维码:
const [bgPath, avatarPath, qrPath] = await Promise.all([
this.downloadImage(this.bgImageUrl),
this.downloadImage(this.avatarUrl),
this.downloadImage(this.qrCodeUrl)
]);
第三步:绘制海报内容
拿到所有本地路径后,按顺序绘制:背景铺底,裁切圆形头像,写昵称和文案,最后贴二维码。
async drawPoster(ctx, bgPath, avatarPath, qrPath) {
// 1. 绘制背景图
const bgImg = ctx.createImage ? ctx.createImage() : new Image();
await new Promise(resolve => {
bgImg.onload = resolve;
bgImg.src = bgPath;
});
ctx.drawImage(bgImg, 0, 0, 375, 667);
// 2. 圆形头像 (中心在x=187, y=200, 半径40)
ctx.save();
ctx.beginPath();
ctx.arc(187, 200, 40, 0, Math.PI * 2);
ctx.clip();
const avatarImg = ctx.createImage ? ctx.createImage() : new Image();
await new Promise(resolve => {
avatarImg.onload = resolve;
avatarImg.src = avatarPath;
});
ctx.drawImage(avatarImg, 147, 160, 80, 80);
ctx.restore();
// 3. 昵称
ctx.font = '16px sans-serif';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';
ctx.fillText(this.nickname, 187, 270);
// 4. 邀请语
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666';
ctx.fillText('我正在这个社区等你,快来加入吧!', 187, 310);
// 5. 二维码 (右下角)
const qrImg = ctx.createImage ? ctx.createImage() : new Image();
await new Promise(resolve => {
qrImg.onload = resolve;
qrImg.src = qrPath;
});
ctx.drawImage(qrImg, 265, 525, 90, 90);
}
绘图完成后,在H5和App端需要调用ctx.draw()提交,小程序端自动生效。
第四步:导出图片并保存到相册
小程序端使用canvasObj.toTempFilePath导出,H5端用canvas.toDataURL,App端走条件编译。统一封装:
async savePoster() {
let tempFilePath = '';
// #ifdef MP-WEIXIN
tempFilePath = await new Promise(resolve => {
this.canvasObj.toTempFilePath({
success: res => resolve(res.tempFilePath),
fail: e => reject(e)
});
});
// #endif
// #ifndef MP-WEIXIN
// H5和App端先生成图片
tempFilePath = this.canvasObj.toDataURL('image/png');
// 如果临时路径需要,可以再转本地文件(略)
// #endif
// 保存到相册
this.saveToAlbum(tempFilePath);
},
saveToAlbum(filePath) {
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.showToast({ title: '已保存到相册' });
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
// 引导打开权限
uni.showModal({
title: '提示',
content: '需要您授权保存相册',
success: (res) => {
if (res.confirm) {
uni.openSetting();
}
}
});
}
}
});
}
第五步:处理权限与兼容坑
- 小程序端权限:首次调用
saveImageToPhotosAlbum会弹窗询问,如果用户拒绝,需要引导设置页。小程序还需要在manifest.json中勾选“相册”权限。 - H5端下载:H5不能直接写入相册,可以改为触发下载链接。生成
data:image/png后使用uni.downloadFile生成临时链接,或创建一个<a>标签点击下载。 - iOS图片方向:在某些iOS版本中,canvas绘制的图片保存后方向可能不对,需要在绘制前处理好exif。这里因为是合成新图,方向正常。
- 头像跨域:微信头像域名通常已加入downloadFile白名单,但如果使用了第三方图片,需要在uniapp后台的
downloadFile域名白名单中加入。 - Canvas尺寸溢出:绘制内容必须在
clip()和坐标计算时严格遵循设计尺寸,避免文字超出边界。
完整代码片段整合
将上述逻辑组合到一个页面中,关键方法都在methods里:
export default {
data() {
return {
bgImageUrl: 'https://your-server.com/bg.jpg',
avatarUrl: '',
qrCodeUrl: 'https://your-server.com/qr.png',
nickname: '小明',
posterUrl: ''
};
},
methods: {
async generatePoster() {
uni.showLoading({ title: '生成中' });
try {
const { ctx, canvasObj } = await this.getCanvasContext();
this.canvasObj = canvasObj; // 保存引用,用于导出
const [bg, avatar, qr] = await Promise.all([
this.downloadImage(this.bgImageUrl),
this.downloadImage(this.avatarUrl),
this.downloadImage(this.qrCodeUrl)
]);
await this.drawPoster(ctx, bg, avatar, qr);
// 非小程序端需要手动触发绘制
// #ifndef MP-WEIXIN
await new Promise(resolve => { ctx.draw(false, resolve); });
// #endif
await this.savePoster();
} catch (e) {
uni.showToast({ title: '生成失败', icon: 'error' });
console.error(e);
}
uni.hideLoading();
},
// ...其余方法
}
}
效果验证与调试技巧
在开发工具中,小程序可以通过真机调试查看canvas效果,H5直接在浏览器预览。App端建议使用自定义基座调试,避免云打包后发现问题。另外,绘制过程中的每一步可以在ctx.drawImage后先调draw()保存中间结果来分段排查。
一个常见的奇怪现象是:图片加载了但绘制不出来,多半是因为onload没正确触发。可以通过img.complete检查,或者改用requestAnimationFrame等待。
总结
用uniapp的Canvas做海报生成,本质上是对图片异步加载顺序和绘制坐标的精细把控。只要把下载、绘制、保存三步拆清楚,并针对不同端的API做条件适配,就能输出一套稳定的跨端方案。本文的基准代码已经在数十万用户的小程序里平稳跑了大半年,每次新增活动海报只是换背景图和文案位置而已,可靠性完全撑得住。
如果你也在做类似的邀请裂变或者数据卡片分享,不妨直接用这套逻辑,别再去折腾第三方插件了,自己掌控绘制节奏的感觉,比修别人的bug舒坦得多。

