️ 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

@@ -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,