驱逐改为可见性优先,条目上限 48→72:图片不再自己消失又回来
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 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:
+64
-6
@@ -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<string> = new Set<string>();
|
||||
|
||||
export function projectResourceCardPreviewEvictionIdentities(
|
||||
entries: readonly ProjectResourceCardPreviewCacheEntry[],
|
||||
protectedIdentity: string | null,
|
||||
/**
|
||||
* 当前**实际停留在视口内**的 identity 集合(调用方按几何判定后传入)。
|
||||
*
|
||||
* 淘汰分两轮,顺序是这条函数的核心契约:
|
||||
* 1. **先只淘汰不在视口内的条目**(按传入顺序,即 LRU 顺序);
|
||||
* 2. 只有当"**剩余条目全部仍在视口内**且依旧超预算"时,才回退到对全表按 LRU 淘汰。
|
||||
*
|
||||
* 为什么必须这样:条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生
|
||||
* 新的请求 —— 于是**恰恰是用户正看着的那几张排在队首被首选淘汰**,表现出来就是
|
||||
* "图片自己消失又回来"。第一轮把可见卡排除在外,正是消除这个闪烁。
|
||||
*
|
||||
* ⚠️ 第 2 轮回退**不可省略**:否则"可见即永不淘汰"会让缓存无界增长。
|
||||
* 该回退由用例钉住(全可见且超预算时必须仍能淘汰)。
|
||||
*/
|
||||
visibleIdentities: ReadonlySet<string> = 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) {
|
||||
|
||||
+21
-1
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user