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 就认为解决了问题。

离开工作集时通常需要:

  1. pause()
  2. 解绑 timeupdate/frame callback;
  3. 从 Stage/DOM 移除;
  4. 对可重建播放器移除 src/srcObject 并调用 load()
  5. revoke 由当前 owner 创建的 object URL;
  6. 释放 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。