ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小程序 Canvas 2D 分享海报生成架构与真机避坑

小程序 Canvas 2D 分享海报生成架构与真机避坑

简介:面向微信小程序开发者的新版 canvas2d 海报分享组件代码包,适合需要快速实现商品分享海报、活动推广图等场景的前端与小程序开发者,也适合希望从旧版 canvas 迁移到 canvas2d 的团队参考。内容围绕 canvas2d 绘制效率高、资源占用少、性能优于普通 canvas 的特点展开,将常见海报绘制流程封装成可复用组件,调用时传入参数即可生成,样式也能按业务需要调整;组件还参照了淘宝、京东、拼多多等商城常见的分享海报交互。压缩包共 4 个文件,包含 js 逻辑、wxml 结构、wxss 样式和 json 配置,体积约 4KB,结构轻量,便于直接查看和集成。已有 619 人学习,适合想降低 canvas2d 上手难度、减少重复绘制工作的开发者参考,也可据此理解组件参数传递、画布绘制和样式组织的实现方式,并在此基础上做二次样式定制。

1. 需求先立住:一张分享海报必须在真机上稳定生成

分享海报是带交易或报名场景的小程序躲不开的需求:用户点分享,要当场生成一张带封面、标题、价格、二维码的图,存相册或直接丢进会话。旧版wx.createCanvasContext接口在部分真机上会出现纹理不刷新、draw回调不触发,一张海报等两三秒才出来并不少见。新版 Canvas 2D 接口把浏览器那套 canvas API 直接搬进小程序,主流电商类小程序的分享模块基本都换到了这条路上。这套方案要解决的是:把模板化海报拆成配置、资源、绘制、导出四段可复用管线,换模板只改 JSON,换业务只换数据源,绘图代码一行不动。适合正在做毕设或课设、需要一个完整可演示模块的同学,也适合接手分享功能、准备重写绘制逻辑的开发者。

2. 总体架构:配置层、资源层、绘制层、导出层的职责怎么切

先把结论摆出来:海报功能真正难的地方在于同一份绘制代码要适配十几种模板、几十种机型、几套业务数据,难点集中在架构和状态管理上。我见过太多把绘图逻辑全塞在onShareTap里的写法,第一版能跑,第二个模板进来就开始互相踩变量,第三个模板直接推倒重来。所以这一章先把四层的边界划清楚,后面所有实现都往这个骨架里填。

2.1 旧接口与 Canvas 2D 的差异对比

选型这件事不用纠结太久,把两套接口摆在一起看就明白了。

维度旧接口wx.createCanvasContextCanvas 2D(type="2d")
上下文获取createCanvasContext(canvasId)传字符串 IDnode.getContext('2d')拿真实节点
绘制提交必须调ctx.draw()才真正落屏调用即生效,没有提交批次
图片对象只能传临时文件路径字符串canvas.createImage()拿到 Image 对象
像素比由框架隐式处理,容易糊自己设canvas.width/height,可控
动画能力无requestAnimationFrame支持,可做进度条和过渡
节点查询只能靠 canvasId 关联SelectorQuery拿 node,可封装成 Promise

旧接口最大的问题是时序不透明。draw()的回调在部分机型上会早于图片解码完成触发,你以为画完了,实际上图还没解码完,导出的就是一张纯色底图。这个坑在开发者工具里几乎复现不出来,属于典型的真机玄学。Canvas 2D 把 Image 对象暴露出来,我们就能用img.onload精确控制"图真的可以画了"这一刻,整套流程才变得可预测。

另外 Canvas 2D 走的是同层渲染,canvas 节点可以正常被其他元素覆盖,loading 蒙层、按钮浮层都能叠上去;旧接口的原生组件层级问题在老版本基础库上相当折腾。如果项目还要兼容极低版本基础库,通常的做法是留着旧接口做降级分支,用wx.canIUse('canvas.type.2d')判断走哪条路,但新项目没必要背这个包袱。

2.2 四个模块的职责边界

整体按"谁产生数据、谁消费数据"来切,四层各自只干一件事。

模块职责输入输出失败时的表现
模板配置层描述画布尺寸、图层顺序、坐标样式业务类型 + 模板 JSON一份可渲染的图层数组图层缺失,画面空白但代码不报错
资源层网络图下载、本地缓存、并发控制图层里所有src{ img, width, height }映射表图片 404 或超时,对应图层不渲染
绘制层遍历图层,调用各类型的 painter图层数组 + 资源表画好的 ctx坐标算错,元素出画布或被裁半
导出层导出临时文件、相册授权、分享图canvas 节点 + 逻辑尺寸tempFilePath白图、糊图、保存失败无提示

对应的目录大致长这样,一个模块一个文件,不搞大杂烩:

poster/ ├── index.js # 入口:模板选择、数据注入、串联四层 ├── canvas.js # 节点初始化、DPR 换算 ├── loader.js # 图片下载、缓存池、并发控制 ├── painters/ │ ├── text.js # 文字换行、截断、对齐 │ ├── image.js # cover 裁剪、圆角裁剪 │ └── rect.js # 底色、分割线、标签底板 ├── export.js # canvasToTempFilePath、相册授权 └── templates/ └── product.json # 纯数据模板,不含任何绘制代码

这样拆的好处是:新增一个模板只在templates/里加一个 JSON;新增一种元素类型(比如渐变标签)只在painters/加一个文件再注册进映射表。改动的爆炸半径被压到最小,这也是后面能放心做验收的前提。

2.3 节点初始化与像素比换算

第一步永远是把 canvas 节点拿到手,并按设备像素比把画布撑开。

<!-- poster.wxml:type 必须是 2d,id 用于 SelectorQuery 定位 --> <canvas type="2d" id="posterCanvas" class="poster-canvas" style="width: {{canvasW}}px; height: {{canvasH}}px;" />
// canvas.js —— 拿到 Canvas 2D 节点并按 DPR 初始化画布 function initCanvas(selector, pageInstance, logicWidth, logicHeight) { const query = pageInstance ? pageInstance.createSelectorQuery() : wx.createSelectorQuery(); return new Promise((resolve, reject) => { query.select(selector) .fields({ node: true, size: true }) .exec((res) => { const item = res && res[0]; if (!item || !item.node) { // 节点还没渲染完就查询,是最常见的空指针来源 reject(new Error('canvas node not found: ' + selector)); return; } const canvas = item.node; const ctx = canvas.getContext('2d'); // 新基础库优先用 getWindowInfo,老版本回落到 getSystemInfoSync const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const dpr = info.pixelRatio || 2; // 物理像素撑满,逻辑坐标仍按设计稿写,绘制时不用到处乘 dpr canvas.width = logicWidth * dpr; canvas.height = logicHeight * dpr; ctx.scale(dpr, dpr); resolve({ canvas, ctx, dpr, logicWidth, logicHeight }); }); }); }

三个参数的含义要交代清楚:selector是 wxml 里的#id,组件内部用pageInstance传this(页面直接用wx.createSelectorQuery()就行),logicWidth/logicHeight是设计稿尺寸,比如 375×600。核心在于canvas.width = 逻辑宽 × dpr这一行——canvas 的位图缓冲区是按物理像素算的,只设 CSS 宽度不设这个属性,导出的图就是 1 倍图,放大看全是锯齿。

还有个细节:ctx.scale(dpr, dpr)只对当前上下文生效,如果后面调了ctx.reset()或者重新getContext,缩放就丢了。所以我在绘制函数入口处不依赖外部 scale,而是在初始化时一次性设好,绘制过程不再碰变换矩阵,避免二次缩放把图放大成虚影。

2.4 一次绘制的完整时序

把四层串起来,一次完整的海报生成是这样的:

  1. 用户点击分享按钮,立刻显示 loading 蒙层并上锁,防止连点重复绘制;
  2. 根据业务类型从模板表里取出对应 JSON,把{{title}}、{{cover}}这类占位符替换成真实数据;
  3. 扫描整份图层配置,把所有src收集成数组,交给资源层并发预加载;
  4. 资源全部就绪(或超时兜底)后,初始化 canvas 节点;
  5. 绘制层按图层顺序逐个渲染,先底图后文字再二维码;
  6. 导出层调canvasToTempFilePath拿到临时文件;
  7. 隐藏 canvas,弹保存相册或直接调起分享。

这个时序里任何一个环节失败都要有明确出口:图片超时用占位图顶替、节点拿不到就重试一次、导出失败给用户一个可重试的提示。第 5 章会把这些失败场景逐条拆开。

3. 绘制层实现:图层遍历、文字换行与图片圆角裁剪

这一章进入真正画东西的部分。绘制层的设计原则只有一条:每个 painter 都是纯函数,只接受 ctx 和配置,不读外部变量、不改全局状态。做到这一点,模板才能随便加。

3.1 模板 JSON 的字段约定

模板是一份纯数据,字段名固定,业务侧只负责填值。

{ "width": 375, "height": 600, "background": "#ffffff", "layers": [ { "type": "image", "src": "{{cover}}", "x": 0, "y": 0, "w": 375, "h": 375, "mode": "cover" }, { "type": "text", "value": "{{title}}", "x": 24, "y": 400, "w": 327, "fontSize": 20, "lineHeight": 30, "maxLines": 2, "color": "#1a1a1a", "weight": "bold" }, { "type": "text", "value": "¥{{price}}", "x": 24, "y": 470, "fontSize": 28, "color": "#ff4d4f", "weight": "bold" }, { "type": "image", "src": "{{qrcode}}", "x": 275, "y": 500, "w": 80, "h": 80, "mode": "contain" } ] }

字段约定里有几个值得单独说。w只对需要换行的文字生效,单行文字完全可以不写;mode决定图片是cover(等比铺满后居中裁剪)还是contain(等比缩放到完整可见),商品大图用 cover,二维码和头像必须用 contain,否则扫不出来。坐标全部按x/y左上角定位,不支持百分比,因为海报尺寸是固定的,百分比只会让调试变复杂。

3.2 图层遍历主循环与绘制顺序

主循环很短,难的都拆到 painter 里去了。

// painters/index.js —— 类型到绘制函数的映射表 const painters = { text: drawText, image: drawImage, rect: drawRect }; async function render(ctx, template, data, resources) { // 先铺底色:PNG 透明区域在部分安卓机上导出会变黑,必须垫一层 ctx.fillStyle = template.background || '#ffffff'; ctx.fillRect(0, 0, template.width, template.height); for (const layer of template.layers) { if (layer.hidden) continue; const painter = painters[layer.type]; if (!painter) { console.warn('[poster] 未注册的图层类型:', layer.type); continue; } // 占位符统一在这里替换,painter 内部不再关心数据来源 const conf = resolveTemplate(layer, data); await painter(ctx, conf, resources); } } // 把 {{key}} 替换成 data[key],找不到就返回空字符串 function resolveTemplate(layer, data) { const walk = (val) => { if (typeof val === 'string') { return val.replace(/\{\{(\w+)\}\}/g, (_, k) => (data[k] == null ? '' : data[k])); } if (Array.isArray(val)) return val.map(walk); if (val && typeof val === 'object') { return Object.keys(val).reduce((acc, k) => { acc[k] = walk(val[k]); return acc; }, {}); } return val; }; return walk(layer); }

render是async的,因为图片 painter 内部要等资源就绪。这里用了串行await,看着慢,但海报图层通常不超过十个,串行的实际耗时差异在几十毫秒量级,换来的是绘制顺序绝对可控——背景、大图、文字、二维码的叠放关系一旦乱掉,出来的图就是废的。想要并行优化的话,正确做法是预加载阶段并行、绘制阶段串行,而不是把绘制也并发掉。

3.3 文字换行、截断与基线处理

中文海报最容易被投诉的就是标题。《黑匣子级》的长标题直接溢出画布,或者第二行只剩两个字,观感很差。canvas 没有自动换行,得自己按字符量。

// painters/text.js —— 按最大宽度逐字累加,超出行数用省略号收尾 function wrapText(ctx, text, maxWidth, maxLines) { const chars = Array.from(String(text || '')); // 用 Array.from 拆,避免把 emoji 拆坏 const lines = []; let current = ''; for (let i = 0; i < chars.length; i++) { const test = current + chars[i]; if (ctx.measureText(test).width > maxWidth && current) { lines.push(current); current = chars[i]; if (lines.length === maxLines) break; } else { current = test; } } if (lines.length < maxLines && current) lines.push(current); // 超出行数时,把最后一行裁到能放下省略号的长度 if (lines.length === maxLines) { const consumed = lines.join('').length; if (consumed < chars.length) { let last = lines[maxLines - 1]; while (last.length && ctx.measureText(last + '…').width > maxWidth) { last = last.slice(0, -1); } lines[maxLines - 1] = last + '…'; } } return lines; }

wrapText的关键参数是maxWidth(来自图层的w)和maxLines。注意measureText依赖当前ctx.font,所以在调用它之前必须先把字体设好,否则量出来的宽度和最终渲染对不上——这是我在项目里踩过的坑,同一个字符串两种字体宽度能差 15%,换行位置自然就飘了。

真正画文字时还要处理基线。canvas 默认textBaseline是alphabetic,行高按lineHeight手动累加时,第一行会比预期低一点。我的习惯是统一设成top,然后按y + i * lineHeight逐行画,所见即所得:

function drawText(ctx, conf) { ctx.save(); ctx.font = `${conf.weight || 'normal'} ${conf.fontSize}px sans-serif`; ctx.fillStyle = conf.color || '#333333'; ctx.textBaseline = 'top'; ctx.textAlign = conf.align || 'left'; // 单行文字不需要换行计算,直接画,省一次 measureText 循环 const lines = conf.w ? wrapText(ctx, conf.value, conf.w, conf.maxLines || 2) : [String(conf.value || '')]; lines.forEach((line, i) => { ctx.fillText(line, conf.x, conf.y + i * (conf.lineHeight || conf.fontSize * 1.4)); }); ctx.restore(); }

ctx.save()/ctx.restore()这一对必须成对出现,字体、颜色、对齐方式都改在 save 里面,出了这个 painter 上下文就还原,不然下一个图层的样式会被污染,排查起来非常费劲。

3.4 图片的圆角裁剪与 cover 等比填充

商品图是矩形原图,海报上往往要圆角,甚至是圆形头像。用arcTo画路径再clip是最稳的做法。

// painters/image.js —— 先画圆角路径再裁剪,最后填充图片 function drawRoundImage(ctx, img, x, y, w, h, r) { const radius = Math.min(r, w / 2, h / 2); // 半径不能超过短边一半 ctx.save(); ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + w, y, x + w, y + h, radius); ctx.arcTo(x + w, y + h, x, y + h, radius); ctx.arcTo(x, y + h, x, y, radius); ctx.arcTo(x, y, x + w, y, radius); ctx.closePath(); ctx.clip(); drawImageCover(ctx, img, x, y, w, h); ctx.restore(); } // cover 模式:按比例放大到刚好铺满目标区域,再居中裁剪 function drawImageCover(ctx, img, x, y, w, h) { const scale = Math.max(w / img.width, h / img.height); const sw = w / scale; const sh = h / scale; const sx = (img.width - sw) / 2; const sy = (img.height - sh) / 2; ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); }

drawImage的九参数版本前四个是源图裁剪区,后四个是目标绘制区。很多同学只传五个参数直接拉伸,商品图就会被压扁。cover的算法是先算放大比例scale,再用w / scale反推出源图需要裁多大,最后取居中偏移sx/sy。contain则反过来取Math.min,并且要在目标区域内居中,公式换成(w - img.width * scale) / 2。

圆角半径r要做一次上限保护,Math.min(r, w / 2, h / 2),否则当业务把圆角配成 50、图片只有 40 宽时,arcTo画出来的路径会自交,裁剪区域直接空掉,图片整个消失。

3.5 二维码与头像的兜底

二维码建议由后端直接出图,前端只负责下载和绘制,理由是二维码内容带用户 ID 和场景参数,前端生成还得处理纠错等级和版本,投入产出比不高。如果确实要前端生成,就接一个纯 JS 的二维码矩阵库,把矩阵画成一个个fillRect,注意每格之间不要留缝,否则识别率会掉。

头像和商品图必须准备兜底图。资源层下载失败时返回一张内置的本地占位图(打包进小程序包体),保证海报至少是完整的,而不是少一块。兜底逻辑放在资源层而不是绘制层,绘制层只管"拿到图就画",职责不混。

4. 资源与导出:图片预加载、像素比与相册保存全链路

画得再好,导出糊了、存不进相册,用户照样给差评。这一章把从网络图到相册的整条链路补齐。

4.1 图片预加载与缓存池

canvas.createImage()拿到的 Image 对象,src只能是本地路径或已经下载好的临时文件,直接塞网络 URL 在部分机型上是画不出来的。所以统一走wx.getImageInfo,它既能下载又能顺带返回原始宽高。

// loader.js —— 图片下载与内存缓存 const imageCache = new Map(); // key: 原始 src,value: { img, width, height } function loadImage(canvas, src, timeout = 8000) { if (imageCache.has(src)) { return Promise.resolve(imageCache.get(src)); } return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('image timeout: ' + src)), timeout); wx.getImageInfo({ src, success: ({ path, width, height }) => { const img = canvas.createImage(); img.onload = () => { clearTimeout(timer); const record = { img, width, height }; imageCache.set(src, record); resolve(record); }; img.onerror = (e) => { clearTimeout(timer); reject(e); }; img.src = path; // 注意这里传的是本地临时路径,不是原始 URL }, fail: (e) => { clearTimeout(timer); reject(e); } }); }); }

缓存用Map按原始 URL 存,同一张商品图在一个页面生命周期里只会下载一次。缓存不做主动清理,因为小程序页面销毁时整个 JS 上下文就回收了,手动清理反而容易在页面切换的瞬间把正在用的 Image 对象干掉,导致绘制时拿到一个已失效的引用。timeout参数是保命的,网络差的时候getImageInfo可能长时间不回调,没有超时兜底整个海报流程就挂在那里,用户看到的是 loading 转圈转到天荒地老。

还要提醒一句:网络图域名必须配在 downloadFile 合法域名里,开发者工具里勾了"不校验合法域名"能跑,真机直接失败,这是上线前最经典的翻车点之一。

4.2 并发控制与失败降级

图层一多,同时发起十几个下载请求,弱网下很容易被限流或者部分请求直接失败。我一般把并发压到 4 个,用一个小型的任务队列控制。

// 简易并发池:limit 控制同时在跑的下载数量 async function loadAll(canvas, srcList, limit = 4) { const queue = [...new Set(srcList.filter(Boolean))]; // 去重,同一张图不重复下 const results = {}; let cursor = 0; async function worker() { while (cursor < queue.length) { const src = queue[cursor++]; try { results[src] = await loadImage(canvas, src); } catch (e) { // 单张失败不影响整体,绘制时用占位图顶上 console.warn('[poster] 图片加载失败:', src, e && e.message); results[src] = null; } } } await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, worker)); return results; }

Promise.all里塞的是limit个 worker,每个 worker 自己从cursor抢任务,这是最简单也最好懂的一种并发池写法。失败一律降级成null,绘制层遇到null就画占位图,不抛异常——海报是分享链路上的一环,不能因为一张图挂掉就让整个分享动作失败。

4.3 导出临时文件:尺寸、格式与质量

导出这一步参数给错,前面的功夫全白费。

// export.js —— 导出临时文件,尺寸按逻辑宽高乘以 DPR function exportPoster(canvas, width, height, dpr) { return new Promise((resolve, reject) => { wx.canvasToTempFilePath({ canvas, // Canvas 2D 传节点,不传 canvasId x: 0, y: 0, width, // 逻辑宽,和绘制时的坐标系一致 height, destWidth: width * dpr, // 输出物理宽,决定清晰度 destHeight: height * dpr, fileType: 'png', quality: 1, success: (res) => resolve(res.tempFilePath), fail: reject }); }); }

四个尺寸参数值得单独拎出来说。width/height是裁剪区域,按逻辑坐标填,和你在 canvas 上画的坐标是同一个坐标系;destWidth/destHeight是输出分辨率,乘以 dpr 才能拿到 2 倍或 3 倍图。只填前者不填后者,导出的就是 1 倍图,在手机上全屏看全是毛边,这就是"为什么开发者工具里很清楚、真机上很糊"的根本原因。

fileType选png还是jpg看场景。海报里有大量纯色块和文字,png 体积大但边缘干净;如果海报主体是一张照片,jpg 加quality: 0.9能把体积压下来,分享出去加载更快。我一般把导出宽度控制在 750 到 1125 之间,太大在低端安卓上容易内存吃紧直接崩溃,太小又不够清晰,这个区间是踩了很多次坑之后定下来的经验值。

4.4 相册授权与分享链路收尾

导出拿到临时路径,接下来是保存到相册和配置分享卡片。

// export.js —— 保存相册,处理用户拒绝授权的二次引导 async function saveToAlbum(filePath) { try { await wx.saveImageToPhotosAlbum({ filePath }); wx.showToast({ title: '已保存到相册', icon: 'success' }); } catch (err) { const msg = (err && err.errMsg) || ''; // 用户点过拒绝后,API 不会再弹授权框,必须引导去设置页 if (msg.indexOf('auth deny') > -1 || msg.indexOf('authorize') > -1) { const { confirm } = await wx.showModal({ title: '需要相册权限', content: '保存海报需要使用相册权限,是否前往开启?', confirmText: '去开启' }); if (confirm) wx.openSetting(); return; } if (msg.indexOf('cancel') > -1) return; // 用户主动取消,不打扰 wx.showToast({ title: '保存失败,请重试', icon: 'none' }); } }

授权这条链路的关键在于区分首次拒绝和永久拒绝。首次拒绝调wx.saveImageToPhotosAlbum会自己弹系统授权框,用户点了拒绝之后,再调就不会弹了,只会返回auth deny,这时候唯一的出口是wx.openSetting。不做这个分支,用户会以为按钮坏了,反复点没有任何反应。

分享链路上,onShareAppMessage的imageUrl建议单独生成一张 5:4 的分享图,而不是直接把海报塞进去。海报通常是 3:5 的长图,被裁剪成正方形后,标题和二维码刚好被切掉,分享出去的卡片就只剩一张商品图,信息全丢。分享图可以复用同一套绘制管线,只是换一份模板 JSON,这也是第 2 章分层带来的直接收益。

5. 避坑与常见问题:真机白屏、图糊、字体错乱与导出失败

这一章全是血泪经验。下面五条是我在不同项目里真真切切翻过的车,每条都按"现象、原因、解决"走一遍,遇到了直接对号入座。

5.1 开发者工具正常,真机 canvas 一片空白

现象:模拟器里海报清清楚楚,到了手机上导出的是纯白图或者纯底色,控制台没有任何报错。

原因:绝大多数是节点还没渲染完就去查了。SelectorQuery在页面onLoad里同步执行,此时 wxml 的 canvas 还没有真正挂载,res[0].node是null,代码走到getContext那步直接抛错,但如果你在 Promise 里吞了异常,表现就是静悄悄地什么都没画。第二种可能是 canvas 被display: none隐藏了,隐藏状态下部分机型拿到的节点尺寸是 0,位图缓冲区也就是 0,自然画不出东西。

解决:节点查询放到onReady之后,或者干脆在用户点击时再查,同时把查询结果缓存起来复用。需要隐藏 canvas 时用position: fixed; left: -9999px;移出屏幕,不要用display: none和visibility: hidden。查询失败加一次setTimeout重试,大部分首屏时序问题都能兜住。

5.2 导出的图模糊,字有锯齿

现象:海报在手机上打开看是糊的,字体边缘发虚,商品的纹理细节全丢了。

原因:三种可能,一是canvas.width/height没乘 dpr,位图缓冲区只有 1 倍分辨率;二是canvasToTempFilePath里destWidth/destHeight没给,同样输出 1 倍图;三是ctx.scale调了两次,第一次在初始化时调了,某个 painter 里又调了一次,结果整个画面被放大两倍,超出绘制区域的部分被裁掉,看起来就是"糊且缺一块"。

解决:把 dpr 只在initCanvas里处理一次,并且在整个绘制流程里不再碰scale/translate/rotate这些变换,需要局部变换的 painter 自己save/restore包住。导出时显式传destWidth = width * dpr。如果做了缩放还要额外注意:绘制坐标始终用逻辑像素写,不要混着用物理像素。

5.3 网络图第一次能显示,第二次就空了

现象:第一次生成海报,封面图正常;退出页面再进来生成,封面区域变成一片空白或者占位图。

原因:缓存池里存了 Image 对象,但页面销毁时 JS 上下文已经回收过一轮,Map里留下的引用指向了一个失效的 Image。第二次绘制时img.complete还是true,代码以为可以画,实际drawImage什么都没画出来,还不报错。另一种情况是临时文件路径过期,wx.getImageInfo返回的path在下次冷启动后可能已失效。

解决:缓存的生命周期跟着页面走,在onUnload里清空Map,或者把缓存挂在页面实例上而不是模块作用域。更稳的做法是缓存前校验一次img.width > 0,宽度为 0 就当作未加载重新走下载。上传到 CDN 的图片本身不快,但胜在稳定,别为了省一次请求去赌临时路径的存活时间。

5.4 iOS 和安卓上文字换行位置不一样

现象:同一个标题,iOS 上第一行放得下,安卓上就折行了,导致第二行的位置整体下移,压到下面的价格。

原因:measureText的测量结果依赖实际渲染字体,而不同系统对sans-serif的映射不同,iOS 映射到苹方,安卓映射到思源或系统默认字体,同一个字符串的宽度能差 10% 以上。另外fontWeight在部分安卓机型上会被忽略,bold根本没生效,宽度又变了一档。

解决:不要依赖系统默认字体族,尽量在ctx.font里写全font-weight font-size font-family三段,font-family用一个确定的内置字体名。如果对排版要求严格,可以用wx.loadFontFace加载一个自定义字体文件,加载完再开始绘制,这样两端渲染一致。另外maxLines尽量留一行余量,别把文案卡到极限宽度。

5.5 保存相册失败,或者连点几次直接崩溃

现象:点保存没反应,或者快速点三下之后小程序白屏退出,怀疑 OOM。

原因:没反应多半是授权被拒后没走openSetting分支,前面已经说过。崩溃则是重复绘制导致的:每次点击都初始化一次 canvas、导出一份临时文件,大尺寸位图在低端机上内存很快吃满。还有个隐蔽的点是canvasToTempFilePath的回调还没返回,用户就又触发了下一次导出,两次导出并发操作同一个 canvas 节点,行为未定义。

解决:点击入口加一把锁,isGenerating标志位在流程开始时置true、finally里置回false,锁期间按钮置灰。导出完成后如果不再需要这张图,主动把 canvas 尺寸置 0 释放位图内存。连续生成多张海报的场景(比如一次分享九宫格),一定要串行排队,不要并发。

6. 验收与答辩:用一份对照表证明海报功能真的没翻车

海报这种功能最难答辩的地方在于——它"看起来能跑",但你说不清它在多少机型上真的能跑。我一般会在交付前准备一份自检表,评审老师或者接手的人照着勾一遍,比口头说"我测过了"有说服力得多。

检查项判定方式合格标准
节点获取真机日志打印!!node三次冷启动均能拿到节点
清晰度导出图放大到 200% 看文字边缘无锯齿、无重影
换行一致iOS 与安卓对照同一长标题换行位置与行数一致
缺图降级断网后生成海报显示占位图,流程不中断
授权链路首次拒绝后再次保存弹出设置引导,能跳转
首屏耗时打点统计绘制与导出中端机合计不超过 1.5 秒
重复点击连点五次分享按钮只生成一张,无崩溃

耗时打点建议直接写进代码里,答辩时现场演示比截图有说服力:

// 在完整流程的首尾各打一次点,输出绘制与导出的分段耗时 const t0 = Date.now(); await render(ctx, template, data, resources); const t1 = Date.now(); const filePath = await exportPoster(canvas, width, height, dpr); const t2 = Date.now(); console.log(`[poster] 绘制 ${t1 - t0}ms,导出 ${t2 - t1}ms,合计 ${t2 - t0}ms`); // 建议阈值:绘制 < 600ms,导出 < 900ms,超出就去看是不是图片没走缓存

验收的时候按机型分层抽测,至少覆盖一台 iOS、一台中端安卓、一台低端安卓。低端机是试金石,位图内存、字体渲染、导出耗时的问题几乎都会在那里暴露。答辩被问到"你凭什么说这个方案稳定"时,把这张表加上三段耗时日志拿出来,比讲十分钟架构都有用。

最后说个我自己的习惯:从那以后我每次做海报类需求,都强制走一遍"先跑通一张最简模板(纯底色加一行字),再往模板里加图层"的流程,加一层测一层,绝不允许一次性把十几个图层全写完再联调。因为 canvas 的报错实在太安静了,错一个坐标它不会告诉你任何东西,只有把变量一个个隔离出来,才知道是哪一层出的问题。这套笨办法救过我很多次,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进