运营丢过来一张设计稿,说“就要这个效果,分享出去带二维码”,很多前端的第一反应是:不就是画张图吗。真上手才发现,这不是画图的问题,是布局系统、异步资源加载、像素密度适配、导出与保存权限四件事同时压在一个 API 上。小程序原生CanvasContext给你的是 setFillStyle、fillRect、drawImage 这种“画笔级”接口,连一行文字自动换行都得自己算宽度,一个圆角矩形要拆成四条弧线。wxa-plugin-canvas 这个组件的价值就在这里:它把“画海报”从命令式绘图改成了声明式配置,你描述“这里有一张图、那里有一行字、字号 32、超出 550 自动折行”,剩下的坐标换算和绘制顺序它替你跑。这篇内容面向的是做过小程序、但被海报生成折磨过或者正准备做的人,我会把配置字段的含义、几个最容易写错的参数、资源加载的真实链路,以及我自己踩过的几类失败场景完整拆开讲,看完你可以直接抄配置改成自己的海报。
1. 小程序海报生成为什么总被严重低估
1.1 一张海报里同时压着三层不同性质的问题
很多人评估工作量时只看到“视觉还原”这一层,觉得对着设计稿把坐标抄一遍就完事。实际拆开看,一张海报至少牵扯三层:第一层是排版层,元素的位置、层级、文字折行、行高、对齐方式,这层是纯计算,但原生接口一个都不给你;第二层是资源层,海报里的头图、用户头像、商品图、二维码,全都是网络图片,而 canvas 的drawImage只认本地临时路径,你必须先把每一张图wx.downloadFile下来,还要处理加载失败、超时、域名未配置的情况;第三层是导出层,画完之后canvasToTempFilePath拿临时文件,再saveImageToPhotosAlbum存相册,中间夹着用户授权弹窗,用户点拒绝之后还得引导他去设置页重新打开。
这三层叠在一起的麻烦在于它们的失败表现完全不同。排版错了是画面难看,资源加载失败是画面局部空白但整体正常,导出失败则是什么都不报,就卡在那里。我在一个活动页里遇到过一次,测试机上一切正常,上线后部分机型分享出去的海报里头像位置是一块纯白,排查了半天才发现是头像域名没进downloadFile白名单,安卓上静默失败、iOS 上有时候能出图,属于最典型的那种“只在部分人身上复现”的问题。
所以评价一个海报生成方案好不好,不要只看它画得像不像,要看它有没有把这三层的问题收敛到一处。wxa-plugin-canvas 的思路就是把排版层完全接管,资源层做统一的预加载和串行控制,导出层留出回调让你自己接业务。
1.2 原生 CanvasContext 到底缺了什么
我把原生接口的短板列一下,你就明白为什么这类封装组件会存在,也明白选型时该重点看什么:
| 能力 | 原生 CanvasContext | 实际影响 |
|---|---|---|
| 文字自动换行 | 不提供 | 长文案要么溢出画布,要么自己写循环measureText累加宽度 |
| 圆角矩形 | 不提供 | 需要arcTo四次拼接,位置算错一点就是歪的 |
| 图片预加载 | 不提供 | 每张图自己写下载队列,还要处理并发上限(小程序同时下载数有限制) |
| 元素层级 | 靠调用顺序 | 谁先画谁在下面,稍微调换代码顺序整张图就乱 |
| 像素密度适配 | 手动乘 | 不乘pixelRatio,在 3 倍屏上导出的图会糊成一片 |
| 调试 | 基本没有 | 画错了只能靠猜,没有可视化的结构信息 |
最后一条其实是最要命的。原生命令式绘图,你写错一个坐标,画面上只表现为“某个东西不见了”,你无法从现象反推是哪一行代码的问题。声明式配置的好处就在这:你的所有元素都在一个 JSON 对象里,打开debug开关之后每个元素会画出边框,一眼就能看出是哪个 block 的宽高算错了。
2. wxa-plugin-canvas 的核心思路:把命令式绘图翻译成配置
2.1 从 poster 配置对象到绘制指令的映射关系
这个组件的使用模型很直接:你给它一个poster属性,里面是一个描述整张海报的对象,对象包含三类数组——blocks(色块、矩形)、texts(文本)、images(图片)。组件内部拿到这个对象后,会按固定顺序遍历这三个数组,把每一项翻译成对应的 canvas 绘制指令。也就是说,你不再关心“先画什么后画什么”,只关心“有哪些元素、它们在哪”。
这个模型带来的最大好处是可复用。同一个页面里,活动海报、邀请海报、成绩单海报可能只是配置不同,你可以把配置抽成函数,传不同的数据进去生成不同的 poster 对象,组件本身不用动。我在一个项目里做过六套海报模板,全部共用一个组件实例,切换模板就是setData换一个配置对象,代码量比原来写了六个页面少了一大半。
配置对象的基本骨架长这样,字段含义我后面逐个拆:
// poster 配置对象的核心结构 const posterConfig = { width: 750, // 画布逻辑宽度,单位 px(注意不是 rpx) height: 1334, // 画布逻辑高度 backgroundColor: '#ffffff', debug: false, // 打开后每个元素会画出边框,调试必备 pixelRatio: 2, // 导出倍率,直接影响清晰度和文件体积 blocks: [], texts: [], images: [] }这里第一个坑就来了:width和height的单位是px 不是 rpx。很多人习惯性按设计稿的 750 宽写,但因为不是 rpx,它在不同屏宽的手机上不会自动缩放,最终得到的永远是 750×1334 像素的图。这其实是好事——海报是拿来分享的静态图,尺寸应该固定,不该跟着设备变。但你心里要清楚:设计稿是 750 宽两倍图的话,你写在配置里的字号应该是设计稿标注值的一半,否则字会大一倍。
2.2 组件边界:它管什么,不管什么
搞清楚能力边界能省掉很多无效搜索。这个组件管的事情:把配置翻译成绘制指令、按顺序绘制、处理图片的预下载、文本按maxWidth自动折行、圆角块绘制、最终导出临时文件路径。
它不管的事情也得说清楚:它不管你图片域名有没有配白名单(这个是小程序后台配置的事)、不管用户相册权限给没给(这是业务逻辑)、不管你的设计稿怎么切成资源配置(这是你自己的工程问题)、也不管海报里的内容从哪来(接口数据、用户信息都是你传进去的)。我见过有人在群里问“为什么二维码画不出来”,贴了半天代码,最后发现是二维码图片的地址是一个需要登录才能访问的接口,downloadFile拿回来的是一个 JSON 错误页,当然画不出来。这类问题组件帮不了你,但你只要理解“图片必须能被匿名下载成文件”这一条,就能自己定位。
3. 从接入到第一张图跑通
3.1 引入组件与页面结构准备
组件的引入方式和普通自定义组件一致,在页面的 json 里注册,wxml 里挂上标签。有一件事需要注意:组件必须真实渲染在页面上,不能hidden或者display:none。因为 canvas 节点需要有实际尺寸才能绘制,把它藏起来会导致绘制结果异常或者直接失败。常见做法是把它放在屏幕外,用绝对定位挪走,而不是用hidden属性。
<!-- 页面 wxml:组件放在可视区外,但不能用 hidden --> <view style="position: fixed; left: -9999px; top: -9999px;"> <wxa-plugin-canvas id="posterCanvas" poster="{{posterConfig}}" onPosterSuccess="onPosterSuccess" onPosterFail="onPosterFail" /> </view>poster属性是响应式的,你setData改了这个对象,组件就会重新绘制一次。这一点在做“换一换”功能时特别方便,改数据即可,不用手动调重绘方法。
页面 js 里需要准备三样东西:配置对象、成功回调、失败回调。触发绘制的时机也要注意——如果你在onLoad里就setData配置,可能组件还没挂载完成,稳妥的做法是把生成动作放到用户点击时触发,或者用onReady之后再加一个短延时。我在早期版本里就吃过这个亏,onLoad里设配置,十次里有一两次出白图,加了onReady之后彻底消失。
3.2 三个数组的字段逐个说清楚
images数组是最常用的,每个元素至少需要x、y、width、height、url。这四个位置尺寸参数是逻辑像素,跟width/height同一坐标系。图片默认按你给的宽高拉伸,不保持比例,所以算尺寸的时候一定要自己按原图比例算好,否则人脸会被压扁。如果你需要圆角头像,加一个borderRadius字段;需要描边,加borderWidth和borderColor。
texts数组的字段最多,也最容易写错。text是内容,x/y是基线起点,fontSize字号,color颜色,lineHeight行高,maxWidth折行宽度,textAlign对齐方式。这里y的语义要特别注意——它是文字的左上角起点而不是基线,但由于字体本身有内边距,实际视觉上文字会略微偏下,微调的时候不要用整数去试,两三个像素的偏移很常见。
blocks数组用来画背景块、分隔线、色条,字段是x/y/width/height/backgroundColor,需要圆角就加borderRadius。有一类高频用法是给文字加一个半透明底衬,做法就是在texts里对应位置先放一个blocks元素,因为数组是按顺序渲染的,blocks先于texts,天然就在文字下面。
3.3 成功与失败回调里该做什么
onPosterSuccess回调里拿到的是临时文件路径,注意是临时路径,小程序重启后可能失效,所以不要把它存到本地缓存里当永久地址用,要存就存到自己的服务端或者云存储。
// 成功回调:拿到临时路径后做保存或预览 onPosterSuccess(e) { const { detail } = e this.setData({ posterPath: detail }) // 这里可以做:预览、保存相册、上传服务端 }, onPosterFail(err) { // 失败原因通常是图片下载失败或画布尺寸非法 console.error('海报生成失败', err) wx.showToast({ title: '生成失败,请重试', icon: 'none' }) }失败回调里我建议做两件事:一是打日志,把当时用的配置对象一起打出来,方便复现;二是给用户一个明确的重试入口,而不是只弹个 toast 就完事。海报生成失败的用户体验很差,因为用户往往已经填了一堆信息,这时候让他从头再来一遍是很挫败的。
4. 配置里最容易写错的四个地方
4.1 pixelRatio 决定了清晰度,也决定了文件体积
这个字段是导出倍率。设成 1,导出的图就是配置里写的宽高像素;设成 2,实际导出尺寸翻倍,比如配置 750×1334,实际得到 1500×2668。为什么需要它?因为很多手机屏幕是 2 倍或 3 倍密度,如果你按 1 倍导出,图片在手机上显示时会被拉伸,边缘发虚、文字发毛。
但倍率不是越高越好。我实测过一组数据:750×1334 的配置,pixelRatio设为 1 时文件大约 200KB 左右,设为 2 时涨到 500KB 到 700KB,设为 3 时能到 1.2MB 以上。而分享到聊天窗口的图片,微信本身还会再压一次,你给的原图再大也没意义。我的经验值是2 倍,兼顾清晰度和体积,除非海报里有大量小字(比如一屏条款说明),那可以到 3,但要做好加载耗时的心理准备。
还有一个隐蔽的坑:pixelRatio提高之后,所有坐标和尺寸依然按逻辑值写,不需要乘。组件内部会处理缩放。我见过有人改了倍率之后手动把坐标也乘了一遍,结果元素全部跑到画布外面去了。
4.2 图片域名白名单:最容易被忽略的前置条件
海报里几乎所有图片都来自网络:用户头像、商品主图、二维码、装饰素材。这些图要在 canvas 里画出来,必须先下载成本地文件,而小程序的下载请求受域名白名单限制。这一步没配置,表现就是图片位置空白,但不报错。
排查这个问题的顺序我固定成三步:第一,把图片地址直接粘到浏览器地址栏,确认能匿名打开(不需要登录态、不带 referer 校验);第二,确认这个域名在小程序后台的 downloadFile 合法域名列表里;第三,在真机上用wx.downloadFile单独测一次,看返回的状态码。这三步走完基本能定位 90% 的空白图问题。
提示:用户头像这类地址经常是带签名的、有过期时间的临时链接,直接把地址存下来过几天就失效了。正确的做法是每次生成海报时用用户当前的 openid 去换一个新的头像地址,而不是把地址写死在用户表里。
4.3 文字折行与行高:maxWidth 是硬约束
maxWidth是折行的唯一依据。不填的话长文本会直接冲出画布,填了之后超出宽度会自动换行。但这里有个细节很多人没注意:折行的单位是字符宽度累加,中文一个字占一个字号宽,英文按字宽算,混排的时候实际折行位置可能和你目测的不一样。做双语种海报时,最好在中文和英文之间留出余量,maxWidth不要卡到刚好。
行高lineHeight决定多行文本的行间距。它的默认行为如果不显式指定,行间距会非常挤。经验值是lineHeight取fontSize的 1.4 到 1.6 倍,正文类文案取 1.5 比较舒服,标题类可以取 1.2 让标题更紧凑。另外,多行文本的y是第一行的位置,后面的行会依次往下推,所以估算整块文字高度的时候要用行数 × lineHeight来算,而不是只看单行。
4.4 元素顺序与遮挡关系
blocks、texts、images三个数组内部的元素按数组顺序渲染,先出现的在下面。跨数组的话,组件的默认顺序是 blocks → images → texts,也就是说文字永远在最上层。这个默认顺序覆盖了大多数场景,但如果你的设计里需要“文字在图片下面”(比如文字压在装饰图上做背景字效果),就得换个思路,把那段文字做成图片资源,或者调整你元素的组织方式。
我踩过一次比较隐蔽的:海报底部有一条半透明的渐变色条压在背景图上,我把它放进了blocks,结果因为 blocks 先渲染,色条被背景图盖住了,等于没画。解决办法是给背景图单独做成 blocks 的 backgroundColor 或者把色条改成图片资源。这类问题不会报错,只能靠debug: true打开边框一眼看出来。
5. 导出之后:保存相册与清晰度的收尾工作
5.1 授权时序:先拿权限还是先生成图片
这个顺序看起来无所谓,实际影响很大。如果用户点“保存”,你先弹授权、用户同意了、然后才开始生成图片,生成过程要下载一批图、画一遍、导出,用户会盯着一个没有任何反馈的界面等一两秒,体验很差。如果反过来,先生成图片(这时候可以显示 loading),生成完了再弹授权,用户点了同意立刻就能存,感知上快很多。
授权被拒绝之后的处理也要想清楚。saveImageToPhotosAlbum拒绝之后,再次调用不会重新弹窗,只会直接失败。这时候只能引导用户去设置页手动打开,代码上要判断错误信息里是否包含“auth deny”之类的标识,然后调起wx.openSetting。
// 保存到相册的典型处理链路 savePoster() { const { posterPath } = this.data if (!posterPath) return wx.saveImageToPhotosAlbum({ filePath: posterPath, success: () => wx.showToast({ title: '已保存到相册' }), fail: (err) => { // 用户拒绝授权的情况,引导去设置页 if (/auth deny|authorize/i.test(err.errMsg)) { wx.showModal({ title: '需要相册权限', content: '请在设置中允许保存到相册', success: (r) => { if (r.confirm) wx.openSetting() } }) } } }) }5.2 生成时机与用户等待的关系
一次完整的海报生成,耗时主要花在图片下载上,而不是绘制本身。绘制是毫秒级的,下载一批图可能要好几百毫秒甚至一两秒。所以优化方向很清楚:能预加载的就预加载。比如用户进入页面时就把他头像、商品图先下载好缓存到本地,等真正点生成的时候直接用本地路径,把poster配置里的 url 换成下载好的临时路径,耗时能砍掉一大半。
另外,生成过程中一定要有 loading 反馈,而且要有超时兜底。我见过一个页面因为某张装饰图挂了 CDN,downloadFile卡在那里没有回调,用户就一直转圈。稳妥的做法是给整个生成动作加一个 5 秒的超时,超时就提示重试,不要让用户无限等待。
6. 动态数据、二维码与多模板的工程化组织
6.1 把配置和数据彻底解耦
如果你只有一张海报,配置写死在页面里也没问题。但一旦超过两张,我强烈建议把“模板结构”和“动态数据”分开。模板结构描述的是坐标、字号、颜色这些不变的东西,动态数据就是用户名、金额、二维码地址。两者通过一个函数合并成最终的 poster 对象。
// 模板与数据分离:模板只描述结构,占位符由数据填充 function buildPoster(template, data) { const { texts, images, blocks } = template return { ...template, texts: texts.map(t => ({ ...t, text: t.text.replace(/\{(\w+)\}/g, (_, k) => data[k] || '') })), images: images.map(img => ({ ...img, url: img.key ? data[img.key] : img.url })) } }这么写的好处是新增模板只需要加一个 JSON,不用改逻辑代码。我在一个项目里用这个思路做了八套模板,运营自己配 JSON 就能上线新模板,前端只维护合并逻辑。
6.2 二维码的位置与尺寸计算
二维码是海报里最容易出问题的元素,原因有三个:一是它必须是可扫描的,尺寸不能太小,一般建议在最终导出图里不小于 200×200 像素;二是它周围要留白,太贴边或者被其他元素压住边角会扫不出来;三是它的生成地址经常需要携带参数,参数里如果带中文或者特殊符号要记得编码。
尺寸换算的思路是这样的:假设海报配置宽度 750,二维码区域设计稿上是 150×150,那么配置里就写 150×150,导出倍率 2 的时候实际是 300×300,完全够扫。留白方面,二维码四周至少留出四分之一模块宽的空白,实操上给个 10 到 15 像素的边距就够。
位置计算建议直接写死相对坐标而不是用百分比,因为这个配置里没有百分比的概念,全都是绝对像素,写死反而最可控。如果海报高度是动态的(比如文字长度不定),那就要先用估算公式算出文字块占多高,再把二维码的 y 坐标往下推。
6.3 高度动态变化的处理
固定高度的海报最好做,配置一个 750×1334 就完事。但很多场景需要海报高度跟着内容走,比如用户留言条数不定、商品数量不定。这时候你有两个选择:一是做最大高度预留,按最多的内容算高度,内容少了就底部留白,实现简单但浪费空间;二是做动态高度计算,先按每块内容的高度累加算出总高,再生成配置。
第二种更好看,但要注意一个细节:canvas 有最大尺寸限制,宽度高度都不能超过一个上限(不同机型不一样,保守估计单边不要超过 4000 像素,考虑倍率之后配置尺寸别超过 2000)。如果你的内容有可能很多,一定要加一个上限截断,超出部分显示成“等 N 项”。
7. 出问题时的排查链路
7.1 白图、黑图、局部空白的三类现象区分
排查海报问题,第一步永远是分清现象,因为不同现象指向完全不同的原因。
| 现象 | 最可能的原因 | 验证方法 |
|---|---|---|
| 整张图全白 | 配置对象为空、组件未渲染、生成时机过早 | 打开debug看有没有边框 |
| 背景正常,图片位置空白 | 图片下载失败、域名不在白名单 | 用downloadFile单独测 |
| 图出了但文字全没 | 文字颜色和背景同色、y坐标超出画布 | 改一个刺眼的颜色试 |
| 元素位置整体偏移 | 坐标误乘了pixelRatio | 检查是否有手动缩放 |
| 圆角、边框不生效 | 字段名写错或该字段不支持当前版本 | 对照组件文档逐字核对 |
我遇到最多的是第二类,图片空白。有一类特别隐蔽:图片文件能下载,但它是 webp 格式,部分低版本基础库的 canvas 不支持 webp 解码,画出来是空白。这种问题在浏览器里看不出来,因为浏览器都支持。解决办法是把素材统一转成 jpg 或 png,或者在下载后判断格式、必要时转码。
7.2 真机与开发者工具的差异
开发者工具的 canvas 实现和真机不完全一致,最典型的差异是字体渲染和图片解码。你在工具里看到的文字位置,到真机上可能差一两个像素;工具里能画的 webp,真机上可能是空白。所以海报这类东西一定要在真机上验证,而且至少验一台 iOS 一台安卓。
另一个真机才有的问题是内存。pixelRatio设得太高、画布尺寸太大,在低端机上可能直接崩掉,表现是生成回调根本不触发。这种情况只能靠降倍率或者分段生成来解决。
7.3 几个我自己踩过的坑
第一个坑:在onLoad里设配置。前面说过,组件可能还没挂载完,结果是间歇性白图。改成用户点击时触发,或者onReady之后延时 100 毫秒,问题消失。
第二个坑:把width/height写了 rpx 单位。配置里不认 rpx,写上也不会报错,只是当作数字处理,最终画布尺寸完全不对。判断方法是看导出的图片实际像素尺寸是否符合预期。
第三个坑:text里带了换行符。配置里的换行是靠maxWidth自动折行,你手动塞\n进去,不同机型的表现不一样,有的会换行有的会显示成方块。多行文本就用多个 text 元素,或者依赖自动折行。
第四个坑:修改了配置但画面没变。poster是响应式的,但如果你改的是同一个引用对象内部的属性(而不是整体替换),组件的监听可能不触发。稳妥做法是每次都构造一个新对象setData。
7.4 关于建一个调试面板的建议
做到第三张海报之后,我给自己加了一个小工具:在开发环境里,把当前 poster 配置序列化成 JSON 显示在页面上,同时打开debug: true显示所有元素边框。这样任何位置问题都能在两分钟内定位,不用再靠肉眼比对和猜坐标。这个习惯养成之后,我做海报的效率至少翻了一倍,因为绝大多数问题其实都是某个坐标算错了一位,或者某个尺寸没按比例算。
我个人的体会是,海报生成这件事的难点从来不在“画”上,而在“资源”和“边界”上。wxa-plugin-canvas 把画的复杂度抹平了,剩下的事情——素材能不能下载、尺寸怎么换算、权限怎么引导、失败怎么兜底——还是得你自己想清楚。每次接新需求,我现在的习惯是先拿一张设计稿,把所有元素按三类数组归类写一遍,把坐标全算出来,再一次性填进配置,而不是边看设计稿边改代码,后者往往要来回试十几次才能对上。素材方面,把 CDN 上的图统一成 jpg、把二维码尺寸固定成 200 起、把头像地址做成实时获取,这三条做到,基本能避开我前面提到的绝大多数坑。