分享海报在不同手机上糊成一团:Canvas 2D 生成海报的 dpr 适配与保存授权实战
适用读者:正在给微信小程序做分享海报功能的开发者;被「iOS 正常、安卓发糊」折磨过的前端同学;想一次搞清楚 dpr 适配、图片下载、保存相册授权完整链路的人。
海报功能 9 月 12 日上线,14 号测试群里就炸了。测试同学老周甩过来三张截图:同一张活动海报,iPhone 上清清楚楚,一台 2020 年的安卓千元机上标题边缘全是锯齿,底部二维码扫半天都出不来。他原话是:「糊得跟打了马赛克一样,已经有两个用户在投诉了。」排查下来根因不复杂:画布按逻辑像素建,导出却按物理像素走,中间差了一个 dpr(devicePixelRatio,设备像素比)。这篇把排查过程、改造后的完整封装代码、保存相册授权的分支处理一次写全,都是踩过坑之后的版本,可以直接抄。
排查那一晚:位图天生就小
先讲我们是怎么把问题钉死的,这个定位思路比结论更值钱。9 月 13 日晚上,我和老周用真机调试复现:海报生成后先调 wx.getImageInfo 打印临时文件的宽高,输出是 340×480;再看手机参数,那台安卓机的屏幕物理宽度是 1080 像素,dpr 是 2,实际可用逻辑宽度 360。也就是说,一张 340px 宽的位图,最后要被拉伸铺满接近 1080 物理像素的宽度,像素被放大三倍多,文字不发虚才怪。

当时第一反应是导出参数没给对,试着把 wx.canvasToTempFilePath 的 destWidth 硬放大四倍——结果锯齿一个没少,只是糊得更均匀了。这才意识到问题不在导出,而在画布本身的位图尺寸。设计师小鹿看完对比图说了句:「这相当于我把高清稿画好了,你们拿去复印了三手。」话糙理不糙,绘制阶段的分辨率上限,决定了后面所有环节的天花板。
先分清新旧两套接口
小程序的 canvas 有两套 API,很多教程还在讲旧的那套,但两者的底层渲染机制不同,直接决定 dpr 适配的做法。
| 对比项 | 旧接口 wx.createCanvasContext | 新接口 Canvas 2D |
|---|---|---|
| 获取上下文 | 调 API 直接返回 | SelectorQuery 拿节点再 getContext |
| 绘制模型 | 异步指令队列,draw 时统一提交 | 同步调用,即时上屏 |
| 导出方式 | wx.canvasToTempFilePath 传 canvasId | 同名 API 改传 canvas 节点 |
| 自定义位图尺寸 | 不支持 | width/height 随便设 |
| 官方维护状态 | 已停止更新 | 持续迭代 |
旧接口的画布位图尺寸跟组件尺寸绑死,你没法独立控制位图分辨率,dpr 适配基本无从下手。新接口把「组件显示尺寸」和「位图尺寸」拆开了:WXML 里的 style 管显示大小,canvas 节点的 width/height 属性管位图大小,两者可以不一致。这个拆分正是整个适配方案的地基。所以老项目如果要做海报,建议直接迁到 Canvas 2D,别在旧接口上继续打补丁。
模糊的原理:逻辑像素与物理像素差了一截
这节是原理剖析,看懂了后面代码全是水到渠成的事。
小程序 WXML 里写的 px 是逻辑像素(CSS 像素),手机屏幕实际由物理像素点阵组成,两者的比值就是 dpr。dpr=2 的设备,1 个逻辑像素对应 2×2 个物理像素;dpr=3 对应 3×3。常见机型大致是:iPhone 标准款和多数安卓千元机 dpr=2,Pro 系列 iPad 和安卓旗舰在 3 上下。
模糊的链条是这样的:你按 340 逻辑像素设了 canvas.width = 340,位图就只有 340 个像素点的宽度;页面内显示时按 CSS 尺寸渲染问题不大,但导出到相册后,系统相册按物理像素铺满屏幕展示,340px 的图被拉伸两三倍,文字边缘和二维码细线全部糊掉。
平台差异还在火上浇油。iOS 的图像缩放算法偏平滑,轻度放大后肉眼不太察觉;安卓低端机的缩放实现简单粗暴,锯齿直接裸奔。同样的代码,iPhone 验收一切正常,一上安卓就翻车,这就是老周截图里只有安卓机糊的原因。
flowchart LR
A[设计稿 340×480 逻辑像素] --> B{画布位图怎么建}
B -->|旧做法 width=340| C[位图只有 340px 宽]
C --> D[导出后系统放大到物理像素]
D --> E[文字发虚 二维码扫不出]
B -->|正确做法 width=340×dpr| F[位图 680 或 1020px 宽]
F --> G[导出即物理像素 1:1 展示]
G --> H[全机型清晰]
结论一句话:画布按物理像素建,绘制坐标系乘 dpr,导出 destWidth/destHeight 直接取画布 width/height,三步缺一不可。
动手改造:按物理像素重建画布
环境先交代清楚:Canvas 2D 接口要求基础库 2.9.0 以上,本文代码在基础库 3.x 上验证过。WXML 里放一个 type="2d" 的 canvas 组件,CSS 尺寸保持设计稿的逻辑像素,位图尺寸在 JS 里乘 dpr。
// 取窗口信息,正确 API 是 wx.getWindowInfo()
// 网上有些文章写成 wx.getWindowField,那是笔误,照抄必报错
const info = wx.getWindowInfo()
// pixelRatio 就是 dpr:iPhone 标准款常见 2,Pro 系列和安卓旗舰常见 3
const dpr = info.pixelRatio || 2
// 设计稿上的海报尺寸是 340 × 480 逻辑像素
const LOGIC_W = 340
const LOGIC_H = 480
// 画布位图尺寸 = 逻辑尺寸 × dpr,这一步决定清晰度上限
canvas.width = LOGIC_W * dpr
canvas.height = LOGIC_H * dpr
// 注意 WXML 里 canvas 的 style 尺寸保持 340px × 480px 不动
// 位图尺寸管清晰度,CSS 尺寸管页面布局,两个别混着改
// 拿到 2d 上下文后把坐标系整体缩放 dpr
// 这样后面所有绘制代码都按设计稿逻辑像素写,不用处处乘 dpr
ctx.scale(dpr, dpr)
用 ctx.scale(dpr, dpr) 有个取舍。好处是绘制代码和设计稿一一对应,字号写 16 就是 16,心智负担小;代价是极少数低版本安卓机上缩放后的线条抗锯齿表现一般。我们实测下来低端机肉眼已分辨不出差异,可读性收益更大,就定了这个方案。如果你对线条锐度要求更高,也可以不用 scale,改成所有坐标字号手动乘 dpr,效果等价,代码丑一些。
网络图片要先落本地
画图之前先说图片,这步偷懒会在真机翻车。小程序 canvas 绘制网络图,要求图片域名配置进 downloadFile 合法域名白名单,而且这个校验只在真机生效,开发者工具默认不拦,很多人本地调通了上线就挂。我们的做法是所有海报素材先走 wx.downloadFile 拿临时路径再绘制,顺便把下载失败的重试也收在这一层。二维码这类第三方的图床域名同样要加白名单,别漏。
完整的海报生成封装
下面是生产环境在用的封装,拆成绘制和导出两段。
/**
* 海报绘制模块:下载图片、圆角卡片、二维码裁剪
* 依赖:基础库 >= 2.9.0;图片域名需提前加入 downloadFile 白名单
*/
// 把网络图下载到本地,返回临时路径
function downloadImage(url) {
return new Promise((resolve, reject) => {
// 先落地再绘制,真机上直接画网络图行为不稳定
wx.downloadFile({
url,
success: (res) => {
// 非 200 一律当失败,别把错误页截图画进海报
if (res.statusCode !== 200) {
reject(new Error('download fail: ' + res.statusCode))
return
}
resolve(res.tempFilePath)
},
fail: reject
})
})
}
// 拼一段圆角矩形路径,二维码卡片和头像裁剪都靠它
function roundRectPath(ctx, x, y, w, h, r) {
// 进出配对 save/restore,防止路径污染后续绘制
ctx.save()
ctx.beginPath()
// 用 arcTo 拼圆角,比 roundRect 属性的兼容性好
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
// 二维码外面套白底圆角卡片,直接贴图会顶到海报边缘
async function drawQrWithCard(ctx, qrPath, x, y, size) {
// 卡片四边各留 12 逻辑像素内边距,视觉上透气
const pad = 12
roundRectPath(ctx, x, y, size + pad * 2, size + pad * 2, 8)
// 先设填充色再 fill,顺序反了白底出不来
ctx.fillStyle = '#ffffff'
ctx.fill()
// 裁剪到圆角路径内再贴二维码,四角不会戳出卡片
ctx.clip()
ctx.drawImage(qrPath, x + pad, y + pad, size, size)
ctx.restore()
}
/**
* 导出与主流程:canvasToTempFilePath + loading 蒙层
* 注意新接口传 canvas 节点对象,不再收 canvasId 字符串
*/
// 导出海报为本地临时文件
function exportPoster(canvas) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
// destWidth/destHeight 是导出位图的真实像素
// 画布已按物理像素建,这里直接取画布宽高,1:1 导出
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
quality: 1,
success: (res) => resolve(res.tempFilePath),
fail: reject
})
})
}
// 主流程:下载、绘制、导出一条龙,全程挂 loading
async function generatePoster(canvas, data) {
const dpr = wx.getWindowInfo().pixelRatio || 2
// 低端机绘制加导出要一秒多,不挂蒙层用户会连点好几次
wx.showLoading({ title: '海报生成中', mask: true })
try {
// 背景图和二维码并行下载,比串行快一截
const [bgPath, qrPath] = await Promise.all([
downloadImage(data.bgUrl),
downloadImage(data.qrUrl)
])
// 坐标和字号全部按设计稿逻辑像素写,scale 已处理放大
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, 340, 480)
ctx.drawImage(bgPath, 0, 0, 340, 480)
await drawQrWithCard(ctx, qrPath, 110, 320, 120)
// 标题按设计稿 16px 写,绘制引擎内部会乘 dpr
ctx.font = '16px sans-serif'
ctx.fillStyle = '#333333'
ctx.fillText(data.title, 20, 60)
return await exportPoster(canvas)
} finally {
// 成败都要收蒙层,不然用户卡在转圈页
wx.hideLoading()
}
}
flowchart TD
A[用户点生成海报] --> B[showLoading 蒙层]
B --> C[并行下载背景图与二维码]
C --> D{下载都成功?}
D -->|否| E[toast 提示重试]
D -->|是| F[按物理像素建画布并 scale dpr]
F --> G[绘制背景 文字 圆角二维码]
G --> H[canvasToTempFilePath 1:1 导出]
H --> I[hideLoading 进入保存流程]
保存相册:授权被拒后的二次引导
海报生成完只算成功一半,保存到相册才是用户的终点动作。wx.saveImageToPhotosAlbum 要授权 scope.writePhotosAlbum,这个环节的分支处理直接决定保存转化率。
/**
* 保存模块:覆盖已授权、首次弹窗、曾被拒绝三种分支
* 分支依据:fail 回调里 errMsg 是否含 auth deny
*/
// 保存海报到相册
function savePoster(tempFilePath) {
wx.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: () => {
// 只在成功时提示,别把失败也弹成保存成功
wx.showToast({ title: '已存入相册', icon: 'success' })
},
fail: (err) => {
// 拒绝授权时 errMsg 带 auth deny 字样,以此分流
if (err.errMsg.indexOf('auth deny') > -1) {
// 走二次引导,别直接弹 openSetting
handleAuthDenied(tempFilePath)
} else {
// iOS 存储空间不足等场景落在这里
wx.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
})
}
// 被拒后的二次引导:先解释缘由,再让用户点去设置
function handleAuthDenied(tempFilePath) {
wx.showModal({
title: '需要相册权限',
content: '开启相册权限后,才能把海报保存到手机相册',
confirmText: '去设置',
success: (res) => {
if (!res.confirm) return
// openSetting 必须由用户点击触发,不能代码里静默调
wx.openSetting({
success: (setting) => {
// 用户从设置页回来后复查授权结果
if (setting.authSetting['scope.writePhotosAlbum']) {
// 开了就立刻重试保存,别让用户再点一遍
savePoster(tempFilePath)
}
}
})
}
})
}
三个实操细节。openSetting 只能由用户点击行为触发,所以引导必须包在 modal 里让用户自己点。引导时机更讲究:我们最初是拒绝后立刻弹窗,用户还在气头上,点「去设置」的不到一成;改成被拒后先轻提示一句「未获得相册权限,可在设置中开启」,等用户再次主动点保存时才弹 modal 解释缘由,从被拒到进设置页的引导成功率从 12% 提到了 58%,改动只有时机和文案两处。还有一点,微信会记住用户的拒绝选择,之后系统弹窗不再出现,所以曾被拒的用户只能靠 openSetting 这条路救回来,这个分支不是可选项,是必答题。
| 用户授权状态 | 系统行为 | 应对策略 |
|---|---|---|
| 从未授权 | 自动弹授权窗 | 直接调保存 API |
| 曾点过拒绝 | 静默失败不再弹窗 | 轻提示加 modal 引导 openSetting |
| 已授权 | 直接保存成功 | 成功 toast |
| iOS 空间不足等 | fail 但非授权错误 | 普通错误提示可重试 |
上线后的机型对比
改造完成后拿四台机器做回归,数据贴出来。
| 测试机型 | dpr | 改造前表现 | 改造后表现 |
|---|---|---|---|
| iPhone 13 | 3 | 清晰 | 清晰,无明显退化 |
| iPad 第九代 | 2 | 轻微发虚 | 边缘锐利 |
| 安卓旗舰 | 3 | 文字发虚 | 与 iOS 基本一致 |
| 安卓千元机 | 2 | 锯齿明显,二维码失效 | 文字清晰,二维码可扫 |
安卓千元机那张「二维码可扫」的截图发到群里,老周回了句「总算能交差了」。上线后第一个月,海报保存成功率整体涨了两成出头,客服那边再没收到过「二维码扫不出」的反馈。设计师小鹿后来追加深色模式的海报模板,因为绘制逻辑都封装好了,加模板只花了半天。
误区澄清或趋势预判
几个高频误区顺便澄清。有人以为把 destWidth 放大四倍就能救模糊,前面提过,画布位图本身小,导出放大只是把糊图拉大,锯齿一个不少,清晰度必须在绘制阶段解决。还有人在 WXML 里把 canvas 的 style 尺寸也乘了 dpr,海报在页面上显示成两三倍大,记住位图尺寸管清晰度、CSS 尺寸管布局。二维码识别对分辨率格外敏感,模糊容忍度远低于文字,调试时拿二维码当清晰度标尺最灵。趋势上 Canvas 2D 已是官方主推,旧接口只是兼容存量,新特性只在 2D 接口上出现,新项目没有理由再选 wx.createCanvasContext。如果你的海报元素特别多、低端机绘制超时,可以再研究离屏绘制和分帧,这块有更好玩的做法欢迎评论区交流。
参考与延伸
微信小程序开发、Canvas 2D、分享海报、dpr 适配、wx.canvasToTempFilePath、保存相册授权