优化图片画布拖动性能 (#173)
## 修改 - 按动画帧合并高频拖动更新,结束时持久化最终状态 - 对未移动图层子树做 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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user