
分享海报在不同手机上糊成一团Canvas 2D 生成海报的 dpr 适配与保存授权实战适用读者正在给微信小程序做分享海报功能的开发者被「iOS 正常、安卓发糊」折磨过的前端同学想一次搞清楚 dpr 适配、图片下载、保存相册授权完整链路的人。海报功能 9 月 12 日上线14 号测试群里就炸了。测试同学老周甩过来三张截图同一张活动海报iPhone 上清清楚楚一台 2020 年的安卓千元机上标题边缘全是锯齿底部二维码扫半天都出不来。他原话是「糊得跟打了马赛克一样已经有两个用户在投诉了。」排查下来根因不复杂画布按逻辑像素建导出却按物理像素走中间差了一个 dprdevicePixelRatio设备像素比。这篇把排查过程、改造后的完整封装代码、保存相册授权的分支处理一次写全都是踩过坑之后的版本可以直接抄。排查那一晚位图天生就小先讲我们是怎么把问题钉死的这个定位思路比结论更值钱。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。dpr2 的设备1 个逻辑像素对应 2×2 个物理像素dpr3 对应 3×3。常见机型大致是iPhone 标准款和多数安卓千元机 dpr2Pro 系列 iPad 和安卓旗舰在 3 上下。模糊的链条是这样的你按 340 逻辑像素设了canvas.width 340位图就只有 340 个像素点的宽度页面内显示时按 CSS 尺寸渲染问题不大但导出到相册后系统相册按物理像素铺满屏幕展示340px 的图被拉伸两三倍文字边缘和二维码细线全部糊掉。平台差异还在火上浇油。iOS 的图像缩放算法偏平滑轻度放大后肉眼不太察觉安卓低端机的缩放实现简单粗暴锯齿直接裸奔。同样的代码iPhone 验收一切正常一上安卓就翻车这就是老周截图里只有安卓机糊的原因。旧做法 width340正确做法 width340×dpr设计稿 340×480 逻辑像素画布位图怎么建位图只有 340px 宽导出后系统放大到物理像素文字发虚 二维码扫不出位图 680 或 1020px 宽导出即物理像素 1:1 展示全机型清晰结论一句话画布按物理像素建绘制坐标系乘 dpr导出 destWidth/destHeight 直接取画布 width/height三步缺一不可。动手改造按物理像素重建画布环境先交代清楚Canvas 2D 接口要求基础库 2.9.0 以上本文代码在基础库 3.x 上验证过。WXML 里放一个type2d的 canvas 组件CSS 尺寸保持设计稿的逻辑像素位图尺寸在 JS 里乘 dpr。// 取窗口信息正确 API 是 wx.getWindowInfo()// 网上有些文章写成 wx.getWindowField那是笔误照抄必报错constinfowx.getWindowInfo()// pixelRatio 就是 dpriPhone 标准款常见 2Pro 系列和安卓旗舰常见 3constdprinfo.pixelRatio||2// 设计稿上的海报尺寸是 340 × 480 逻辑像素constLOGIC_W340constLOGIC_H480// 画布位图尺寸 逻辑尺寸 × dpr这一步决定清晰度上限canvas.widthLOGIC_W*dpr canvas.heightLOGIC_H*dpr// 注意 WXML 里 canvas 的 style 尺寸保持 340px × 480px 不动// 位图尺寸管清晰度CSS 尺寸管页面布局两个别混着改// 拿到 2d 上下文后把坐标系整体缩放 dpr// 这样后面所有绘制代码都按设计稿逻辑像素写不用处处乘 dprctx.scale(dpr,dpr)用ctx.scale(dpr, dpr)有个取舍。好处是绘制代码和设计稿一一对应字号写 16 就是 16心智负担小代价是极少数低版本安卓机上缩放后的线条抗锯齿表现一般。我们实测下来低端机肉眼已分辨不出差异可读性收益更大就定了这个方案。如果你对线条锐度要求更高也可以不用 scale改成所有坐标字号手动乘 dpr效果等价代码丑一些。网络图片要先落本地画图之前先说图片这步偷懒会在真机翻车。小程序 canvas 绘制网络图要求图片域名配置进 downloadFile 合法域名白名单而且这个校验只在真机生效开发者工具默认不拦很多人本地调通了上线就挂。我们的做法是所有海报素材先走wx.downloadFile拿临时路径再绘制顺便把下载失败的重试也收在这一层。二维码这类第三方的图床域名同样要加白名单别漏。完整的海报生成封装下面是生产环境在用的封装拆成绘制和导出两段。/** * 海报绘制模块下载图片、圆角卡片、二维码裁剪 * 依赖基础库 2.9.0图片域名需提前加入 downloadFile 白名单 */// 把网络图下载到本地返回临时路径functiondownloadImage(url){returnnewPromise((resolve,reject){// 先落地再绘制真机上直接画网络图行为不稳定wx.downloadFile({url,success:(res){// 非 200 一律当失败别把错误页截图画进海报if(res.statusCode!200){reject(newError(download fail: res.statusCode))return}resolve(res.tempFilePath)},fail:reject})})}// 拼一段圆角矩形路径二维码卡片和头像裁剪都靠它functionroundRectPath(ctx,x,y,w,h,r){// 进出配对 save/restore防止路径污染后续绘制ctx.save()ctx.beginPath()// 用 arcTo 拼圆角比 roundRect 属性的兼容性好ctx.moveTo(xr,y)ctx.arcTo(xw,y,xw,yh,r)ctx.arcTo(xw,yh,x,yh,r)ctx.arcTo(x,yh,x,y,r)ctx.arcTo(x,y,xw,y,r)ctx.closePath()}// 二维码外面套白底圆角卡片直接贴图会顶到海报边缘asyncfunctiondrawQrWithCard(ctx,qrPath,x,y,size){// 卡片四边各留 12 逻辑像素内边距视觉上透气constpad12roundRectPath(ctx,x,y,sizepad*2,sizepad*2,8)// 先设填充色再 fill顺序反了白底出不来ctx.fillStyle#ffffffctx.fill()// 裁剪到圆角路径内再贴二维码四角不会戳出卡片ctx.clip()ctx.drawImage(qrPath,xpad,ypad,size,size)ctx.restore()}/** * 导出与主流程canvasToTempFilePath loading 蒙层 * 注意新接口传 canvas 节点对象不再收 canvasId 字符串 */// 导出海报为本地临时文件functionexportPoster(canvas){returnnewPromise((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})})}// 主流程下载、绘制、导出一条龙全程挂 loadingasyncfunctiongeneratePoster(canvas,data){constdprwx.getWindowInfo().pixelRatio||2// 低端机绘制加导出要一秒多不挂蒙层用户会连点好几次wx.showLoading({title:海报生成中,mask:true})try{// 背景图和二维码并行下载比串行快一截const[bgPath,qrPath]awaitPromise.all([downloadImage(data.bgUrl),downloadImage(data.qrUrl)])// 坐标和字号全部按设计稿逻辑像素写scale 已处理放大constctxcanvas.getContext(2d)ctx.clearRect(0,0,340,480)ctx.drawImage(bgPath,0,0,340,480)awaitdrawQrWithCard(ctx,qrPath,110,320,120)// 标题按设计稿 16px 写绘制引擎内部会乘 dprctx.font16px sans-serifctx.fillStyle#333333ctx.fillText(data.title,20,60)returnawaitexportPoster(canvas)}finally{// 成败都要收蒙层不然用户卡在转圈页wx.hideLoading()}}否是用户点生成海报showLoading 蒙层并行下载背景图与二维码下载都成功?toast 提示重试按物理像素建画布并 scale dpr绘制背景 文字 圆角二维码canvasToTempFilePath 1:1 导出hideLoading 进入保存流程保存相册授权被拒后的二次引导海报生成完只算成功一半保存到相册才是用户的终点动作。wx.saveImageToPhotosAlbum要授权scope.writePhotosAlbum这个环节的分支处理直接决定保存转化率。/** * 保存模块覆盖已授权、首次弹窗、曾被拒绝三种分支 * 分支依据fail 回调里 errMsg 是否含 auth deny */// 保存海报到相册functionsavePoster(tempFilePath){wx.saveImageToPhotosAlbum({filePath:tempFilePath,success:(){// 只在成功时提示别把失败也弹成保存成功wx.showToast({title:已存入相册,icon:success})},fail:(err){// 拒绝授权时 errMsg 带 auth deny 字样以此分流if(err.errMsg.indexOf(auth deny)-1){// 走二次引导别直接弹 openSettinghandleAuthDenied(tempFilePath)}else{// iOS 存储空间不足等场景落在这里wx.showToast({title:保存失败请重试,icon:none})}}})}// 被拒后的二次引导先解释缘由再让用户点去设置functionhandleAuthDenied(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已授权直接保存成功成功 toastiOS 空间不足等fail 但非授权错误普通错误提示可重试上线后的机型对比改造完成后拿四台机器做回归数据贴出来。测试机型dpr改造前表现改造后表现iPhone 133清晰清晰无明显退化iPad 第九代2轻微发虚边缘锐利安卓旗舰3文字发虚与 iOS 基本一致安卓千元机2锯齿明显二维码失效文字清晰二维码可扫安卓千元机那张「二维码可扫」的截图发到群里老周回了句「总算能交差了」。上线后第一个月海报保存成功率整体涨了两成出头客服那边再没收到过「二维码扫不出」的反馈。设计师小鹿后来追加深色模式的海报模板因为绘制逻辑都封装好了加模板只花了半天。误区澄清或趋势预判几个高频误区顺便澄清。有人以为把 destWidth 放大四倍就能救模糊前面提过画布位图本身小导出放大只是把糊图拉大锯齿一个不少清晰度必须在绘制阶段解决。还有人在 WXML 里把 canvas 的 style 尺寸也乘了 dpr海报在页面上显示成两三倍大记住位图尺寸管清晰度、CSS 尺寸管布局。二维码识别对分辨率格外敏感模糊容忍度远低于文字调试时拿二维码当清晰度标尺最灵。趋势上 Canvas 2D 已是官方主推旧接口只是兼容存量新特性只在 2D 接口上出现新项目没有理由再选wx.createCanvasContext。如果你的海报元素特别多、低端机绘制超时可以再研究离屏绘制和分帧这块有更好玩的做法欢迎评论区交流。参考与延伸canvas 组件文档含 Canvas 2D 用法Canvas 节点 API 参考wx.saveImageToPhotosAlbum 官方文档微信小程序开发、Canvas 2D、分享海报、dpr 适配、wx.canvasToTempFilePath、保存相册授权