简介:一份面向微信小程序开发者的分享海报生成示例包,专注解决社交传播场景中海报与二维码合成的实际需求,适合初级、中级开发者快速上手或移植。示例涵盖完整实现链路:通过wx.getImageInfo获取网络图片信息、wx.downloadFile将远程图缓存为本地文件,再借助qrcode.js生成二维码数据,并将两者绘制到canvas画布,最后调用wx.saveImageToPhotosAlbum授权保存至相册。压缩包共5个文件,由wxss样式、json配置、js逻辑与wxml页面结构组成,其中js代码对关键API的调用顺序与参数做了清晰标注;另附txt使用说明,讲解集成步骤、常见错误处理与权限配置注意点。整个包仅4KB,结构简约无冗余,便于逐行研读和按需修改。已有2648人学习下载,对想在小程序中快速落地带二维码分享海报的开发者颇具参考价值。 做了几年微信小程序,分享海报这个功能几乎在每个需要裂变拉新的项目里都会遇到,而且坑不少。最近整理了一套相对完整的实现方案,包含canvas绘制、二维码生成、保存到相册的完整链路,特此分享出来,希望能帮你少踩几个坑。
这套方案解决的核心问题是:用户在小程序内一键生成带专属二维码的海报图片,分享到微信群或朋友圈后,新用户扫码即可进入小程序并绑定推荐关系。听起来不复杂,但真正落地时会遇到图片跨域加载、canvas层级错乱、二维码扫码无效、iOS保存失败等一堆问题。下面从设计思路到完整代码,再到踩坑实录,一条条捋清楚。
1. 整体设计思路与方案选型
1.1 需求拆解:一张海报背后有什么
先别急着写代码,把需求拆开看。一张分享海报通常包含四类元素:背景图、用户信息(头像、昵称)、正文内容(商品图、标题、价格等)、二维码。其中二维码不是普通二维码,而是小程序码,用户扫码后可以直接跳转到小程序的指定页面,同时带上分享者的ID作为参数,实现渠道追踪。
这个"带上分享者ID"是核心需求,也是很多初学者的坎。如果只是单纯把一张二维码贴到海报上,扫码进来的人没法确定是哪个用户带来的流量,裂变效果就等于零。所以必须在生成海报前,先拿到当前登录用户的唯一标识,把它拼进小程序码的scene参数里。
另一个容易忽略的点是图片的跨域加载。小程序的canvas不能直接绘制网络图片,必须先用 wx.getImageInfo 把图片下载到本地临时文件,拿到本地路径以后再绘制。这个下载过程是异步的,而且多个图片还需要并行加载,所以代码里得处理好并发逻辑,否则会出现"画布上只显示了背景图,二维码没出来"这种尴尬情况。
1.2 技术方案对比:Canvas绘制还是DOM截图
目前生成海报的主流方案有两种:Canvas绘制和DOM节点截图。后端用puppeteer之类的工具直接渲染HTML再截图,前端威ux则是用 wx.createCanvasContext 或者新版 Canvas 2D 接口在canvas上逐像素绘制。
后端方案的好处是图片清晰度高、样式控制灵活,但缺点也很明显:需要额外部署服务,响应速度受网络影响,而且高峰期并发压力大。前端Canvas方案的好处是不依赖服务器、实时性强,代价是代码量明显增加,布局全靠手算坐标,调试起来相对费劲。
我个人的建议是:海报样式比较固定的场景,用前端Canvas方案就够了;如果需要频繁改版、多种模板切换,再考虑后端渲染。前端方案里还要选一版接口,旧版的 wx.createCanvasContext 用起来直观但性能一般,新版 Canvas 2D 接口更贴近Web标准、支持离屏canvas,但API风格差异大,容易踩兼容性的坑。下面这套代码用的是新版 Canvas 2D 接口,适配的成熟度已经比较高了。
2. 核心细节解析与实操要点
2.1 二维码生成原理与工具选择
小程序里生成二维码有两种途径:一种是通过微信服务端API换取官方小程序码,另一种是在前端本地生成普通二维码。
官方小程序码(wxacode.getUnlimited)的好处是扫码体验顺畅、不会出现"无法识别"的提示,但需要后端配合调用接口拿图片,而且接口有调用频率限制。前端本地生成二维码的典型方案是使用 weapp-qrcode 这个库,它在canvas上根据二维码编码算法画出黑白方块,完全不用请求服务器,缺点是生成的是普通二维码,内容只能是一段URL或文本,用户扫码后微信会先打开一个中间页,再跳转到小程序,链路会比小程序码长一步。
我这边的选择是两条腿走路:如果后端方便,优先用官方小程序码;如果只是个Demo或者后端资源紧张,就用前端生成普通二维码,把跳转地址指向小程序内的页面路径加参数。普通二维码的内容可以写成pages/index/index?scene=userId_xxx这样的scheme,用户扫码后通过微信的扫一扫识别,可以顺利进入小程序。
如果你对二维码的容错率有要求,比如海报上二维码可能被遮挡、折叠,记得把容错级别调到最高(H级),这样即使二维码部分区域被污染,扫码也依然能够识别。weapp-qrcode 的配置里提供了 errorCorrectLevel 参数,直接设成 'H' 就行。
2.2 Canvas绘制流程与关键参数
用Canvas绘制海报,本质上就是在一张画布上按坐标摆放各个元素。新版 Canvas 2D 接口的绘制逻辑和Web端的canvas几乎一致,核心步骤如下:获取画布节点并设置宽高、在画布上绘制背景图、绘制头像(需要先裁剪成圆形)、绘制昵称和正文文本、绘制二维码、调用 canvasToTempFilePath 导出临时文件、最后保存到相册。
这里有几个参数需要特别留意。首先是画布的物理尺寸和逻辑尺寸,比如设计稿是750x1334,canvas的width设成750,height设成1334,但在高分辨率设备上会模糊,所以需要乘上一个dpr(设备像素比)的系数。dpr可以用wx.getWindowInfo().pixelRatio获取,然后canvas的真实宽高设为 750 * dpr,绘制时再用ctx.scale(dpr, dpr)把坐标系归一化,这样画出来的图才够清晰。
其次是文字绘制,注意ctx.setTextAlign和ctx.textBaseline的组合,以及对中文字体的兼容。部分安卓机默认字体渲染中文会发虚,最好通过ctx.font = 'bold 28px sans-serif'显式指定,并且字号太大时换行容易错位,所以封装一个自定义的换行函数会比手动敲\n更稳妥。
3. 实操过程与核心代码实现
3.1 基础准备:项目结构与依赖
先看一个最小可运行的项目结构:
pages/ poster/ index.wxml index.js index.wxss utils/ qrcode.js // weapp-qrcode 核心库,也可以npm安装 poster.js // 海报绘制封装函数建议把海报绘制逻辑单独抽成一个模块,方便多个页面复用。如果项目用了npm,可以直接安装weapp-qrcode,否则就从GitHub仓库把lib下的qrcode.js拷贝到utils目录。注意新版小程序要勾选"构建npm",路径别配错。
3.2 模板与样式文件
WXML部分只需要一个canvas节点和一个保存按钮,不需要复杂的布局。
<view class="poster-page"> <canvas type="2d" id="posterCanvas" class="poster-canvas" ></canvas> <button class="save-btn" bindtap="onSavePoster">保存海报到相册</button> </view>对应WXSS给canvas设置固定尺寸,注意这里的尺寸是逻辑像素,canvas内部的物理像素会在JS里通过dpr调整。
.poster-page { display: flex; flex-direction: column; align-items: center; min-height: 100vh; background: #f5f5f5; padding: 20rpx 0; } .poster-canvas { width: 690rpx; height: 1226rpx; border-radius: 16rpx; background: #fff; } .save-btn { margin-top: 40rpx; width: 600rpx; background: #07c160; color: #fff; font-size: 32rpx; border-radius: 44rpx; }3.3 核心绘制代码与参数计算
新建utils/poster.js,把绘制逻辑封装成一个Promise函数,这样页面里调用起来非常顺手。
实际开发中,背景图和头像都是网络图片,所以先并行加载,全部成功后再开始绘制。加载图片用wx.getImageInfo,它在成功回调里会返回图片本地路径。用Promise.all控制并发,任何一个图片加载失败都会触发整体失败,这时候可以给用户一个toast提示,而不是画出一张烂图。
// utils/poster.js function loadImage(src) { return new Promise((resolve, reject) => { wx.getImageInfo({ src, success: (res) => resolve(res.path), fail: reject, }); }); } function drawPoster({ canvas, width, height, bgPath, avatarPath, nickname, qrcodePath }) { return new Promise((resolve, reject) => { const ctx = canvas.getContext('2d'); const dpr = wx.getWindowInfo().pixelRatio; canvas.width = width * dpr; canvas.height = height * dpr; ctx.scale(dpr, dpr); // 绘制背景图 ctx.drawImage(bgPath, 0, 0, width, height); // 绘制圆形头像 const avatarSize = 120; const avatarX = 40; const avatarY = 40; ctx.save(); ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(avatarPath, avatarX, avatarY, avatarSize, avatarSize); ctx.restore(); // 绘制昵称 ctx.fillStyle = '#333333'; ctx.font = 'bold 28px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(nickname, 190, 75, width - 190 - 20); // 绘制二维码区域 const qrSize = 180; const qrX = width - qrSize - 40; const qrY = height - qrSize - 40; ctx.drawImage(qrcodePath, qrX, qrY, qrSize, qrSize); // 导出图片 setTimeout(() => { wx.canvasToTempFilePath({ canvas, success: (res) => resolve(res.tempFilePath), fail: reject, }); }, 300); }); } module.exports = { loadImage, drawPoster };在页面里组合使用这两个函数,注意二维码的生成结果是一个临时文件路径,也需要通过loadImage转成canvas可识别的路径格式,或者直接用weapp-qrcode生成的临时文件路径。
Page({ data: { posterPath: '', }, async onLoad() { const canvas = await this.getCanvas(); await this.generatePoster(canvas); }, getCanvas() { return new Promise((resolve, reject) => { wx.createSelectorQuery() .select('#posterCanvas') .fields({ node: true, size: true }) .exec((res) => { if (res && res[0] && res[0].node) { resolve(res[0].node); } else { reject(new Error('canvas节点未找到')); } }); }); }, async generatePoster(canvas) { wx.showLoading({ title: '海报生成中' }); try { const bgPath = await loadImage('https://example.com/poster-bg.png'); const avatarPath = await loadImage(this.data.userInfo.avatarUrl); const qrcodePath = await this.generateQrcode(); const posterPath = await drawPoster({ canvas, width: 690, height: 1226, bgPath, avatarPath, nickname: this.data.userInfo.nickname, qrcodePath, }); this.setData({ posterPath }); } catch (err) { wx.showToast({ title: '生成失败,请重试', icon: 'none' }); console.error('generatePoster error:', err); } finally { wx.hideLoading(); } }, generateQrcode() { // 这里以weapp-qrcode为例 return new Promise((resolve, reject) => { const QRCode = require('../../utils/qrcode'); const qrcodeCanvas = wx.createOffscreenCanvas({ type: '2d', width: 250, height: 250 }); QRCode({ canvas: qrcodeCanvas, width: 250, height: 250, text: 'pages/index/index?scene=' + this.data.userInfo.id, errorCorrectLevel: 'H', correctLevel: 'H', callback: () => { wx.canvasToTempFilePath({ canvas: qrcodeCanvas, success: (res) => resolve(res.tempFilePath), fail: reject, }); }, }); }); }, async onSavePoster() { if (!this.data.posterPath) { wx.showToast({ title: '海报尚未生成完毕', icon: 'none' }); return; } try { await wx.saveImageToPhotosAlbum({ filePath: this.data.posterPath }); wx.showToast({ title: '已保存到相册', icon: 'success' }); } catch (err) { if (err.errMsg && err.errMsg.includes('auth deny')) { wx.showModal({ title: '提示', content: '需要您授权保存图片到相册', success: (res) => { if (res.confirm) wx.openSetting(); }, }); } else { wx.showToast({ title: '保存失败', icon: 'none' }); } } }, });这段代码里的坐标参数不是随手写的。以750x1334的设计稿为准,我把canvas在页面里设置成了690x1226,因为上下左右各留了一点安全边距,防止部分机型状态栏遮挡。头像固定40px起步、120px大小,昵称从190px开始排,避开头像区域。二维码放在右下角,尺寸180px,距离右侧和底部都是40px,这个位置在视觉上最容易被接受,也不容易遮挡背景主体内容。
3.4 布局参数的计算思路
很多人copy代码时最头疼的是坐标怎么来的,这里说下我的推演方法:先把背景图设计稿拿到设计软件里量一遍核心元素的坐标,然后在代码里按设计稿的逻辑像素一一对应。因为canvas绘制时用的是逻辑像素坐标,所以过程和设计稿里的标注几乎没有差别。
头像和昵称在左上角,头像40,40;昵称垂直居中对齐,x从190开始。为什么是190不是180?因为头像120px宽,加上跟文字之间的10px间距,40+120+10=170,再留20px视觉缓冲,取190比较稳。二维码180px放在右下角,如果还要加一个"长按识别二维码"的提示语,可以在二维码上方留出40px的文本绘制位置,坐标就要相应上移。
二维码的尺寸也有讲究。180px在导出后的图片里大概是540物理像素,放在朋友圈里足够被清晰识别。如果用户经常压缩图片,建议至少200px起步,但再大就有点影响海报美观了,具体根据你的背景设计来。
4. 常见问题与排查技巧实录
4.1 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 画布空白或只有背景图 | 网络图片未加载完成 | 用wx.getImageInfo预加载所有图片,全部成功后再绘制 |
| 头像不圆 | 没有使用clip裁剪路径 | 绘制前ctx.save() + beginPath + arc + clip,绘制后restore |
| 二维码扫码无效 | 二维码内容不是合法scheme,或容错率低 | 内容使用小程序页面路径,容错级别设为H |
| canvas导出失败 | 导出时机太早,绘制未完成 | setTimeout延迟300ms左右再调用canvasToTempFilePath |
| iOS保存相册失败 | 用户未授权 | 先主动调用saveImageToPhotosAlbum,失败后引导打开设置页 |
| 海报在部分安卓机模糊 | 未按dpr放大canvas物理像素 | canvas.width = 逻辑宽度 * dpr,ctx.scale(dpr, dpr) |
4.2 三个真实踩坑记录
第一个坑是canvasToTempFilePath在iOS上偶发性的导出失败。表现为Android正常,iOS有时导出黑屏。排查后发现是因为绘制完成后立即导出,canvas还没完成渲染合成,尤其在图片较多的场景下更明显。后来在导出前加了300ms的延时,彻底解决了问题。如果还是担心时间不够,可以使用ctx.draw的回调,新版的canvas 2d接口虽然没有draw的回调,但配合requestAnimationFrame或setTimeout是够用的。
第二个坑是授权弹窗被系统拦截。用户第一次点击保存时主动弹出授权框,一旦用户点了拒绝,后面再调用wx.saveImageToPhotosAlbum都不会弹窗了,直接走fail回调。所以fail里必须引导用户去wx.openSetting()手动打开相册权限。另外要注意在调用保存前先wx.getSetting查询一下授权状态,如果已经是拒绝状态,直接弹引导弹窗,不要等fail再处理,这样体验更顺滑。
第三个坑是weapp-qrcode在部分基础库版本上canvas传参会报错。如果使用的是wx.createOffscreenCanvas,需要确认基础库在2.16.1以上。老旧基础库不支持离屏canvas,这时候会退化成非常难排查的报错。建议在app.json里设置一个合理的最低基础库版本,同时在真机上多测几个微信版本。
4.3 调试小技巧
调试canvas时,建议在自定义模式下打开vconsole,并且把wx.canvasToTempFilePath生成的结果提前保存到相册里看效果,而不是每次都用真机预览那个canvas节点。因为canvas在模拟器上的渲染效果跟真机有差异,颜色、字体、圆角都可能有偏差。还有一个小技巧是临时把背景改成纯白色,方便查看元素边界和位置,调整完再换回正式背景。
另一个调试技巧是把海报图上传到图床或者保存到文件系统里,然后用微信开发者工具的"本地资源"能力直接预览大图。有些问题在canvas节点上肉眼看不出来,但导出图片以后就会暴露,比如文案被截断、二维码被遮挡,这时候直接看成品图最直观。
5. 进阶扩展与性能优化
5.1 多模板方案与动态配置
如果业务方要求多套海报样式,建议把每个模板的绘制逻辑抽象成配置驱动。简单来说就是准备一个JSON配置,描述每个元素的类型、坐标、字体、对齐方式,然后写一个通用的绘制引擎去解析这个配置。新增模板时只需要加一份JSON,不用改代码。
const template = { elements: [ { type: 'image', key: 'background', x: 0, y: 0, width: 690, height: 1226 }, { type: 'avatar', key: 'avatar', x: 40, y: 40, size: 120, border: true }, { type: 'text', key: 'nickname', x: 190, y: 75, fontSize: 28, color: '#333333', weight: 'bold' }, { type: 'qrcode', x: 470, y: 1006, size: 180 }, ], };这样的好处是后端可以动态下发模板,前端不用发版就能改海报样式,适合运营活动频繁变更的场景。缺点是通用引擎的代码量会更大,兼容边界也更多。我在实际项目里是把模板json放在管理后台,小程序启动时拉取一次并缓存,活动更新时替换图片和文案即可。
5.2 生成性能与图片优化
海报生成耗时主要卡在图片加载上,尤其是背景图。一个1MB的背景图在弱网环境下可能要加载好几秒。建议把设计稿导出时做压缩,宽度不能超过750px,格式优先用WebP或JPEG,体积控制在200KB以内。另外小程序包内的静态图比网络图片加载快得多,如果背景图不常改,直接本地打包比放CDN更稳妥。
离屏canvas在这里也能派上用场。二维码这种固定尺寸的生成完全可以先用离屏canvas画一次,生成临时文件后缓存起来,后续用户再次生成海报时直接复用二维码图片,不用每次都跑一遍二维码编码计算。头像和昵称变化频繁的部分才需要实时绘制,这样整体性能能提升不少。
5.3 业务安全与消息推送的衔接
海报生成后还有一个环节容易被忽略:用户把海报分享出去,新用户扫码进小程序,此时要能正确记录邀请关系。建议scene参数里只放一个短ID,比如scene=U12345,而不是纯数字ID。因为scene参数限制32位可见字符,如果塞太多业务参数会把长度撑爆,而且明文暴露用户ID也有被刷接口的风险。后端再根据U12345解析出真实的用户ID。
新用户进入小程序后,如果你想在对方授权手机号后给分享者发一条通知消息,可以参考微信小程序的消息推送配置。订阅消息的模板ID要在mp后台申请,前端用wx.requestSubscribeMessage拉起授权,后端调用subscribeMessage.send接口下发通知。整个链路跟海报生成配合好,做一场拉新活动是比较顺手的。
6. 写在最后的实操心得
海报生成这个功能,我觉得最值得投入精力的不是canvas绘制本身,而是整体交互链路的设计。从生成预览、引导保存、分享出去、新用户扫码、邀请关系绑定、消息回流,每一步的体验都会直接影响最终的裂变效果。
我个人的体会是,canvas方案的代码量虽然比截图方案大,但它稳定可控,不受WebView渲染差异的影响,而且不依赖服务端资源。只要你把图片预加载、坐标推算、权限处理、兼容性适配这几个环节的功课做足,后续维护成本其实很低。最后再分享一个很多人不知道的小技巧:做分享海报时,把背景设计成竖向9:16的比例,导出的时候顺便生成一张等比缩小的分享卡片图,用在小程序的onShareAppMessage里,两种场景共用一套视觉,传播效果会更统一。
如果你正准备动手写自己的版本,建议先把最小可行版本跑通,再逐步加模板、加缓存、加业务参数。别一上来就追求完美,代码能跑出图的那一刻,很多之前想不明白的问题自然就清晰了。
本文还有配套的精品资源,点击获取