驱逐改为可见性优先,条目上限 48→72:图片不再自己消失又回来
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 19m24s

用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 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 子集的前后对照见随后的回报。)
This commit is contained in:
2026-09-11 20:39:59 +08:00
parent fcaf1a8e91
commit 2ea3bcbb07
3 changed files with 166 additions and 26 deletions
@@ -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<string>();
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(
(