English | 简体中文
无限画布的缓存问题通常不是“文件有没有保存”,而是同一时刻有多少图片被解码、多少纹理
在 GPU、多少视频播放器仍挂在 DOM。ResourcePool 管理的是这类可回收的运行时资源。
1. 四层资源不要混为一谈
| 层 | 示例 | 是否应持久化 | 主要预算 |
|---|---|---|---|
| Asset identity | assetId、校验和、元数据 |
是 | 元数据条数 |
| Encoded blob | JPEG/WebP/MP4 原文件 | 是或服务端保存 | 存储/网络字节 |
| LOD variant | 256/1024/4096 宽度图片 | 可重新生成 | 存储/网络字节 |
| Decoded runtime | ImageBitmap、纹理、播放器 |
否 | RAM/GPU/解码器 |
一张 3840 × 2160 的 RGBA 位图,仅像素就约为:
3840 * 2160 * 4 = 33,177,600 bytes ≈ 31.6 MiB
JPEG 文件只有 2 MiB,并不意味着解码后仍是 2 MiB。10 张 4K 图同时驻留就可能超过
300 MiB,还没有计算浏览器副本和 GPU 纹理。因此 Pool 的 bytes 应估算解码/运行时成本,
不能直接填响应 Content-Length。
2. ResourcePool 的状态机
stateDiagram-v2
[*] --> Missing
Missing --> Loading: acquire(key)
Loading --> Loading: concurrent acquire / deduplicated
Loading --> Leased: load fits budget
Loading --> Missing: rejected / loader fails
Leased --> Idle: all leases release
Idle --> Leased: acquire cache hit
Idle --> Missing: LRU eviction + dispose
Leased --> Missing: pool.destroy + dispose
创建 Pool 时预算必须是正的 safe integer:
import { ResourcePool } from '@jason-huang/infinite-canvas-agent/cache'
const images = new ResourcePool<ImageBitmap>(128 * 1024 * 1024)
acquire:并发只加载一次
const lease = await images.acquire('asset-42@1024', async () => {
const blob = await fetch('/assets/asset-42-1024.webp').then((r) => r.blob())
const bitmap = await createImageBitmap(blob)
return {
value: bitmap,
bytes: bitmap.width * bitmap.height * 4,
dispose: (value) => value.close(),
}
})
try {
renderer.attachBitmap(nodeId, lease.value)
} finally {
// 应在节点 dehydrate 时 release;这里只展示所有权结构。
}
多个调用者同时 acquire 同一 key 时,只执行一次 loader,后续调用者等待同一个 Promise。
每个调用者得到独立 lease,必须各自调用一次 release()。release 是幂等的。
eviction:只有真正空闲的条目可被赶走
当新资源到来,Pool 按 lastUsed 从旧到新尝试清理。候选必须同时满足:
leases === 0;- 没有正在等待同一 key 的 acquire reservation。
正在画面中使用的资源不会为新资源被强行销毁。如果所有资源都仍有 lease,或者单个新资源
本身大于预算,加载结果会立即 dispose,并抛出 ResourceBudgetExceededError。Pool 不会偷偷
超预算。
可以主动收缩空闲缓存:
images.evictUnused(64 * 1024 * 1024)
targetBytes 是清理后的目标,不会改写 Pool 的总预算。destroy() 会 dispose 所有已加载
条目并阻止后续 acquire;它不等待仍在执行的外部 loader,所以宿主仍应在视图销毁前取消
自己的请求。
3. Renderer 与 lease 的正确配合
推荐一个挂载对象拥有一个 lease:
class ImageRenderer implements RendererPort<ImageNode> {
#mounted = new Map<string, { object: Shape; lease: ResourceLease<ImageBitmap> }>()
async hydrate(node: ImageNode) {
const lease = await pool.acquire(resourceKey(node), () => loadBitmap(node))
// 异步完成时节点可能已经离开视口,生产实现还要检查 generation/AbortSignal。
this.#mounted.set(node.id, { object: mount(lease.value), lease })
}
dehydrate(id: string) {
const mounted = this.#mounted.get(id)
if (!mounted) return
mounted.object.destroy()
mounted.lease.release()
this.#mounted.delete(id)
}
}
异步 hydrate 还要防止竞态:节点 A 离开后,旧请求才返回。可为每个 id 保存 generation,
或使用 AbortController;过期结果应立刻 release,不能重新挂回视口。
不要让 React/Vue 组件、Renderer 和全局缓存同时声称拥有同一个 lease。选定一个明确的
owner,并让其生命周期与 dehydrate 对齐。
4. LOD:不为缩略视图解码原始大图
默认 tier:128, 256, 512, 1024, 2048, 4096。选择依据是节点投影到屏幕后的最大边乘 DPR:
import { imageResourceKey, selectImageTier } from '@jason-huang/infinite-canvas-agent/cache'
const projected = Math.max(nodeWidth, nodeHeight) * camera.zoom
const tier = selectImageTier(projected, {
devicePixelRatio: window.devicePixelRatio,
currentTier: mountedTier,
})
const key = imageResourceKey(assetId, tier) // asset-42@1024
向上切换会立即选择足够清晰的 tier。向下切换默认要等 required edge 小于当前 tier 的
45%,用 hysteresis 防止在阈值附近反复加载。可以通过 downshiftRatio 调整。
LOD 是资源选择,不是节点真相。Document 只保存 assetId 和业务变换,不保存“当前 512
版本”这种易失状态。
5. AssetStore:管理原件与变体关系
interface AssetManifest {
id: string
original: AssetVariant
variants: AssetVariant[]
}
AssetStore 提供 manifest/blob 的 put/get 和按 asset 删除。仓库包含内存实现以及
IndexedDbPersistence 的浏览器实现。
await assets.putManifest({
id: 'asset-42',
original: { key: 'sha256/original', mimeType: 'image/webp', bytes: 2_400_000 },
variants: [
{ key: 'sha256/1024', mimeType: 'image/webp', bytes: 180_000, width: 1024, height: 576 },
],
})
await assets.putBlob('sha256/1024', blob)
删除 asset 会同时删除 manifest 中声明的 original 和 variants。若多个 asset 共享同一个
内容寻址 blob,需要由宿主增加引用计数;基础 AssetStore 不会推断跨 manifest 共享关系。
6. 视频不是普通图片
视频节点至少可能占有:HTMLVideoElement、SourceBuffer、解码队列、网络连接、音轨和纹理。 建议把“当前挂载的视频播放器数量”作为独立预算,不要只填一个估算 bytes 就认为解决了问题。
离开工作集时通常需要:
pause();- 解绑 timeupdate/frame callback;
- 从 Stage/DOM 移除;
- 对可重建播放器移除
src/srcObject并调用load(); - revoke 由当前 owner 创建的 object URL;
- 释放 Pool lease 或播放器槽位。
正在播放、拖拽或画中画的节点可以 pin,但 pin 不能无限增长。宿主需要定义最大并发视频数 和超限时的产品行为。
7. 预算怎样定
没有一个适合所有设备的固定值。建议用可观测数据分层定额:
- 低配移动端:较小 Pool、较低 DPR tier、较短 exit grace;
- 桌面端:较大 Pool,但仍保持硬上限;
- 单个资源上限:防止一张超大图占满整个预算;
- 同时加载数:在 Pool 之外用 semaphore 限制网络/解码并发;
- 视频播放器数:独立硬限制;
- object count:通过
RuntimeFrame.renderedObjectCount验收。
pool.inspect() 返回 entries、bytes、activeLeases、pendingLoads、hits、misses、evictions 和
rejections。生产环境至少关注:
bytes / budgetBytes
activeLeases
pendingLoads
evictions / minute
rejections by resource kind
renderedObjectCount / visible candidate count
8. 常见反模式
- 只缓存 URL,离开视口时不释放
ImageBitmap或 texture。 - 用原始 JPEG 文件大小估算解码内存。
- 每个节点生成不同 key,导致同一 asset 无法并发去重。
- acquire 后忘记 release,使全部条目永远不可淘汰。
- 为避免闪烁把所有节点 pin。
- 把 Pool 当持久存储,页面刷新后期待它恢复 Document。
- 资源超预算后静默降级为无限缓存。
- 在 Renderer destroy 后仍让异步 loader 挂载结果。
先运行 cache-only demo, 可以在不接入 Document/Runtime 的情况下观察并发去重、lease 与 eviction。