上个月做一款相册美化类小程序,测试同事丢过来一条反馈:从系统相册里选九张照片,点一下“统一旋转 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 传给 worker:
const 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传给 worker,Worker 内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 | 体积 |
|---|---|---|
| JPEG | 0.8 | 约 220KB |
| WebP | 0.8 | 约 140KB |
| PNG | - | 约 4.2MB |
PNG 在照片类场景体积爆炸,除非需要透明通道否则别用。这组数据说明 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 秒 | 120MB+ |
| OffscreenCanvas worker 直转 | 1100ms | 页面不卡,等待 9 秒 | 120MB+ |
| worker + maxSize 1600 | 260ms | 等待 2.5 秒 | 约 20MB |
| worker + 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 按顺序执行 pipeline:
const 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控制。越接近真实用户环境,越要保守。