resource-cacheWorking Set 控制同时活跃多少个节点;LOD 控制每张图用多大规格;Resource Pool 控制这些资源合计最多占多少内存。三者缺一不可。
学习目标
- 区分压缩文件大小、解码内存和 GPU 成本;
- 理解 original、variant、decoded resource 的不同保留规则;
- 根据屏幕投影尺寸选择 LOD;
- 用 Lease + LRU + Hard Budget 管理共享资源;
- 解释预算不足时为什么必须拒绝,而不是驱逐正在使用的资源。
1. 5 MB 图片为什么可能占 92 MiB
JPEG/WebP 的文件大小是压缩后体积。解码后的常用估算:
decodedBytes ≈ width × height × 4
6000 × 4000:
6000 × 4000 × 4 = 96,000,000 bytes ≈ 91.6 MiB
这还未计算 Canvas 中间缓存、GPU texture 和多份缩略图。一张“只有 5 MB”的图片可能已经突破移动设备预算,所以不能只看网络包大小。
2. 四层资源,四种保留规则
| 层次 | 例子 | 普通 LRU 可直接淘汰 |
|---|---|---|
| Document 引用 | assetId |
否,作品真相 |
| AssetStore original | 原始 Blob | 被作品引用时通常否 |
| 派生 variant | 256/1024/2048 WebP | 是,可重建 |
| 运行时资源 | ImageBitmap、texture、player | 是,但活跃 lease 期间否 |
Document 不保存 Blob 或 ImageBitmap。一个素材可以被多个节点引用,因此“一个节点离屏”不代表共享资源可以立即释放。
3. LOD 根据实际投影尺寸选图
projectedPx = max(nodeWorldWidth, nodeWorldHeight) * zoom * dpr
世界尺寸 400 × 300、DPR=2:
| zoom | 最长边投影 | 可选档位 |
|---|---|---|
| 0.25 | 200 px | 256 |
| 1 | 800 px | 1024 |
| 4 | 3200 px | 4096 或 original |
选择“能覆盖投影尺寸的最小档位”。仅把 Konva Image 显示宽度改成 200px,不等于使用缩略图;如果 source 仍是 6000px 原图,浏览器仍可能解码完整像素。
4. LOD 从哪里来
用户通常只导入一张原图。派生档位在导入或后台低优先级任务中生成:
Original Blob
→ createImageBitmap
→ OffscreenCanvas 缩放
→ convertToBlob(WebP)
→ 256 / 1024 / 2048 variants
→ AssetStore
生成 LOD 不能放进 Camera/zoom 热路径。缩放只选择已有档位;缺少档位时显示当前资源并排队后台生成。
5. 为什么切换档位需要滞回
假设 1024 与 2048 的边界是投影 1024px。用户在 1010~1030px 间缩放时,立即切换会不断解码。
升级:需求明显超过当前档位,例如 > 1.2 × current
降级:需求明显低于下级档位,例如 < 0.7 × current
还可以 debounce 连续 zoom,在缩放稳定后再降档;导出时绕过编辑 LOD,显式请求原图。
6. Lease 表达“谁正在使用”
const lease = await pool.acquire('asset-A:1024', loader)
renderer.hydrate(node, lease.value)
// node 退出且 handle 销毁后
lease.release()
同一 key 被多个节点 acquire 时,Pool 复用资源并增加引用。引用归零后资源不会必须立刻销毁,而是进入 LRU 候选;这样短时间重访可以复用。
资源状态可以理解为:
loading → leased → unleased/LRU → evicted
↑ │
└──────────┘ re-acquire
并发 acquire 同一个 key 应共享一个 in-flight Promise,避免重复解码。
7. 硬预算算法
请求新资源前:
预计 usedBytes + incomingBytes
≤ hardBudget:允许
> hardBudget:先按 LRU 驱逐无 lease 资源
仍超预算:拒绝新请求
不能为了让新资源进入而驱逐活跃 lease,否则 Renderer 仍持有失效对象。拒绝不是系统失败,而是正确的背压:调用方可以降级到更低 LOD、占位图或稍后重试。
除了字节预算,还应限制对象数和特殊资源:
decodedBytes <= 64 MiB
decodedObjects <= 128
activePlayers <= 2
inFlightDecodes <= 4
数值由宿主和设备策略决定,框架只提供机制与观测。
8. 淘汰与释放顺序
节点退出 Working Set
→ Renderer handle 销毁并解除 image 引用
→ release lease
→ 资源变为可驱逐
→ LRU 超预算时淘汰
→ ImageBitmap.close / revokeObjectURL / texture dispose
原始 Blob 仍在 AssetStore;下一次进入可以重新加载。若是派生 LOD,磁盘空间不足时也可删除后重建,但 original 的 GC 必须检查 Document、History、Checkpoint 和共享引用。
9. 操作 Demo
Demo 硬预算为 12 MiB:
- 获取 A=4 MiB、B=6 MiB,并保持两个 lease;
- 请求 C=8 MiB,观察即使有 LRU 算法也必须拒绝;
- 释放 A,A 进入可驱逐状态;
- 再请求 C,观察 Pool 先驱逐 A;
- 同时两次获取 B,验证只存在一份资源但 leaseCount 增加;
- 全部 release 后执行 trim,观察 bytes 和 object count 下降。
10. 验收指标
decodedBytes 永不突破 hardBudget
活跃 lease 不被 LRU 驱逐
同 key 并发加载只执行一次 loader
资源淘汰后可由 AssetStore 重建
LOD 与屏幕投影匹配,不长期使用超大原图
播放器、decode 并发和对象数都有单独上限
常见错误
- 用压缩文件大小代替解码预算;
- 只改变显示尺寸,实际仍解码原图;
- 一个节点退出就销毁多人共享的资源;
release()后立刻销毁,失去短时复用;- 为满足新请求驱逐活跃 lease;
- 在 zoom 事件里生成新 LOD;
- History 删除节点时立即永久删除 original,导致 Undo 无法恢复。
课后实验
增加 256/1024/2048 三档资源,根据 zoom 自动选择。记录连续缩放时的 decode 次数;加入滞回和 debounce 后再次比较。目标不是只减少内存,还要减少反复升降档的抖动。