分享海报在不同手机上糊成一团:Canvas 2D 生成海报的 dpr 适配与保存授权实战

2026-09-26 01:16:08 1 次浏览
微信小程序Canvas分享海报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、保存相册授权

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。