From 149c0b591a0958150bc6f193853a4364ce68045d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 19:58:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E9=A6=96=E5=B1=8F?= =?UTF-8?q?=EF=BC=9A=E7=83=AD=E9=A2=84=E5=8F=96=E8=BF=87=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E5=88=A4=E6=8D=AE=EF=BC=8C=E7=9B=B8=E4=BA=A4=E5=8D=A1?= =?UTF-8?q?=E6=8C=89=E8=A7=86=E5=8F=A3=E5=86=85=20/=20=E4=BD=99=E9=87=8F?= =?UTF-8?q?=E5=9C=88=E4=B8=A4=E6=A1=A3=E6=94=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A 热预取不再按投影顺序盲取前 N:eager effect 现在只预取已登记且几何上可见(档 0 / 档 1)的卡,按两档顺序入队,eagerPreviewLimit 仍是硬上限; - A 保留首屏兜底:一张可见卡都判不出来时(卡片尚未注册 / 容器还没布局出尺寸)退回原顺序预取,绝不把首屏热预取削成 0; - B 新增 viewportBandOfElement:0 = 视口内、1 = 只落在 rootMargin(160px) 圈里、2 = 放行范围外(含量不出尺寸);门禁与放行顺序共用同一套几何口径; - B 新增 requestPreviewCardsByViewportBand:先档 0 再档 1,只改入队顺序、不改总量(档 1 在同一次调用里一样放行),3 槽 / 48 项 / 64 MiB 合同不动; - B 兜底扫描改用两档放行(门禁语义不变:只有档 0 / 档 1 才放行),不会退化成"只补第一档"; - B observer 回调一次报来十几二十张相交卡时,只把"视口内"的插到前面,其余一张不丢,既有"相交即放行"语义不变; - 新增用例:视口外卡片不得进入热预取(改前首屏 distinct 读 20 张、含 8 张视口外;改后 12 张全部可见);相交一次性放行时先视口内后余量圈且总量不变(6 张全放行); - 变异验证:整份还原成改前版本 → 两条用例分别红(20 vs 12、放行顺序反了);只把 observer 回调改回单趟 → 只有两档那条红。 --- .../useProjectResourceCardPreviews.ts | 224 +++++++++++--- .../useProjectResourceCardPreviews.test.ts | 291 +++++++++++++++++- 2 files changed, 469 insertions(+), 46 deletions(-) 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 fc9384003..4ba682c9b 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 @@ -794,32 +794,92 @@ export function useProjectResourceCardPreviews(input: { ); /** - * 与 observer 的 `rootMargin: 160px` 对齐的视口判定。 + * 把一个卡元素判到三个档位: + * `0` 落在视口内、`1` 只落在 `rootMargin`(160px)那一圈里、`2` 在放行范围之外。 * - * 兜底扫描用它**独立复核可见性**,而不是信任 observer 报过什么 —— 要修的是 - * "门禁漏放行",所以不能拿同一条可能失效的通路当判据。只认"确实落在视口矩形 - * (含 160px 余量)内"的卡,**不会退化成全量预读**。 + * 判据与 observer 的 `rootMargin: 160px` 对齐,但它是**独立复核**:门禁漏放行要修的 + * 就是"trust observer 报过什么",所以不能拿同一条可能失效的通路当判据。 + * + * 两个用途共用同一套几何口径,避免"扫描一套、热预取另一套": + * - `2` 是**放行门禁**(外圈不得请求,也不得进热预取,因此不会被全量预读吃掉预算); + * - `0` / `1` 是**放行顺序**(先视口内、再余量圈,见 `requestPreviewCardsByViewportBand`)。 + * + * 尺寸量不出来(未布局、`0×0`)时判 `2`:宁可等一次 observer 回调或下一轮扫描, + * 也不能凭"量不到"就当可见。 */ - const isElementWithinVisibleArea = useCallback((element: HTMLElement) => { - const rect = element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return false; - } - const margin = RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX; - const viewportWidth = - window.innerWidth || document.documentElement?.clientWidth || 0; - const viewportHeight = - window.innerHeight || document.documentElement?.clientHeight || 0; - if (viewportWidth <= 0 || viewportHeight <= 0) { - return false; - } - return ( - rect.right >= -margin && - rect.left <= viewportWidth + margin && - rect.bottom >= -margin && - rect.top <= viewportHeight + margin - ); - }, []); + const viewportBandOfElement = useCallback( + (element: HTMLElement): 0 | 1 | 2 => { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return 2; + } + const viewportWidth = + window.innerWidth || document.documentElement?.clientWidth || 0; + const viewportHeight = + window.innerHeight || document.documentElement?.clientHeight || 0; + if (viewportWidth <= 0 || viewportHeight <= 0) { + return 2; + } + const withinBand = (margin: number) => + rect.right >= -margin && + rect.left <= viewportWidth + margin && + rect.bottom >= -margin && + rect.top <= viewportHeight + margin; + if (withinBand(0)) { + return 0; + } + return withinBand(RESOURCE_PREVIEW_VIEWPORT_MARGIN_PX) ? 1 : 2; + }, + [], + ); + + /** + * 按**两档**放行一批已确认可见的卡:先视口内的(档 0),再 `rootMargin` 圈里的(档 1)。 + * + * 冷启动首屏一次可能同时有十几二十张卡够格(真机口径:可见 ≈15、加上 160px 余量后 + * 相交 ≈21),而物理读取只有 3 个槽(`PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY`)。 + * 一次全部入队时,排在队列后面的"其实就在屏幕里"的卡要等前面那些最多 160px 外的卡读完 + * —— 用户感知就是"首屏等图"。两档放行**只改入队顺序、不改总量**:档 1 的卡在同一次调用里 + * 一样会被放行,所以兜底扫描不会退化成"只补第一档",3 槽 / 48 项 / 64 MiB 合同也不动。 + */ + const requestPreviewCardsByViewportBand = useCallback( + ( + cards: readonly { + element: HTMLElement; + resource: ProjectResource; + identity: string; + }[], + options: { onlyIdle: boolean; limit?: number }, + ) => { + const limit = options.limit ?? Number.POSITIVE_INFINITY; + const requestedIdentities = new Set(); + let requested = 0; + for (const band of [0, 1] as const) { + for (const card of cards) { + if (requested >= limit) { + return requested; + } + if (requestedIdentities.has(card.identity)) { + continue; + } + if (viewportBandOfElement(card.element) !== band) { + continue; + } + if ( + options.onlyIdle && + previewsRef.current.get(card.identity)?.status !== undefined + ) { + continue; + } + requestedIdentities.add(card.identity); + requestPreview(card.resource, card.identity, 'visible'); + requested += 1; + } + } + return requested; + }, + [requestPreview, viewportBandOfElement], + ); /** * 可见性兜底扫描:把「已登记、确实在视口内、但还没有任何请求」的卡补一次 `visible`。 @@ -828,21 +888,19 @@ export function useProjectResourceCardPreviews(input: { * 而是给"门禁漏放行"一个可恢复窗口。只对 `idle`(从未请求)的卡生效: * `loading` / `queued` / `loaded` / `failed` 一律不碰,所以不会绕过热预取合同、 * 也不会突破全局 3 槽与 LRU 预算。 + * + * 放行顺序按视口内 / 余量圈两档(见 `requestPreviewCardsByViewportBand`)。 */ const sweepVisiblePreviews = useCallback(() => { - let requested = 0; - for (const [element, binding] of observedCardsRef.current) { - if (previewsRef.current.get(binding.identity)?.status !== undefined) { - continue; - } - if (!isElementWithinVisibleArea(element)) { - continue; - } - requestPreview(binding.resource, binding.identity, 'visible'); - requested += 1; - } - return requested; - }, [isElementWithinVisibleArea, requestPreview]); + return requestPreviewCardsByViewportBand( + Array.from(observedCardsRef.current, ([element, binding]) => ({ + element, + resource: binding.resource, + identity: binding.identity, + })), + { onlyIdle: true }, + ); + }, [requestPreviewCardsByViewportBand]); sweepVisiblePreviewsRef.current = sweepVisiblePreviews; /** @@ -985,9 +1043,53 @@ export function useProjectResourceCardPreviews(input: { // 只预热 `eagerResources`(缺省回退 `resources`):身份与缓存仍然覆盖全量投影, // 这里收窄的仅仅是「谁先占读取槽」。 const eagerResources = input.eagerResources ?? input.resources; - let requested = 0; + const elementsByIdentity = new Map(); + for (const [element, binding] of observedCardsRef.current) { + elementsByIdentity.set(binding.identity, element); + } + /** + * **热预取也要过可见性判据**:此前它按投影顺序盲取前 `eagerPreviewLimit` 张, + * 条件里只有身份与 kind 过滤 —— 于是排在前面的视口外卡片先占满 3 个读取槽, + * 屏幕里那些卡反而要排队,冷启动首屏就是"等图片"。 + * + * 现在只预取**几何上可见**(档 0 / 档 1)且已登记的卡,并按"先视口内、再余量圈" + * 两档入队;`eagerPreviewLimit` 仍是硬上限,3 槽 / 48 项 / 64 MiB 合同不变。 + */ + const visibleCards: { + element: HTMLElement; + resource: ProjectResource; + identity: string; + }[] = []; for (const resource of eagerResources) { - if (requested >= eagerPreviewLimit) { + const identity = identityByResourceId.get(resource.id); + if (!identity) { + continue; + } + const kind = projectResourceCardPreviewKind(resource); + if (kind === 'version' || kind === 'placeholder' || kind === 'audio') { + continue; + } + const element = elementsByIdentity.get(identity); + if (!element) { + continue; + } + visibleCards.push({ element, resource, identity }); + } + const requested = requestPreviewCardsByViewportBand(visibleCards, { + onlyIdle: true, + limit: eagerPreviewLimit, + }); + if (requested > 0) { + return; + } + /** + * 一张可见卡都判不出来时(卡片尚未注册、容器还没布局出尺寸)保留原来的顺序预取, + * **绝不把首屏热预取削成 0**:这时按投影顺序预热前若干张,至少保证首屏有图可读。 + * 真正的放行顺序与去重仍然只有一条通路(`requestPreview`),这里不是第二条加载通路。 + */ + let fallbackRequested = 0; + for (const resource of eagerResources) { + if (fallbackRequested >= eagerPreviewLimit) { break; } const identity = identityByResourceId.get(resource.id); @@ -999,7 +1101,7 @@ export function useProjectResourceCardPreviews(input: { kind !== 'audio' ) { requestPreview(resource, identity, 'visible'); - requested += 1; + fallbackRequested += 1; } } }, [ @@ -1008,6 +1110,7 @@ export function useProjectResourceCardPreviews(input: { input.eagerResources, input.resources, requestPreview, + requestPreviewCardsByViewportBand, ]); const imageDimensionsByResourceId = useMemo(() => { @@ -1070,16 +1173,46 @@ export function useProjectResourceCardPreviews(input: { observerRef.current?.disconnect(); const observer = new IntersectionObserverClass( (entries) => { + /* + * 一次回调往往同时报来十几二十张相交的卡(真机首屏口径:可见 ≈15、 + * 加 160px 余量后 ≈21),而读取只有 3 个槽:谁先进队列,谁就先出图。 + * + * 这里**只改入队顺序**:视口内的(档 0)先入队,剩下报相交的(档 1,或几何上 + * 量不出尺寸的卡)随后入队 —— 一张都不丢,所以既有"相交即放行"的语义不变, + * 只是先看到的那几张先出图。判据复用 `viewportBandOfElement`,不做第二次门禁: + * observer 已经报过相交,这里没有理由因为量不到尺寸而拒收。 + */ + const intersectingCards: { + element: HTMLElement; + resource: ProjectResource; + identity: string; + }[] = []; + const deferredCards: typeof intersectingCards = []; for (const entry of entries) { if (!entry.isIntersecting) { continue; } - const binding = observedCardsRef.current.get( - entry.target as HTMLElement, - ); - if (binding) { - requestPreview(binding.resource, binding.identity, 'visible'); + const element = entry.target as HTMLElement; + const binding = observedCardsRef.current.get(element); + if (!binding) { + continue; } + const card = { + element, + resource: binding.resource, + identity: binding.identity, + }; + if (viewportBandOfElement(element) === 0) { + intersectingCards.push(card); + } else { + deferredCards.push(card); + } + } + for (const card of intersectingCards) { + requestPreview(card.resource, card.identity, 'visible'); + } + for (const card of deferredCards) { + requestPreview(card.resource, card.identity, 'visible'); } }, { @@ -1126,6 +1259,7 @@ export function useProjectResourceCardPreviews(input: { requestPreview, rootEpoch, scopeKey, + viewportBandOfElement, ]); return { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 52f49a3e7..b94b4bf58 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -1,6 +1,13 @@ // @vitest-environment jsdom -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { + act, + cleanup, + render, + renderHook, + waitFor, +} from '@testing-library/react'; +import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; @@ -1696,6 +1703,288 @@ describe('预览队列上限吞掉按需请求时的可观测性', () => { }); }); +describe('冷启动首屏的放行范围与放行顺序', () => { + type PreviewController = ReturnType; + + const VIEWPORT_MARGIN = 160; + + /** 只补用例关心的字段:几何判据只读 width/height/left/right/top/bottom。 */ + function cardRect(input: { + top: number; + bottom: number; + left?: number; + right?: number; + }): DOMRect { + const left = input.left ?? 10; + const right = input.right ?? 60; + return { + x: left, + y: input.top, + top: input.top, + bottom: input.bottom, + left, + right, + width: right - left, + height: input.bottom - input.top, + } as DOMRect; + } + + /** 视口内(档 0) */ + const IN_VIEWPORT_RECT = () => cardRect({ top: 10, bottom: 60 }); + /** 只在 160px 余量圈里(档 1):整卡在视口上沿之外,但仍落在 rootMargin 内。 */ + const IN_MARGIN_ONLY_RECT = () => + cardRect({ + top: -(VIEWPORT_MARGIN / 2), + bottom: -(VIEWPORT_MARGIN / 2) + 50, + }); + /** 放行范围之外(档 2) */ + const OFFSCREEN_RECT = () => cardRect({ top: 2000, bottom: 2060 }); + + function PreviewBandCard(input: { + controller: PreviewController; + resource: ProjectResource; + rect: DOMRect; + }) { + const elementRef = React.useRef(null); + const { identityByResourceId, observePreview } = input.controller; + React.useLayoutEffect(() => { + const element = elementRef.current; + const identity = identityByResourceId.get(input.resource.id); + if (!element || !identity) { + return undefined; + } + element.getBoundingClientRect = () => input.rect; + return observePreview(element, input.resource, identity); + }, [identityByResourceId, input.rect, input.resource, observePreview]); + return React.createElement('div', { + ref: elementRef, + 'data-preview-band-card': input.resource.id, + }); + } + + /** + * 真机形状的最小复刻:卡片在子组件的 layout effect 里注册(早于本 hook 的被动 effect, + * 与生产里"卡片是画本容器的子组件"同一时序)。 + */ + function PreviewBandHarness(input: { + projectPath: string; + resources: ProjectResource[]; + rects: DOMRect[]; + eagerPreviewLimit: number; + controllerRef?: { current: PreviewController | null }; + }) { + const canvasRef = React.useRef(null); + const controller = useProjectResourceCardPreviews({ + projectPath: input.projectPath, + projectId: input.projectPath, + mode: 'dependency', + resources: input.resources, + canvasRef, + eagerPreviewLimit: input.eagerPreviewLimit, + }); + if (input.controllerRef) { + input.controllerRef.current = controller; + } + return React.createElement( + 'div', + { ref: canvasRef }, + input.resources.map((item, index) => + React.createElement(PreviewBandCard, { + key: item.id, + controller, + resource: item, + rect: input.rects[index]!, + }), + ), + ); + } + + function readPaths(invoke: ReturnType) { + return previewReadCalls(invoke).map( + ([, args]) => + (args as { relativePath?: string } | undefined)?.relativePath ?? '', + ); + } + + function installRecordingIntersectionObserver() { + const callbacks: IntersectionObserverCallback[] = []; + const observed = new Set(); + const original = Object.getOwnPropertyDescriptor( + window, + 'IntersectionObserver', + ); + class RecordingIntersectionObserver { + readonly root = null; + readonly rootMargin = '160px'; + readonly thresholds = [0]; + + constructor(callback: IntersectionObserverCallback) { + callbacks.push(callback); + } + + observe(element: Element) { + observed.add(element); + } + + unobserve(element: Element) { + observed.delete(element); + } + + disconnect() { + observed.clear(); + } + + takeRecords() { + return []; + } + } + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + writable: true, + value: RecordingIntersectionObserver, + }); + return { + callbacks, + observed, + fire: (targets: readonly Element[]) => { + act(() => { + callbacks.at(-1)!( + targets.map( + (target) => + ({ + target, + isIntersecting: true, + intersectionRatio: 1, + }) as IntersectionObserverEntry, + ), + {} as IntersectionObserver, + ); + }); + }, + restore: () => { + if (original) { + Object.defineProperty(window, 'IntersectionObserver', original); + } else { + Reflect.deleteProperty(window, 'IntersectionObserver'); + } + }, + }; + } + + it('prefetches only cards that are actually in view, and still fills the first screen', async () => { + // A:热预取此前按投影顺序盲取前 12 张,条件里没有几何判断 —— 排在前面的视口外卡片 + // 先占满 3 个读取槽,屏幕里的卡反而排队,首屏就是"等图片"。 + const offscreen = Array.from({ length: 8 }, (_, index) => + resource(`eager-offscreen-${index}`), + ); + const marginOnly = Array.from({ length: 4 }, (_, index) => + resource(`eager-margin-${index}`), + ); + const inViewport = Array.from({ length: 8 }, (_, index) => + resource(`eager-in-viewport-${index}`), + ); + // 投影顺序故意把"视口外"排在前面:旧的顺序预取会先读它们。 + const resources = [...offscreen, ...marginOnly, ...inViewport]; + const rects = [ + ...offscreen.map(OFFSCREEN_RECT), + ...marginOnly.map(IN_MARGIN_ONLY_RECT), + ...inViewport.map(IN_VIEWPORT_RECT), + ]; + const invoke = vi.fn( + async (_command: string, args?: Record) => + preview(String(args?.relativePath ?? '')), + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(PreviewBandHarness, { + projectPath: '/tmp/preview-eager-visibility', + resources, + rects, + eagerPreviewLimit: 12, + }), + ); + + // 首屏请求量:仍是 eagerPreviewLimit = 12 张,但 12 张全部落在可见卡上。 + await waitFor(() => + expect(new Set(readPaths(invoke)).size).toBe( + resources.length - offscreen.length, + ), + ); + const readPathsSet = new Set(readPaths(invoke)); + for (const item of offscreen) { + expect(readPathsSet.has(item.path)).toBe(false); + } + for (const item of [...inViewport, ...marginOnly]) { + expect(readPathsSet.has(item.path)).toBe(true); + } + // 首屏要保证有图可读:可见的 12 张一张都不能少。 + expect(readPathsSet.size).toBe(resources.length - offscreen.length); + }); + + it('releases intersecting cards in two bands: viewport first, margin second', async () => { + // B:一次 observer 回调可能同时报来 ~21 张相交卡,物理读取只有 3 个槽。 + // 两档放行 = 先入队视口内、再入队余量圈,**总量不变**(不是丢掉第二档)。 + const marginOnly = Array.from({ length: 3 }, (_, index) => + resource(`band-margin-${index}`), + ); + const inViewport = Array.from({ length: 3 }, (_, index) => + resource(`band-in-viewport-${index}`), + ); + // 登记顺序故意先余量圈、后视口内:单趟入队会把这个顺序原样写进队列。 + const resources = [...marginOnly, ...inViewport]; + const invoke = vi.fn(() => new Promise(() => undefined)); + window.__TAURI__ = { core: { invoke } }; + const harness = installRecordingIntersectionObserver(); + const controllerRef: { current: PreviewController | null } = { + current: null, + }; + + try { + render( + React.createElement(PreviewBandHarness, { + projectPath: '/tmp/preview-band-order', + resources, + rects: resources.map(OFFSCREEN_RECT), + eagerPreviewLimit: 0, + controllerRef, + }), + ); + await waitFor(() => expect(harness.callbacks.length).toBeGreaterThan(0)); + // 注册时全在放行范围之外:登记扫描一张都不该放行。 + expect(previewReadCalls(invoke)).toHaveLength(0); + + const elements = Array.from( + document.querySelectorAll('[data-preview-band-card]'), + ); + expect(elements).toHaveLength(resources.length); + elements.forEach((element, index) => { + element.getBoundingClientRect = + index < marginOnly.length ? IN_MARGIN_ONLY_RECT : IN_VIEWPORT_RECT; + }); + + harness.fire(elements); + + // 3 个槽先给视口内那批:一次回调里先看到的那几张先出图。 + expect(readPaths(invoke)).toEqual(inViewport.map((item) => item.path)); + // 余量圈那批没有被丢掉,只是排在后面(总量不变 = 6 张全部放行)。 + const queuedIdentities = controllerRef + .current!.previewQueueSnapshot() + .queue.map((job) => job.identity); + expect(queuedIdentities).toEqual( + marginOnly.map((item) => + controllerRef.current!.identityByResourceId.get(item.id), + ), + ); + expect(readPaths(invoke).length + queuedIdentities.length).toBe( + resources.length, + ); + } finally { + harness.restore(); + } + }); +}); + describe('预览缓存驱逐后的可见性复核', () => { it('re-requests a card that is still visible right after the LRU evicts it', async () => { // 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,