优化图片画布拖动性能 (#173)
Project CI / Frontend tests (push) Failing after 45s
Project CI / Native shell tests (push) Failing after 46s
Project CI / Repository checks (push) Failing after 47s
Project CI / Backend tests (push) Successful in 3m25s

## 修改

- 按动画帧合并高频拖动更新,结束时持久化最终状态
- 对未移动图层子树做 memo,保留并发资源与业务字段更新
- 补充拖动合帧、最终 flush、并发状态和图层渲染回归
- 同步真实位图性能验收与排障文档

## 验证

- 8 个相关测试文件 202/202 通过
- TypeScript typecheck、编码检查、Prettier、git diff --check 通过
- 12000×12000 画布、360 张独立 512×512 PNG:有效位置更新 23.44/s → 58.18/s,gap p95 119.8ms → 32.9ms,>50ms 停顿 32 → 0,持久化快照 240 → 1
- 可信 CDP 输入最终运动观测约 46.9 FPS,不以空转 rAF 代替拖动帧率

Reviewed-on: #173
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
This commit was merged in pull request #173.
This commit is contained in:
2026-08-21 15:33:02 +08:00
committed by 段舒康
parent f25fb2a768
commit c39d63b65f
6 changed files with 881 additions and 224 deletions
@@ -287,6 +287,69 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('角色')).toBeTruthy();
});
it('only rerenders the moved image layer when other layer objects stay stable', () => {
const firstLayer = createLayer({
id: 'layer-first',
resourceId: 'resource-first',
title: '第一张图片',
zIndex: 1,
});
const movedLayer = createLayer({
id: 'layer-moved',
resourceId: 'resource-moved',
title: '移动图片',
zIndex: 2,
});
const lastLayer = createLayer({
id: 'layer-last',
resourceId: 'resource-last',
title: '最后一张图片',
zIndex: 3,
});
const { props, rerender } = renderWorldView({
layers: [firstLayer, movedLayer, lastLayer],
});
const initialReadUrlCallCount =
useResolvedAssetReadUrlMock.mock.calls.length;
rerender(
<ImageCanvasWorldView
{...props}
layers={[
firstLayer,
{ ...movedLayer, x: movedLayer.x + 24, y: movedLayer.y + 16 },
lastLayer,
]}
/>,
);
expect(useResolvedAssetReadUrlMock).toHaveBeenCalledTimes(
initialReadUrlCallCount + 1,
);
});
it('calls the latest layer callback without rerendering a stable layer', () => {
const layer = createLayer();
const firstOnLayerClick = vi.fn();
const latestOnLayerClick = vi.fn();
const { props, rerender } = renderWorldView({
layers: [layer],
onLayerClick: firstOnLayerClick,
});
rerender(
<ImageCanvasWorldView
{...props}
layers={[layer]}
onLayerClick={latestOnLayerClick}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '选择角色主图' }));
expect(latestOnLayerClick).toHaveBeenCalledWith(expect.any(Object), layer);
expect(firstOnLayerClick).not.toHaveBeenCalled();
});
it('shows editable kind tags for tagged and untagged layers', () => {
const layer = createLayer({ assetKind: null });
renderWorldView({
File diff suppressed because it is too large Load Diff
@@ -110,6 +110,7 @@ function asCanvasGenerationDialog(
}
function StageInteractionsHarness({
includeConcurrentLayerFixture = false,
pickCharacterSpecFromLayer = vi.fn(),
pickGenerationReferenceFromLayer = vi.fn(),
pickIconSpecFromLayer = vi.fn(),
@@ -122,6 +123,7 @@ function StageInteractionsHarness({
onViewportInteractionEnd = vi.fn(),
captureCanvasHistory = vi.fn(),
}: {
includeConcurrentLayerFixture?: boolean;
pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void;
pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void;
pickIconSpecFromLayer?: (layer: CanvasLayer) => void;
@@ -150,9 +152,12 @@ function StageInteractionsHarness({
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
const [layers, setLayers] = useState<CanvasLayer[]>([
const [layers, setLayers] = useState<CanvasLayer[]>(() => [
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
createLayer({ id: 'second', x: 220, y: 60, zIndex: 2 }),
...(includeConcurrentLayerFixture
? [createLayer({ id: 'removed', x: 900, y: 900, zIndex: 3 })]
: []),
]);
const [viewport, setViewport] = useState<CanvasViewport>({
x: 0,
@@ -331,6 +336,14 @@ function StageInteractionsHarness({
)
.join('|')}
</span>
<span data-testid="layer-details">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`,
)
.join('|')}
</span>
<span data-testid="viewport-state">
{viewport.x.toFixed(1)},{viewport.y.toFixed(1)},{viewport.scale}
</span>
@@ -351,6 +364,17 @@ function StageInteractionsHarness({
? `${generateDialog.placeholder.x.toFixed(1)},${generateDialog.placeholder.y.toFixed(1)}`
: '-'}
</span>
<span data-testid="dialog-details">
{generateDialog
? [
generateDialog.status,
generateDialog.prompt,
generateDialog.errorMessage ?? '-',
generateDialog.placeholder?.width ?? '-',
generateDialog.placeholder?.height ?? '-',
].join(':')
: '-'}
</span>
<span data-testid="dialog-selection-id">
{getCanvasGenerationSelectionId('dialog-1')}
</span>
@@ -440,6 +464,48 @@ function StageInteractionsHarness({
>
直接移动图层
</button>
<button
type="button"
onClick={() => {
[
{ clientX: 230, clientY: 70 },
{ clientX: 250, clientY: 90 },
{ clientX: 280, clientY: 120 },
].forEach((pointer) => {
interaction.handlePointerMove(
createPointerEvent(getViewportElement(), {
pointerId: 2,
...pointer,
}),
);
});
}}
>
连续移动图层
</button>
<button
type="button"
onClick={() => {
setLayers((currentLayers) => [
...currentLayers
.filter((layer) => layer.id !== 'removed')
.map((layer) =>
layer.id === 'first' || layer.id === 'second'
? { ...layer, title: `${layer.id}-concurrent` }
: layer,
),
createLayer({
id: 'added',
title: 'added-concurrent',
x: 640,
y: 360,
zIndex: 4,
}),
]);
}}
>
并发更新图层
</button>
<button
type="button"
onClick={() => {
@@ -589,6 +655,79 @@ function StageInteractionsHarness({
>
直接拖生成占位
</button>
<button
type="button"
onClick={() => {
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 24,
clientX: 300,
clientY: 200,
}),
dialog,
);
[
{ clientX: 320, clientY: 215 },
{ clientX: 340, clientY: 230 },
].forEach((pointer) => {
interaction.handlePointerMove(
createPointerEvent(getViewportElement(), {
pointerId: 24,
...pointer,
}),
);
});
}}
>
连续拖生成占位
</button>
<button
type="button"
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
status: 'failed',
prompt: '并发提示',
errorMessage: '并发失败',
placeholder: currentDialog.placeholder
? {
...currentDialog.placeholder,
width: 222,
height: 111,
}
: undefined,
}
: currentDialog,
);
}}
>
并发更新生成占位
</button>
<button
type="button"
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
status: 'failed',
prompt: '并发移除占位',
errorMessage: '占位已移除',
placeholder: undefined,
}
: currentDialog,
);
}}
>
并发移除生成占位
</button>
<button
type="button"
onClick={(event) => {
@@ -809,9 +948,7 @@ describe('useImageCanvasStageInteractions', () => {
it('selects and drags multiple layers from stage pointer events', () => {
const captureCanvasHistory = vi.fn();
render(
<StageInteractionsHarness
captureCanvasHistory={captureCanvasHistory}
/>,
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
);
act(() => {
@@ -871,9 +1008,7 @@ describe('useImageCanvasStageInteractions', () => {
it('keeps sub-threshold pointer jitter from moving layers or creating history', () => {
const captureCanvasHistory = vi.fn();
render(
<StageInteractionsHarness
captureCanvasHistory={captureCanvasHistory}
/>,
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
);
act(() => {
@@ -887,6 +1022,171 @@ describe('useImageCanvasStageInteractions', () => {
expect(captureCanvasHistory).not.toHaveBeenCalled();
});
it('coalesces high-frequency layer moves to the latest animation frame', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
const captureCanvasHistory = vi.fn();
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
animationFrameCallback = callback;
return 41;
});
render(
<StageInteractionsHarness captureCanvasHistory={captureCanvasHistory} />,
);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
});
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('layers').textContent).not.toContain(
'second:280.0,120.0',
);
act(() => {
animationFrameCallback?.(16);
});
expect(screen.getByTestId('layers').textContent).toContain(
'second:280.0,120.0',
);
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
});
it('preserves concurrent layer updates while applying a pending animation-frame move', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallback = callback;
return 44;
});
render(<StageInteractionsHarness includeConcurrentLayerFixture />);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
});
act(() => {
screen.getByRole('button', { name: '并发更新图层' }).click();
});
expect(screen.getByTestId('layer-details').textContent).toContain(
'second:second-concurrent:',
);
expect(screen.getByTestId('layer-details').textContent).not.toContain(
'second:second-concurrent:280.0,120.0',
);
expect(screen.getByTestId('layer-details').textContent).not.toContain(
'removed:',
);
act(() => {
animationFrameCallback?.(16);
});
const layerDetails = screen.getByTestId('layer-details').textContent;
expect(layerDetails).toContain('first:first-concurrent:40.0,40.0');
expect(layerDetails).toContain('second:second-concurrent:280.0,120.0');
expect(layerDetails).toContain('added:added-concurrent:640.0,360.0');
expect(layerDetails).not.toContain('removed:');
});
it('preserves concurrent generation fields while applying only pending placeholder coordinates', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallback = callback;
return 45;
});
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '连续拖生成占位' }).click();
});
act(() => {
screen.getByRole('button', { name: '并发更新生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
'320.0,215.0',
);
expect(screen.getByTestId('dialog-details').textContent).toBe(
'failed:并发提示:并发失败:222:111',
);
act(() => {
animationFrameCallback?.(16);
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
'340.0,230.0',
);
expect(screen.getByTestId('dialog-details').textContent).toBe(
'failed:并发提示:并发失败:222:111',
);
});
it('does not revive a generation placeholder removed before a pending animation-frame move', () => {
let animationFrameCallback: FrameRequestCallback | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallback = callback;
return 46;
});
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '连续拖生成占位' }).click();
});
act(() => {
screen.getByRole('button', { name: '并发移除生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe('-');
expect(screen.queryByTestId('generation-frame')).toBeNull();
act(() => {
animationFrameCallback?.(16);
});
expect(screen.getByTestId('dialog-position').textContent).toBe('-');
expect(screen.getByTestId('dialog-details').textContent).toBe(
'failed:并发移除占位:占位已移除:-:-',
);
expect(screen.queryByTestId('generation-frame')).toBeNull();
});
it('flushes the latest coalesced layer move before pointer release', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 42);
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
});
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
expect(screen.getByTestId('layers').textContent).toContain(
'second:280.0,120.0',
);
});
it('cancels a pending layer move frame when the stage unmounts', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 43);
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
const view = render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '连续移动图层' }).click();
});
view.unmount();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(43);
});
it('handles marquee, temporary hand mode, and generation frame dragging', () => {
const activateCanvasGenerationDialog = vi.fn();
render(
@@ -1028,7 +1328,7 @@ describe('useImageCanvasStageInteractions', () => {
);
});
it('marks viewport interactions for pan and minimap drags', () => {
it('marks layout interactions for layer, generation, pan, and minimap drags', () => {
const onViewportInteractionStart = vi.fn();
const onViewportInteractionEnd = vi.fn();
const flushMinimapViewportDrag = vi.fn();
@@ -1040,28 +1340,46 @@ describe('useImageCanvasStageInteractions', () => {
/>,
);
act(() => {
screen.getByRole('button', { name: '直接追加第二层' }).click();
screen.getByRole('button', { name: '直接移动图层' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(1);
act(() => {
screen.getByRole('button', { name: '直接结束图层拖拽' }).click();
});
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1);
act(() => {
screen.getByRole('button', { name: '直接拖生成占位' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(2);
act(() => {
screen.getByRole('button', { name: '清理交互' }).click();
});
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
act(() => {
screen.getByRole('button', { name: '切抓手' }).click();
});
act(() => {
screen.getByRole('button', { name: '直接开始平移' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(1);
expect(onViewportInteractionStart).toHaveBeenCalledTimes(3);
act(() => {
screen.getByRole('button', { name: '清理交互' }).click();
});
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(1);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(3);
act(() => {
screen.getByRole('button', { name: '直接拖小地图' }).click();
});
expect(onViewportInteractionStart).toHaveBeenCalledTimes(2);
expect(onViewportInteractionStart).toHaveBeenCalledTimes(4);
act(() => {
screen.getByRole('button', { name: '直接结束小地图拖拽' }).click();
});
expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(4);
});
it('allows middle-button dragging and rejects right-button dragging in select, hand, and Space modes', () => {