ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

小程序图片处理优化:OffscreenCanvas与Worker实现旋转转码不卡顿

小程序图片处理优化:OffscreenCanvas与Worker实现旋转转码不卡顿 上个月做一款相册美化类小程序测试同事丢过来一条反馈从系统相册里选九张照片点一下“统一旋转 90 度并转成 webp 上传”页面直接白屏十几秒iOS 上甚至会弹“无响应”。我第一反应是 setData 写得太猛查完才发现真正的元凶是主线程 Canvas 的同步编码。小程序里常见的图片处理姿势是canvas.toDataURL()或toTempFilePath()图片一大这些调用会阻塞整个 JS 线程九张图串行排队体验自然崩掉。当时排查到 OffscreenCanvas它的价值不在于“转码算法更快”而是把旋转与格式转换整个流程扔进 Worker 线程主线程只剩下一张图片完成后的回调体感从十几秒白屏变成全程流畅。这篇围绕这个方案把我在微信小程序里的完整实现和性能调优过程梳理一遍。方法上不挑框架原生小程序、Taro、uni-app 都可以参考。1. 先搞清楚图片本地处理的性能瓶颈到底在哪1.1 主线程 Canvas 的同步阻塞陷阱先说为什么主线程 canvas 会卡。小程序 JavaScript 是单线程的UI 渲染、事件回调、业务逻辑全在这个线程上排队。当我们拿到一张 4000×3000 的照片先在 canvas 上 rotate再调用toDataURL编码这一步内部要对几十 MB 的像素数据做压缩编码是典型的 CPU 密集运算单张耗时很容易跑到 800ms 到 1200ms。九张图的常规写法是 for 循环逐张处理每张还伴有 decode、drawImage、encode 三步总时间就奔着 10 秒去了。这期间滚动、点击、下拉刷新全被阻塞白屏不是 Canvas 节点没画出来而是整个页面事件循环被卡住。业务侧还会叠加一个问题wx.uploadFile依赖基础库内部队列如果主线程被占死上传回调也会延迟排查起来会误以为是网络问题。1.2 后端方案为什么补救不了体验有些团队会把旋转和格式转换放到服务端小程序只上传原始路径。这个方案对相册批量操作有两个硬伤。一个是延迟和流量。弱网环境一张原图 8MB上传加上服务端处理再回传单张 3 到 5 秒很正常九张就是半分钟用户早就退出页面了。另一个是隐私。现在的相册图片很多包含位置、人脸等敏感信息本地处理至少能少传一轮。出于这两个原因本地处理成了照片类小程序的刚需即便后续要上传也是本地转换压缩之后的小体积文件。1.3 本地处理的三种技术路线对比当时我列了一张对比表帮助自己做选型。方案是否阻塞 UI实现成本编码能力适用场景主线程 canvas是低toDataURL自带偶尔一两张小图OffscreenCanvas Worker否中toDataURL 第三方编码器批量、大图WebAssembly 编码器否高可定制 libjpeg 等极度追求吞吐WebAssembly 是后面可以考虑的方向工程成本偏高需要编译工具链还要处理 wasm 在小程序 Worker 里的加载路径。对大多数旋转和格式转换场景来说OffscreenCanvas 是投入产出比最好的一条路小程序原生支持把离屏 canvas 实例直接传给 Worker 线程。2. OffscreenCanvas 的架构为什么它能做到“极速”不卡2.1 离屏 Canvas 与 Worker 的组合逻辑OffscreenCanvas 字面意思是“在屏幕之外渲染的 Canvas”。平时页面里的 canvas 节点要绑定到 UI 上展示而离屏 Canvas 不参与展示只负责在后台完成像素绘制和编码。它最关键的 API 在于wx.createOffscreenCanvas返回的实例可以通过worker.postMessage传给 Worker 线程Worker 里拿到的是同一个 canvas 对象可以直接getContext(2d)。可以这样理解普通 canvas 像是大家都在客厅里做饭炒菜油烟弥漫整个空间OffscreenCanvas 就是把厨房搬到后厨客厅里的人只看到菜端出来。Worker 里的运算不占用 UI 线程自然也就不会造成白屏和卡顿。2.2 创建与传递的代码骨架主线程初始化部分// app.json 里需要声明 workers 目录 const worker wx.createWorker(workers/rotator/index.js) const offscreenCanvas wx.createOffscreenCanvas({ type: 2d, width: 100, height: 100 }) worker.postMessage({ msg: init, canvas: offscreenCanvas })Worker 侧let canvas let ctx worker.onMessage(({ data }) { if (data.msg init) { canvas data.canvas ctx canvas.getContext(2d) } })这里有个容易踩的细节createOffscreenCanvas的type: 2d不能漏。不传 type 或者传成webgl在部分安卓机上getContext(2d)会直接返回 null而且错误信息不明显排查起来特别磨人。2.3 主线程只传该传的任务消息设计Worker 通信过程中图片文件路径、旋转角度、输出格式、质量这些参数都很小postMessage 传 JSON 没有问题。真正需要注意的是不要在大体积 base64 上反复横跳。编码在 worker 里完成dataURL 回传主线程后主线程负责转成 ArrayBuffer 并写入本地文件。我实际使用的任务对象结构长这样const task { id: Date.now() Math.random().toString(16).slice(2), filePath: tempFilePath, // 相册选择后的临时路径 angle: 90, // 0 / 90 / 180 / 270 format: image/webp, quality: 0.8, maxSize: 1600 } worker.postMessage({ msg: convert, task })id字段必须带上。批量任务回传时主线程靠它找到对应的 Promise 回调避免结果对不上号。2.4 Worker 内的 FIFO 队列一个 Worker 在同一时刻只能执行一个任务。如果批量图片一次性并发下发worker 处理不过来消息积压之后主线程照样会被拖住。我在 worker 内部维护了一个 FIFO 队列任何时刻只处理一个任务处理完立即回传再取下一个。const taskQueue [] let processing false function nextTask() { if (processing || taskQueue.length 0) return processing true const task taskQueue.shift() handleTask(task) .then(result { worker.postMessage({ msg: done, id: task.id, result }) }) .catch(err { worker.postMessage({ msg: error, id: task.id, error: err.message }) }) .finally(() { processing false nextTask() }) } worker.onMessage(({ data }) { if (data.msg convert) { taskQueue.push(data.task) nextTask() } })主线程不需要排队逻辑所有图片任务直接 postMessage 进来由 worker 内部消化。这个协议干净主线程代码也好维护。3. 旋转与格式转换的完整实现从下发任务到拿回字节3.1 Worker 里如何拿到图片数据由于 Worker 环境不能稳定调用wx.getFileSystemManager最可靠的做法是主线程先读文件再把 ArrayBuffer 传给 workerconst fs wx.getFileSystemManager() fs.readFile({ filePath: task.filePath, success: res { worker.postMessage({ msg: convert, task, buffer: res.data }) } })worker 内把 ArrayBuffer 通过canvas.createImage()加载成可绘制的图像对象function loadImageFromBuffer(buffer) { return new Promise((resolve, reject) { const img canvas.createImage() img.onload () resolve(img) img.onerror err reject(err) img.src buffer }) }补充一句如果基础库版本对 ArrayBuffer 作为img.src支持不好可以退一步在主线程拿到临时文件路径后直接把filePath传给 workerWorker 内createImage().src filePath。两条路我都跑通过但 ArrayBuffer 方式少一次临时文件流转批量场景更稳。3.2 旋转方向与画布宽高的处理拿到img之后按照用户传入的角度旋转。这里的核心是画布宽高要先交换再通过平移和旋转把图像画到画布中心。function rotateImage(img, angle) { const rad angle * Math.PI / 180 const swap angle % 180 ! 0 const outW swap ? img.height : img.width const outH swap ? img.width : img.height ctx.clearRect(0, 0, canvas.width, canvas.height) canvas.width outW canvas.height outH ctx.save() ctx.translate(outW / 2, outH / 2) ctx.rotate(rad) ctx.drawImage(img, -img.width / 2, -img.height / 2) ctx.restore() }这个写法避开手动计算四角坐标逻辑最直接。需要特别注意的是旋转 90 度或 270 度时canvas 的 width 和 height 必须交换否则图像边缘会被裁掉一块。3.3 格式转换与质量参数的实际取舍canvas.toDataURL支持的 MIME 类型在不同平台有差异。实测下来iOS 端image/jpeg、image/png稳定image/webp在部分安卓机型上返回的却是data:image/png说明内核不支持或自动降级了。所以输出格式要做一个“声明格式 实际前缀检测”的双保险function encode(format, quality) { const dataURL canvas.toDataURL(format, quality) if (format image/webp dataURL.startsWith(data:image/png)) { // 内核不支持 webp 编码自动降级为 jpeg return canvas.toDataURL(image/jpeg, quality) } return dataURL }下面这组数据来自一张 4000×3000 的照片统一缩放至 1600 边长不同格式的体积差异非常明显。输出格式quality体积JPEG0.8约 220KBWebP0.8约 140KBPNG-约 4.2MBPNG 在照片类场景体积爆炸除非需要透明通道否则别用。这组数据说明 webp 收益明显所以我在代码里默认使用 webp检测到不支持再降级 jpeg。3.4 从 dataURL 写回本地文件worker 把 dataURL 回传后主线程做转换和落盘const base64 result.dataURL.split(,)[1] const buffer base64ToArrayBuffer(base64) const destPath ${wx.env.USER_DATA_PATH}/converted_${Date.now()}.jpg fs.writeFile({ filePath: destPath, data: buffer, encoding: binary, success: () { wx.uploadFile({ filePath: destPath, /* ... */ }) } })这里base64ToArrayBuffer需要自己实现网上常见版本是用atob或者手工拆字节小程序里没有原生atob我用的是循环配合字符编码转换建议直接封装成一个公共工具函数放起来。4. 实测调优把九图处理从 1200ms 压到 160ms 的四板斧4.1 测量先行worker 内 performance.now 埋点调优不能靠感觉必须先把耗时拆开。worker 里可以用performance.now()我在每个任务处理前埋下 t0在 decode、rotate、encode 三个阶段分别打点最后随结果一起回传const t0 performance.now() const img await loadImageFromBuffer(buffer) const tDecode performance.now() rotateImage(img, task.angle) const tRotate performance.now() const dataURL encode(task.format, task.quality) const tEncode performance.now() const result { dataURL, timings: { decode: Math.round(tDecode - t0), rotate: Math.round(tRotate - tDecode), encode: Math.round(tEncode - tRotate), total: Math.round(tEncode - t0) } }主线程收到result后把timings记录下来只用于对比分析。这一步非常关键没有数据支撑后面改什么都像盲人摸象。4.2 内存红线控制与最大输出尺寸先算一笔账4000×3000 的 RGBA 位图在内存里约 48MB转 JPEG 编码时还会有多个中间缓冲低端安卓机很容易直接闪退。我的策略是设置maxSize默认 1600绘制前先按比例缩放function computeTargetSize(img, angle, maxSize) { const rawW angle % 180 0 ? img.width : img.height const rawH angle % 180 0 ? img.height : img.width const scale Math.min(1, maxSize / Math.max(rawW, rawH)) return { width: Math.round(rawW * scale), height: Math.round(rawH * scale) } }这样无论用户原图多大实际参与编码的像素面积都受控内存峰值从 100MB 级降到 20MB 级。这是四个优化里收益最大的一项也是很多人容易忽略的一道安全阀。4.3 批量场景的分片调度九张图不要一次性全部塞进 worker。虽然 worker 内部有队列但如果主线程同时发九个 ArrayBuffer消息积压之后低端机依然会有卡顿。更稳妥的做法是主线程维护一个待处理数组每次只向 worker 发一个任务收到done消息后再取出下一个下发。let pendingTasks [] function enqueueTasks(tasks) { pendingTasks tasks dispatchNext() } function dispatchNext() { if (!pendingTasks.length) return const task pendingTasks.shift() fs.readFile({ filePath: task.filePath, success: res { worker.postMessage({ msg: convert, task, buffer: res.data }) } }) } worker.onMessage(({ data }) { if (data.msg done) { // 处理 result dispatchNext() } })这样既能保证 worker 侧单任务串行也能让主线程的消息风暴降为零。需要取消任务时直接从pendingTasks里按 id 删除即可。4.4 实测数据与体感变化最后放一组我在真机上采集到的数据机型是某安卓中端机原图 4000×3000。场景单张耗时九张体感内存峰值主线程 canvas 直转1200ms白屏约 10 秒120MBOffscreenCanvas worker 直转1100ms页面不卡等待 9 秒120MBworker maxSize 1600260ms等待 2.5 秒约 20MBworker maxSize 旋转优化160ms等待 1.5 秒约 20MB注意第一行和第二行耗时其实接近说明 OffscreenCanvas 本身不会让编码变快它解决的是“主线程被占死”的问题。加上maxSize后编码像素量大幅下降耗时才真正降下来。这两个收益方向要分开看待否则容易归因错误。5. 踩坑实录Worker 里拿不到 Canvas、隐藏不刷新与真机兼容5.1 Worker 里拿不到 Canvas 的排查链路常见报错是 Worker 中canvas.getContext返回 null或者 postMessage 后 worker 收到的 canvas 是 undefined。我的排查顺序是这样的检查 app.json 是否声明了workers: workers且目录真实存在检查基础库版本createOffscreenCanvas需要 2.16.1 以上确认createOffscreenCanvas传了type: 2d检查wx.createWorker路径是否带前缀路径错误时会静默失败。如果以上都对把 postMessage 的 canvas 和 msg 一起打印看 worker onMessage 拿到的 data 里是否真的含 canvas 字段。跨线程传递 canvas 依赖底层序列化支持真机上某些定制 ROM 会有问题此时退路是让 worker 自己调用wx.createOffscreenCanvas不过这条路在部分基础库也不稳最终预案仍然是降级回主线程处理。5.2 页面隐藏后不触发 update 回调这个坑比较隐蔽。一开始我通过onHide/onShow管理任务队列结果 onShow 之后任务没有自动恢复。后来发现页面里的 Canvas 节点在 hide 后小程序内核可能会暂停它的绘制回调导致等待 done 的 Promise 一直挂着。解决办法是在显示生命周期里不要发“继续”指令而是直接重新 postMessage 当前待处理任务列表并带上幂等 id另一种是把处理结果先落到临时文件onShow 时检查目标文件是否已存在存在就直接使用避免重复计算。这个经验同样适用于“滚动使图片离开视口”的场景。离屏 Canvas 虽然不依赖视口但如果你的实现里用 onScroll 驱动预览刷新滚动停止之前不要依赖 Canvas 的同步重绘。5.3 Android 低端机的 WebGL 与 Worker 数量限制OffscreenCanvas 传入 worker 后创建时 type 建议只用2d。WebGL 在低端机上创建上下文成本很高一旦失败还没有干净的降级方案。另外 Worker 实例数量在小程序里是有限的如果项目里已经有别的 worker 在跑需要评估是不是共用同一个图片处理 worker 更划算。再补充一个真机注意点开发者工具里的 OffscreenCanvas 行为和真机不完全一致工具里顺畅不代表真机顺畅性能数据一定要在真机上采集。我之前在工具里测出来单张 120ms换到安卓中端机直接翻倍这个差距必须提前预留。5.4 旋转结果出现“莫名 90 度”的 EXIF 问题相册选出来的照片很多带 EXIF Orientation 信息尤其是 iOS 拍摄的照片。canvas.drawImage不会自动扶正方向直接画就会出现歪 90 度的情况。处理链路里要先用wx.getImageInfo读取 orientation把基准方向校正后再叠加用户角度。function normalizeOrientation(orientation, userAngle) { if (orientation 6) userAngle 270 if (orientation 8) userAngle 90 if (orientation 3) userAngle 180 return ((userAngle % 360) 360) % 360 }注意角度要先 normalize 到 0 到 359 之间避免累加出 540 度之类的情况。这个校正逻辑放在所有旋转之前执行否则后续角度叠加会出现方向错乱。6. 扩展出去图片处理管线在 Worker 里继续生长6.1 从单一旋转到滤镜链一旦 worker 内的 offscreen canvas 稳定跑起来加滤镜就只是多几步绘制操作。原则是保持上下文不销毁一次任务只做一次 canvas 状态清理再把结果编码输出。比如“旋转 灰度 压缩”可以用同一个 ctx 串联主线程只发一个任务描述worker 按顺序执行 pipelineconst pipeline [ { name: rotate, value: 90 }, { name: grayscale, value: true }, { name: encode, value: { format: image/webp, quality: 0.8 } } ]这样做的好处是后续加新滤镜不需要改主线程通信协议只需要在 worker 里扩展一个处理函数扩展性会好很多。6.2 编码能力不足时的下一步当toDataURL的速度和压缩率不能满足要求时再考虑 WebAssembly 方案把 jpeg-js 或 libjpeg-turbo 编成 wasm 放进 worker替代 canvas 自带编码。这样格式不再受内核限制但工程成本会明显增加还需要处理 wasm 在小程序 worker 的加载路径。对绝大多数图片旋转与格式转换场景来说OffscreenCanvas 加toDataURL已经足够稳定。我在实际使用里体会最深的一点是不要把 OffscreenCanvas 想成性能银弹它的真正价值是“把阻塞从 UI 线程中拿走”。只要主线程保持流畅用户等待几秒钟是可以接受的再加上maxSize对输出尺寸的限制内存和耗时会一起降下来。后面如果你遇到真机上表现和开发者工具不一致别急着换方案先在基础库版本和type参数上调再不行就回退主线程 canvas但一定要加上maxSize控制。越接近真实用户环境越要保守。
返回列表