️ perf(window): 降低空闲状态原生轮询频率

This commit is contained in:
Syngnat
2026-07-22 01:10:56 +08:00
parent f64c4a4b98
commit 074f695e15
4 changed files with 345 additions and 40 deletions

View File

@@ -459,11 +459,36 @@ describe('settings center tool entries', () => {
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
expect(appSource).toContain('scheduleWindowBoundsRepair();');
expect(appSource).toContain('scheduleWindowStateSave(260);');
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });");
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
expect(appSource).toContain('const cleanupWindowActivityScheduler = installNativeWindowActivityScheduler({');
expect(appSource).toContain('fallbackIntervalMs: WINDOW_STATE_FALLBACK_INTERVAL_MS,');
expect(appSource).toContain('resize: handleWindowRuntimeChange,');
expect(appSource).toContain('focus: handleWindowRuntimeChange,');
expect(appSource).toContain('pageshow: handleWindowRuntimeChange,');
expect(appSource).toContain('pagehide: handleWindowLifecycleFlush,');
expect(appSource).toContain('beforeunload: handleWindowLifecycleFlush,');
expect(appSource).toContain('cleanupWindowActivityScheduler();');
});
it('keeps the resize minimise probe independent from DPR debounce and clears it on unmount', () => {
const scaleEffectStart = appSource.indexOf('let minimisedCheckTimer: number | null = null;');
const dprScheduleStart = appSource.indexOf('const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {', scaleEffectStart);
const activationScheduleStart = appSource.indexOf('const scheduleActivationFix = () => {', dprScheduleStart);
const resizeHandlerStart = appSource.indexOf('const handleWindowResize = () => {', activationScheduleStart);
const startupFixStart = appSource.indexOf('// Windows 冷启动:', resizeHandlerStart);
const schedulerStart = appSource.indexOf('fallbackIntervalMs: WINDOWS_SCALE_FALLBACK_INTERVAL_MS,', startupFixStart);
const cleanupStart = appSource.indexOf('return () => {', schedulerStart);
const cleanupEnd = appSource.indexOf('cleanupWindowActivityScheduler();', cleanupStart);
expect([scaleEffectStart, dprScheduleStart, activationScheduleStart, resizeHandlerStart, startupFixStart, schedulerStart, cleanupStart, cleanupEnd]
.every((index) => index >= 0)).toBe(true);
expect(appSource.slice(dprScheduleStart, activationScheduleStart)).not.toContain('minimisedCheckTimer');
const resizeHandlerSource = appSource.slice(resizeHandlerStart, startupFixStart);
const minimiseProbeIndex = resizeHandlerSource.indexOf('rememberMinimisedStateSoon();');
const dprCheckIndex = resizeHandlerSource.indexOf("scheduleDevicePixelRatioCheck('resize');");
expect(minimiseProbeIndex).toBeGreaterThan(-1);
expect(dprCheckIndex).toBeGreaterThan(minimiseProbeIndex);
expect(appSource.slice(cleanupStart, cleanupEnd)).toContain('window.clearTimeout(minimisedCheckTimer);');
expect(appSource.slice(cleanupStart, cleanupEnd)).toContain('minimisedCheckTimer = null;');
});
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {

View File

@@ -171,7 +171,18 @@ import {
resolveDockedActiveTabId,
type CloseShortcutScope,
} from './utils/closeTabShortcut';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldResetWebViewZoomForScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowScaleFixReason, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import {
installNativeWindowActivityScheduler,
resolveTitleBarToggleIconKey,
resolveWindowsScaleCheckDelayMs,
shouldApplyWindowsScaleFix,
shouldResetWebViewZoomForScaleFix,
shouldToggleMaximisedWindowForScaleFix,
WINDOW_STATE_FALLBACK_INTERVAL_MS,
WINDOWS_SCALE_FALLBACK_INTERVAL_MS,
type WindowScaleFixReason,
type WindowsScaleCheckTrigger,
} from './utils/windowStateUi';
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
import { resolveWailsWindowVisibleViewport } from './utils/wailsWindowViewport';
import {
@@ -1472,7 +1483,6 @@ function App() {
// 定时保存窗口状态、尺寸与位置
useEffect(() => {
const SAVE_INTERVAL_MS = 2000;
let cancelled = false;
let hydrated = useStore.persist.hasHydrated();
let eventSaveTimer: number | null = null;
@@ -1645,15 +1655,22 @@ function App() {
scheduleWindowStateSave(320);
});
const timer = window.setInterval(() => {
void saveWindowState();
}, SAVE_INTERVAL_MS);
window.addEventListener('resize', handleWindowRuntimeChange);
window.addEventListener('focus', handleWindowRuntimeChange);
window.addEventListener('pageshow', handleWindowRuntimeChange);
window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });
window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
const cleanupWindowActivityScheduler = installNativeWindowActivityScheduler({
windowTarget: window,
documentTarget: document,
fallbackIntervalMs: WINDOW_STATE_FALLBACK_INTERVAL_MS,
onFallback: () => {
void saveWindowState();
},
handlers: {
resize: handleWindowRuntimeChange,
focus: handleWindowRuntimeChange,
pageshow: handleWindowRuntimeChange,
pagehide: handleWindowLifecycleFlush,
beforeunload: handleWindowLifecycleFlush,
visibilitychange: handleVisibilityChange,
},
});
return () => {
cancelled = true;
if (eventSaveTimer !== null) {
@@ -1662,13 +1679,7 @@ function App() {
if (boundsRepairTimer !== null) {
window.clearTimeout(boundsRepairTimer);
}
window.clearInterval(timer);
window.removeEventListener('resize', handleWindowRuntimeChange);
window.removeEventListener('focus', handleWindowRuntimeChange);
window.removeEventListener('pageshow', handleWindowRuntimeChange);
window.removeEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });
window.removeEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });
document.removeEventListener('visibilitychange', handleVisibilityChange);
cleanupWindowActivityScheduler();
unsubscribeHydration();
};
}, []);
@@ -1684,6 +1695,7 @@ function App() {
let lastFixAt = 0;
let activationTimer: number | null = null;
let resizeTimer: number | null = null;
let minimisedCheckTimer: number | null = null;
let minimisedSeen = false;
let hiddenSeen = document.visibilityState === 'hidden';
@@ -1802,7 +1814,11 @@ function App() {
};
const rememberMinimisedStateSoon = () => {
window.setTimeout(() => {
if (minimisedCheckTimer !== null) {
window.clearTimeout(minimisedCheckTimer);
}
minimisedCheckTimer = window.setTimeout(() => {
minimisedCheckTimer = null;
if (cancelled) return;
void rememberMinimisedState();
}, 120);
@@ -1895,10 +1911,6 @@ function App() {
scheduleDevicePixelRatioCheck('resize');
};
const pollTimer = window.setInterval(() => {
void rememberMinimisedState();
checkDevicePixelRatio();
}, 900);
// Windows 冷启动WebView2 首次布局常只铺满左上角一部分,任务栏恢复才会走 restore 修复。
// 这里在启动后主动按 startup 原因做几次轻量 settle避免用户必须双击任务栏。
// 间隔需大于 fixWindowScaleIfNeeded 的 700ms 节流,确保多次都能真正执行。
@@ -1908,11 +1920,22 @@ function App() {
void fixWindowScaleIfNeeded('startup');
}, delayMs)
));
window.addEventListener('resize', handleWindowResize);
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('blur', handleWindowBlur);
window.addEventListener('pageshow', handlePageShow);
document.addEventListener('visibilitychange', handleVisibilityChange);
const cleanupWindowActivityScheduler = installNativeWindowActivityScheduler({
windowTarget: window,
documentTarget: document,
fallbackIntervalMs: WINDOWS_SCALE_FALLBACK_INTERVAL_MS,
onFallback: () => {
void rememberMinimisedState();
checkDevicePixelRatio();
},
handlers: {
resize: handleWindowResize,
focus: handleWindowFocus,
blur: handleWindowBlur,
pageshow: handlePageShow,
visibilitychange: handleVisibilityChange,
},
});
return () => {
cancelled = true;
@@ -1922,15 +1945,14 @@ function App() {
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
}
if (minimisedCheckTimer !== null) {
window.clearTimeout(minimisedCheckTimer);
minimisedCheckTimer = null;
}
for (const timer of startupLayoutFixTimers) {
window.clearTimeout(timer);
}
window.clearInterval(pollTimer);
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('blur', handleWindowBlur);
window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
cleanupWindowActivityScheduler();
};
}, []);

View File

@@ -1,14 +1,182 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
installNativeWindowActivityScheduler,
resolveTitleBarToggleIconKey,
resolveWindowsScaleCheckDelayMs,
shouldApplyWindowsScaleFix,
shouldResetWebViewZoomForScaleFix,
shouldToggleMaximisedWindowForScaleFix,
WINDOW_STATE_FALLBACK_INTERVAL_MS,
WINDOWS_SCALE_FALLBACK_INTERVAL_MS,
} from './windowStateUi';
class FakeWindowActivityTarget {
private readonly listeners = new Map<string, Set<EventListener>>();
private readonly intervals = new Map<number, TimerHandler>();
private nextTimerId = 1;
readonly listenerCapture = new Map<string, boolean>();
readonly intervalDelays: number[] = [];
addEventListener(type: string, listener: EventListener, capture = false) {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
this.listenerCapture.set(type, capture);
}
removeEventListener(type: string, listener: EventListener) {
this.listeners.get(type)?.delete(listener);
}
setInterval(handler: TimerHandler, delayMs?: number) {
const timerId = this.nextTimerId++;
this.intervals.set(timerId, handler);
this.intervalDelays.push(Number(delayMs));
return timerId;
}
clearInterval(timerId: number) {
this.intervals.delete(timerId);
}
dispatch(type: string) {
for (const listener of this.listeners.get(type) ?? []) {
listener(new Event(type));
}
}
tickFallbacks() {
for (const handler of this.intervals.values()) {
if (typeof handler === 'function') handler();
}
}
get activeIntervalCount() {
return this.intervals.size;
}
}
class FakeDocumentActivityTarget {
visibilityState: DocumentVisibilityState = 'visible';
private readonly listeners = new Map<string, Set<EventListener>>();
addEventListener(type: string, listener: EventListener) {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: EventListener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string) {
for (const listener of this.listeners.get(type) ?? []) {
listener(new Event(type));
}
}
}
describe('windowStateUi', () => {
it('keeps 10-30 seconds of visible idle native-window fallback IPC within budget', () => {
const normalWindowStateCallsPerTick = 4;
const windowsScaleCallsPerTick = 1;
const countNativeIpcCalls = (idleDurationMs: number) => (
Math.floor(idleDurationMs / WINDOW_STATE_FALLBACK_INTERVAL_MS) * normalWindowStateCallsPerTick
+ Math.floor(idleDurationMs / WINDOWS_SCALE_FALLBACK_INTERVAL_MS) * windowsScaleCallsPerTick
);
expect(countNativeIpcCalls(10_000)).toBe(1);
expect(countNativeIpcCalls(30_000)).toBe(11);
});
it('uses activity events while suppressing native fallback work on hidden pages', () => {
const windowTarget = new FakeWindowActivityTarget();
const documentTarget = new FakeDocumentActivityTarget();
const onResize = vi.fn();
const onFocus = vi.fn();
const onPageShow = vi.fn();
const onVisibilityChange = vi.fn();
const onPageHide = vi.fn();
const onBeforeUnload = vi.fn();
const onFallback = vi.fn();
const cleanup = installNativeWindowActivityScheduler({
windowTarget: windowTarget as unknown as Window,
documentTarget: documentTarget as unknown as Document,
fallbackIntervalMs: WINDOW_STATE_FALLBACK_INTERVAL_MS,
onFallback,
handlers: {
resize: onResize,
focus: onFocus,
pageshow: onPageShow,
pagehide: onPageHide,
beforeunload: onBeforeUnload,
visibilitychange: onVisibilityChange,
},
});
expect(windowTarget.intervalDelays).toEqual([WINDOW_STATE_FALLBACK_INTERVAL_MS]);
windowTarget.dispatch('resize');
windowTarget.dispatch('focus');
windowTarget.dispatch('pageshow');
documentTarget.dispatch('visibilitychange');
windowTarget.dispatch('pagehide');
windowTarget.dispatch('beforeunload');
expect([onResize, onFocus, onPageShow, onVisibilityChange, onPageHide, onBeforeUnload]
.map((handler) => handler.mock.calls.length))
.toEqual([1, 1, 1, 1, 1, 1]);
expect(windowTarget.listenerCapture.get('pagehide')).toBe(true);
expect(windowTarget.listenerCapture.get('beforeunload')).toBe(true);
documentTarget.visibilityState = 'hidden';
documentTarget.dispatch('visibilitychange');
expect(windowTarget.activeIntervalCount).toBe(0);
windowTarget.tickFallbacks();
expect(onFallback).not.toHaveBeenCalled();
documentTarget.visibilityState = 'visible';
documentTarget.dispatch('visibilitychange');
expect(windowTarget.activeIntervalCount).toBe(1);
windowTarget.tickFallbacks();
expect(onFallback).toHaveBeenCalledTimes(1);
cleanup();
windowTarget.dispatch('focus');
documentTarget.dispatch('visibilitychange');
windowTarget.tickFallbacks();
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onVisibilityChange).toHaveBeenCalledTimes(3);
expect(onFallback).toHaveBeenCalledTimes(1);
});
it('keeps Windows scale fallback dormant while the document is hidden', () => {
const windowTarget = new FakeWindowActivityTarget();
const documentTarget = new FakeDocumentActivityTarget();
const onFallback = vi.fn();
documentTarget.visibilityState = 'hidden';
const cleanup = installNativeWindowActivityScheduler({
windowTarget: windowTarget as unknown as Window,
documentTarget: documentTarget as unknown as Document,
fallbackIntervalMs: WINDOWS_SCALE_FALLBACK_INTERVAL_MS,
onFallback,
handlers: {},
});
expect(windowTarget.intervalDelays).toEqual([]);
windowTarget.tickFallbacks();
expect(onFallback).not.toHaveBeenCalled();
documentTarget.visibilityState = 'visible';
documentTarget.dispatch('visibilitychange');
expect(windowTarget.intervalDelays).toEqual([WINDOWS_SCALE_FALLBACK_INTERVAL_MS]);
windowTarget.tickFallbacks();
expect(onFallback).toHaveBeenCalledTimes(1);
cleanup();
});
it('does not re-toggle a maximized window on activation when focus returns', () => {
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
});

View File

@@ -3,6 +3,96 @@ export type WindowScaleFixReason = 'activation' | 'ratio-change' | 'restore' | '
export type WindowsScaleCheckTrigger = 'focus' | 'pageshow' | 'poll' | 'resize' | 'visibilitychange';
export type TitleBarToggleIconKey = 'maximize' | 'restore';
// resize/focus/pageshow/visibility 生命周期事件承担实时同步;轮询只作为 Wails
// 未上报“仅移动窗口”等边缘场景的低频容错,避免空闲窗口持续跨 JS/Go 边界。
export const WINDOW_STATE_FALLBACK_INTERVAL_MS = 15_000;
export const WINDOWS_SCALE_FALLBACK_INTERVAL_MS = 10_000;
type NativeWindowActivityEventHandler = () => void;
export interface NativeWindowActivitySchedulerHandlers {
resize?: NativeWindowActivityEventHandler;
focus?: NativeWindowActivityEventHandler;
blur?: NativeWindowActivityEventHandler;
pageshow?: NativeWindowActivityEventHandler;
pagehide?: NativeWindowActivityEventHandler;
beforeunload?: NativeWindowActivityEventHandler;
visibilitychange?: NativeWindowActivityEventHandler;
}
export interface NativeWindowActivitySchedulerOptions {
windowTarget: Window;
documentTarget: Document;
fallbackIntervalMs: number;
onFallback: NativeWindowActivityEventHandler;
handlers: NativeWindowActivitySchedulerHandlers;
}
export const installNativeWindowActivityScheduler = ({
windowTarget,
documentTarget,
fallbackIntervalMs,
onFallback,
handlers,
}: NativeWindowActivitySchedulerOptions): (() => void) => {
const windowListeners: Array<{
type: keyof Pick<WindowEventMap, 'resize' | 'focus' | 'blur' | 'pageshow' | 'pagehide' | 'beforeunload'>;
listener: EventListener;
capture: boolean;
}> = [];
const addWindowListener = (
type: keyof Pick<WindowEventMap, 'resize' | 'focus' | 'blur' | 'pageshow' | 'pagehide' | 'beforeunload'>,
handler: NativeWindowActivityEventHandler | undefined,
capture = false,
) => {
if (!handler) return;
const listener: EventListener = () => handler();
windowTarget.addEventListener(type, listener, capture);
windowListeners.push({ type, listener, capture });
};
addWindowListener('resize', handlers.resize);
addWindowListener('focus', handlers.focus);
addWindowListener('blur', handlers.blur);
addWindowListener('pageshow', handlers.pageshow);
addWindowListener('pagehide', handlers.pagehide, true);
addWindowListener('beforeunload', handlers.beforeunload, true);
let fallbackTimer: number | null = null;
const stopFallback = () => {
if (fallbackTimer === null) return;
windowTarget.clearInterval(fallbackTimer);
fallbackTimer = null;
};
const startFallback = () => {
if (fallbackTimer !== null || documentTarget.visibilityState !== 'visible') return;
fallbackTimer = windowTarget.setInterval(() => {
if (documentTarget.visibilityState === 'visible') {
onFallback();
}
}, fallbackIntervalMs);
};
const visibilityListener: EventListener = () => {
if (documentTarget.visibilityState === 'visible') {
startFallback();
} else {
stopFallback();
}
handlers.visibilitychange?.();
};
documentTarget.addEventListener('visibilitychange', visibilityListener);
startFallback();
return () => {
stopFallback();
for (const { type, listener, capture } of windowListeners) {
windowTarget.removeEventListener(type, listener, capture);
}
documentTarget.removeEventListener('visibilitychange', visibilityListener);
};
};
export const shouldApplyWindowsScaleFix = (
reason: WindowScaleFixReason,
hasViewportScaleDrift: boolean,