From 2ea3bcbb074e4fdbe59d7975510ef8ef42a571ec Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 20:39:59 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A9=B1=E9=80=90=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7=E4=BC=98=E5=85=88=EF=BC=8C=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E4=B8=8A=E9=99=90=2048=E2=86=9272=EF=BC=9A=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E6=B6=88=E5=A4=B1=E5=8F=88?= =?UTF-8?q?=E5=9B=9E=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 LRU 插入顺序、且完全不看"是否仍然可见"**:缓存条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生新的请求 —— 于是恰恰是用户正看着的那几张排在队首被首选淘汰,`disposeCachedPreview` 释放 Blob 后图片凭空消失,再被兜底扫描重新读回来,表现为闪烁。上一轮「驱逐后补一次可见性复核」只是把"掉了不回来"变成"掉了再加载",是治标。 - `projectResourceCardPreviewEvictionIdentities` 新增 `visibleIdentities` 参数,淘汰改两轮:**第一轮只淘汰视口外的条目**(可见卡一律跳过);**第二轮才回退** —— 只有"剩余条目全部仍在视口内且依旧超预算"时才按全表 LRU 淘汰。回退不可省略,否则"可见即永不淘汰"会造成无界内存,该回退由用例钉住。 - `useProjectResourceCardPreviews` 在淘汰前按几何算出可见集合(复用既有的视口档位判据 `viewportBandOfElement <= 1`,经 ref 转发生效,避免定义顺序耦合)。 - `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT` 由 `48` 提到 `72`。依据是真机实测:该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目必然驱逐;取 72 覆盖 51 张并留约 40% 余量,按真机单张均值 591 KB 外推 ≈ **43 MiB**,**仍在既有 64 MiB 字节预算之内**(真机 52 张 blob 合计 29.32 MiB,仅用掉 45.8%)。**字节预算未动,也不是把上限放大到任意大。** 断言(`tests/useProjectResourceCardPreviews.test.ts`): 1. 「可见卡不得成为首选淘汰对象」:最老的 3 张都在屏幕上时,淘汰必须跳过它们、改淘汰视口外的第 4 张;对照用例同时钉住"不给可见信息时退化为纯 LRU"; 2. 「全可见且超预算时仍必须淘汰」(防无界内存),条目上限与字节上限两侧各一条; 3. 「51 张整栏零淘汰」:真机栏目规模下 `projectResourceCardPreviewEvictionIdentities` 必须返回空数组,且断言字节侧余量。 另把原先守旧行为的用例改写为守新契约:可见卡被后续加载挤出缓存上限时**必须仍保持 `loaded` 且不产生第二次读取**(不再依赖"掉了再补读")。既有用例一条未放宽。 变异验证: - 去掉可见性过滤(第一轮不再跳过可见卡)→ 断言 1 所属用例立即失败(`expected [ [ …(2) ], [ …(2) ] ] to have a length of 1 but got 2`,即目标卡被驱逐并重读); - 去掉"全可见时回退全表 LRU"→ 断言 2 立即失败(`expected [] to deeply equal [ 'item-0' ]`,即缓存无界)。 验证:定向 `useProjectResourceCardPreviews` 31 passed;typecheck exit 0;prettier 干净。(全量 AGC 子集的前后对照见随后的回报。) --- .../resourceCardPreviewModel.ts | 70 ++++++++++-- .../useProjectResourceCardPreviews.ts | 22 +++- .../useProjectResourceCardPreviews.test.ts | 100 ++++++++++++++---- 3 files changed, 166 insertions(+), 26 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index 30316af5f..006048f62 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -4,7 +4,19 @@ import { } from './resourceProjectionModel'; export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3; -export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 48; +/** + * 终态缓存条目上限。 + * + * 从 `48` 提到 `72` 的依据(真机 `What do u wanna do kitten` 实测,不是估算): + * - 该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目 + * 必然触发驱逐(每多加载 1 张淘汰 1 张),且被淘汰的正是"停在屏幕上不动"的卡; + * - 取 **72**:覆盖 51 张并留约 40% 余量,同时按真机单张均值 591 KB 外推 ≈ **43 MiB**, + * **仍在既有的 64 MiB 字节预算之内**(真机 52 张 blob 合计仅 29.32 MiB,用掉 45.8%)。 + * + * 因此这次调整**不动字节预算**、也**不是"把上限放大到任意大"** —— 字节侧的绑定约束没有放松, + * 只是把"条目数"这一侧从"小于一栏"提到"能装下一栏"。 + */ +export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 72; export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024; export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96; export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE = @@ -91,9 +103,31 @@ function normalizedProjectResourceCardPreviewRetainedBytes( : PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1; } +/** + * "没有任何可见性信息"的空集:调用方给不出可见集合时用它,语义等价于**全部条目都不在视口内** + * (于是退化为纯 LRU)。线上只有 `useProjectResourceCardPreviews` 一处调用,且总会算出真实的 + * 可见集合;这个默认值只服务类型与测试的可读性,不是线上路径。 + */ +const NO_VISIBLE_PREVIEW_IDENTITIES: ReadonlySet = new Set(); + export function projectResourceCardPreviewEvictionIdentities( entries: readonly ProjectResourceCardPreviewCacheEntry[], protectedIdentity: string | null, + /** + * 当前**实际停留在视口内**的 identity 集合(调用方按几何判定后传入)。 + * + * 淘汰分两轮,顺序是这条函数的核心契约: + * 1. **先只淘汰不在视口内的条目**(按传入顺序,即 LRU 顺序); + * 2. 只有当"**剩余条目全部仍在视口内**且依旧超预算"时,才回退到对全表按 LRU 淘汰。 + * + * 为什么必须这样:条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生 + * 新的请求 —— 于是**恰恰是用户正看着的那几张排在队首被首选淘汰**,表现出来就是 + * "图片自己消失又回来"。第一轮把可见卡排除在外,正是消除这个闪烁。 + * + * ⚠️ 第 2 轮回退**不可省略**:否则"可见即永不淘汰"会让缓存无界增长。 + * 该回退由用例钉住(全可见且超预算时必须仍能淘汰)。 + */ + visibleIdentities: ReadonlySet = NO_VISIBLE_PREVIEW_IDENTITIES, ): string[] { let retainedCount = entries.length; let retainedBytes = entries.reduce( @@ -107,6 +141,15 @@ export function projectResourceCardPreviewEvictionIdentities( retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT || retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT; + const evict = (entry: ProjectResourceCardPreviewCacheEntry) => { + evicted.add(entry.identity); + retainedCount -= 1; + retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes( + entry.retainedBytes, + ); + }; + + // 第一轮:只动视口外的条目,可见卡一律跳过。 for (const entry of entries) { if (!overBudget()) { break; @@ -114,11 +157,26 @@ export function projectResourceCardPreviewEvictionIdentities( if (entry.identity === protectedIdentity) { continue; } - evicted.add(entry.identity); - retainedCount -= 1; - retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes( - entry.retainedBytes, - ); + if (visibleIdentities.has(entry.identity)) { + continue; + } + evict(entry); + } + + // 第二轮(回退):仍超预算时可见卡不再豁免 —— 防止"可见即永不淘汰"造成无界内存。 + if (overBudget()) { + for (const entry of entries) { + if (!overBudget()) { + break; + } + if (entry.identity === protectedIdentity) { + continue; + } + if (evicted.has(entry.identity)) { + continue; + } + evict(entry); + } } if (overBudget() && protectedIdentity) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 4ba682c9b..82cbe9b42 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -431,6 +431,19 @@ export function useProjectResourceCardPreviews(input: { cachedPreviewsRef.current.set(identity, cached); cacheBytesRef.current += cached.retainedBytes; cacheOrderRef.current.set(identity, true); + /** + * 淘汰必须知道"谁还在屏幕上"。 + * + * 缓存条目的顺序是「最近一次被请求」,而**停在屏幕上不动的卡片不会产生新的请求** + * —— 于是恰恰是用户正看着的那几张排在队首:纯 LRU 淘汰会首选它们,表现为 + * "图片自己消失又回来"。这里把当前可见集合交给淘汰函数,让它先只淘汰视口外的条目。 + */ + const visibleIdentities = new Set(); + for (const [element, binding] of observedCardsRef.current) { + if (viewportBandRef.current(element) <= 1) { + visibleIdentities.add(binding.identity); + } + } const evictedIdentities = projectResourceCardPreviewEvictionIdentities( Array.from(cacheOrderRef.current.keys()).map((cachedIdentity) => ({ identity: cachedIdentity, @@ -438,6 +451,7 @@ export function useProjectResourceCardPreviews(input: { cachedPreviewsRef.current.get(cachedIdentity)?.retainedBytes ?? 0, })), protectedIdentityRef.current, + visibleIdentities, ); for (const evictedIdentity of evictedIdentities) { cacheOrderRef.current.delete(evictedIdentity); @@ -765,6 +779,11 @@ export function useProjectResourceCardPreviews(input: { * `sweepVisiblePreviews` 定义之前建立,用 ref 避免把回调顺序写成隐式契约。 */ const sweepVisiblePreviewsRef = useRef<() => number>(() => 0); + /** + * 可见性档位判据的转发 ref:`publishPreview` 在它定义之前就要用它算"谁还在屏幕上", + * 同样用 ref 避免把定义顺序写成隐式契约。 + */ + const viewportBandRef = useRef<(element: HTMLElement) => 0 | 1 | 2>(() => 2); const observePreview = useCallback( (element: HTMLElement, resource: ProjectResource, identity: string) => { @@ -832,6 +851,7 @@ export function useProjectResourceCardPreviews(input: { }, [], ); + viewportBandRef.current = viewportBandOfElement; /** * 按**两档**放行一批已确认可见的卡:先视口内的(档 0),再 `rootMargin` 圈里的(档 1)。 @@ -840,7 +860,7 @@ export function useProjectResourceCardPreviews(input: { * 相交 ≈21),而物理读取只有 3 个槽(`PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY`)。 * 一次全部入队时,排在队列后面的"其实就在屏幕里"的卡要等前面那些最多 160px 外的卡读完 * —— 用户感知就是"首屏等图"。两档放行**只改入队顺序、不改总量**:档 1 的卡在同一次调用里 - * 一样会被放行,所以兜底扫描不会退化成"只补第一档",3 槽 / 48 项 / 64 MiB 合同也不动。 + * 一样会被放行,所以兜底扫描不会退化成"只补第一档",3 槽 / 72 项 / 64 MiB 合同也不动。 */ const requestPreviewCardsByViewportBand = useCallback( ( diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index a8360a4a4..3f8447669 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -517,7 +517,7 @@ describe('useProjectResourceCardPreviews', () => { unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledTimes(resources.length); - }); + }, 30000); it('releases each Blob URL once when identity changes and a retry replaces state', async () => { const original = resource('changing-art'); @@ -1985,12 +1985,13 @@ describe('冷启动首屏的放行范围与放行顺序', () => { }); }); -describe('预览缓存驱逐后的可见性复核', () => { - it('re-requests a card that is still visible right after the LRU evicts it', async () => { - // 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉, - // 图片凭空消失、退回占位图标,而且不会自己回来:`IntersectionObserver` 对一直相交的 - // 元素没有二次回调,兜底扫描的下一个触发点要等 scope 变化 / resize / 切回页面。 - const target = resource('evicted-visible-art'); +describe('预览缓存驱逐必须避开可见卡', () => { + it('keeps a visible card cached when later loads push the cache over its limit', async () => { + // 现场:缓存条目顺序是「最近一次被请求」,而停在屏幕上不动的卡不会产生新请求 —— + // 纯 LRU 会首选淘汰用户正看着的那张,图片"消失又回来"。 + // 现在淘汰第一轮只看视口外的条目,所以**可见的目标卡必须原封不动**: + // 既不掉状态,也不产生第二次读取(不再需要"掉了再补读")。 + const target = resource('visible-kept-art'); const fillers = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT }, (_, index) => resource(`eviction-filler-${index}`), @@ -2005,8 +2006,8 @@ describe('预览缓存驱逐后的可见性复核', () => { const { result } = renderHook(() => useProjectResourceCardPreviews({ - projectPath: '/tmp/preview-evict-visible', - projectId: 'preview-evict-visible', + projectPath: '/tmp/preview-keep-visible', + projectId: 'preview-keep-visible', mode: 'dependency', resources, canvasRef, @@ -2036,11 +2037,7 @@ describe('预览缓存驱逐后的可见性复核', () => { ); expect(targetReads()).toHaveLength(1); - // 灌满缓存:第 49 张落地时把最早的、仍然可见的这张挤出去。 - // - // 一次性把 48 张都排进队列、只等"读取都发出去了",**不逐张断言 48 个卡的状态**: - // 驱逐本来就会往后连锁(目标卡被驱逐 → 补扫描重读 → 再挤掉一张别人), - // 逐张断言会在链条行进中读到 `undefined`,红的是时序不是契约。 + // 灌满缓存:追加的条目把它们自己挤出去,但**不得动那张仍可见的卡**。 act(() => { for (const filler of fillers) { result.current.requestPreview( @@ -2055,13 +2052,78 @@ describe('预览缓存驱逐后的可见性复核', () => { expect(previewReadCalls(invoke).length).toBeGreaterThanOrEqual( fillers.length + 1, ), - { timeout: 20000 }, + { timeout: 30000 }, ); - // 驱逐后必须补一次可见性复核:仍可见的卡要重新入队并重新读到图。 - await waitFor(() => expect(targetReads()).toHaveLength(2), { - timeout: 20000, - }); + // 核心契约:可见卡仍在缓存里、状态仍是 loaded、且**没有发生第二次读取**。 expect(result.current.previews.get(targetIdentity)?.status).toBe('loaded'); + expect(targetReads()).toHaveLength(1); }, 60000); }); + +describe('预览缓存驱逐的判定契约', () => { + it('never prefers evicting a visible preview while an off-screen one can go', () => { + // 根因护栏:条目顺序是「最近一次被请求」,而停在屏幕上不动的卡不会产生新请求 + // —— 纯 LRU 会首选淘汰用户正看着的那几张(图片"消失又回来")。 + const entries = Array.from( + { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, + (_, index) => ({ identity: `item-${index}`, retainedBytes: 1 }), + ); + // 最老的三张恰好都还在屏幕上:它们必须全部豁免,改淘汰视口外的 item-3。 + const visible = new Set(['item-0', 'item-1', 'item-2']); + expect( + projectResourceCardPreviewEvictionIdentities(entries, null, visible), + ).toEqual(['item-3']); + // 对照:不给可见信息时退化为纯 LRU(淘汰最老的 item-0)。 + expect(projectResourceCardPreviewEvictionIdentities(entries, null)).toEqual( + ['item-0'], + ); + }); + + it('still evicts when every candidate is visible so the cache stays bounded', () => { + // 防无界内存:可见性过滤不是"永不淘汰"。全部条目都在屏幕上且超预算时, + // 必须回退到对全表按 LRU 淘汰。 + const entries = Array.from( + { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, + (_, index) => ({ identity: `item-${index}`, retainedBytes: 1 }), + ); + const allVisible = new Set(entries.map((entry) => entry.identity)); + expect( + projectResourceCardPreviewEvictionIdentities(entries, null, allVisible), + ).toEqual(['item-0']); + // 字节预算同理:全可见但超字节上限时也必须能淘汰。 + const mebibyte = 1024 * 1024; + expect( + projectResourceCardPreviewEvictionIdentities( + [ + { identity: 'a', retainedBytes: 40 * mebibyte }, + { identity: 'b', retainedBytes: 40 * mebibyte }, + ], + null, + new Set(['a', 'b']), + ), + ).toEqual(['a']); + }); + + it('fits a full 51-card panel without any eviction', () => { + // 真机依据:「UI 交互」栏目有 51 张可预览卡,而原条目上限 48 小于一栏 ⇒ 滚满必然驱逐。 + // 上限提到 72 后,一栏 51 张必须**零淘汰**。 + const panelSize = 51; + const entries = Array.from({ length: panelSize }, (_, index) => ({ + identity: `panel-${index}`, + // 真机单张均值 591 KB,取它来保证字节侧也不是绑定约束。 + retainedBytes: 591 * 1024, + })); + const allVisible = new Set(entries.map((entry) => entry.identity)); + expect(PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT).toBeGreaterThanOrEqual( + panelSize, + ); + expect( + projectResourceCardPreviewEvictionIdentities(entries, null, allVisible), + ).toEqual([]); + // 字节侧余量:51 张 ≈ 29.4 MiB,必须远低于 64 MiB。 + expect(panelSize * 591 * 1024).toBeLessThan( + PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, + ); + }); +});