本文首发同名微信公众号:前端徐徐
大家好,我是徐徐,今天我们聊聊如何在 Electron 中优雅得进行窗口管理。
前言
在 Electron 应用中,我们经常会遇到新窗口打开显示缓慢的问题,大多数解决方案是先把要打开的窗口先隐藏起来,然后在需要打开的时候再展示出来,不得不说这个是一个简单高效的方法。但是这样会面临两个问题,第一个问题是如果用户不打开新窗口,那么就会有多余的进程消耗 CPU 和 内存;第二个问题是窗口只有显示和隐藏,所有隐藏窗口的状态不会按需得到初始化和销毁(当然可以通过事件监听或者主进程发送消息的方式,这样负担有点重),基于上面的场景和问题便有了窗口池的概念。
窗口池原理
窗口池的原理的确与线程池和数据库连接池类似,都是通过预先创建一定数量的资源来提升系统的响应速度和资源利用率。具体来说,窗口池的工作流程如下:
- 初始化窗口池:
-
- 在应用程序启动时,预先创建
n
个隐藏的窗口,这些窗口加载一个空白页面。如果使用的是 Vue 或 React,可以在这些窗口中提前初始化实例,甚至渲染自定义的窗口标题栏,只是不渲染内容区域。 - 这些窗口被隐藏,并且保存在一个池子中,等待被使用。
- 在应用程序启动时,预先创建
- 请求窗口:
-
- 当用户需要一个窗口时,程序从窗口池中取出一个备用窗口。
- 将该窗口的内容区域路由到用户指定的页面,并显示该窗口。
- 由于窗口已经初始化,内容区域的路由跳转非常快,一般不会超过0.5秒。
- 释放窗口:
-
- 当用户关闭窗口时,窗口实例被释放。
- 程序监听窗口的关闭事件,一旦检测到一个窗口被释放,就马上创建一个新的隐藏窗口补充到窗口池中,以确保窗口池中始终有
n
个备用窗口。
先看效果
我们分别用两种方式打开同一个窗口,每隔 1s 打开一次,连续打开10次。分别看看使用窗口池和不使用窗口池的时间差。
- 使用窗口池
const openNewWindow = () => {
let count = 0;
const openWindows = () => {
if (count < 10) {
Log4.info("openNewWindow");
window.electronAPI.openNewWindow("/testWindow");
count += 1;
setTimeout(openWindows, 1000);
}
};
openWindows();
};
ipcMain.handle('openNewWindow', (event: IpcMainInvokeEvent, url: string) => {
openWindow({
width: 800,
height: 600,
webPreferences: {
preload: join(__dirname, "../preload/index.cjs"),
},
url,
brandNew: true,
});
})
平均时间为 13 ms
- 不使用窗口池
const openNewWindowByDefaultHandle = () => {
let count = 0;
const openWindows = () => {
if (count < 10) {
Log4.info("openNewWindowByDefaultHandle");
window.electronAPI.openNewWindowByDefaultHandle("/testWindow");
count += 1;
setTimeout(openWindows, 1000);
}
};
openWindows();
};
ipcMain.handle('openNewWindowByDefaultHandle', (event: IpcMainInvokeEvent, url: string) => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: join(__dirname, "../preload/index.cjs"),
},
});
const newUrl = getOpenUrl(url)
win.loadURL(newUrl);
})
平均时间为 218.4 ms
核心实现
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import { join,resolve } from 'path';
export type WindowPoolOptions = BrowserWindowConstructorOptions & {
url: string,
brandNew?: boolean,
}
export const getOpenUrl = (url:string) => {
const baseUrl =
import.meta.env.MODE === "dev"
? import.meta.env.VITE_DEV_SERVER_URL
: `file://${resolve(__dirname, "../render/index.html")}`;
const newUrl = url.startsWith('http') || url.startsWith('https')? url : `${baseUrl}#${url}`;
return newUrl;
}
class WindowPoolManager {
private static instance: WindowPoolManager;
private windowPoolSize: number;
private windowPools: Map<string, BrowserWindow>;
private constructor(poolSize: number) {
this.windowPoolSize = poolSize;
this.windowPools = new Map<string, BrowserWindow>();
this.initPool();
}
public static getInstance(poolSize: number = 2): WindowPoolManager {
if (!WindowPoolManager.instance) {
WindowPoolManager.instance = new WindowPoolManager(poolSize);
}
return WindowPoolManager.instance;
}
private createPoolWindow(windowOptions: WindowPoolOptions): { id: string, win: BrowserWindow } {
const win = new BrowserWindow({ ...windowOptions, show: false });
const idData = windowOptions.url;
const newUrl = getOpenUrl(windowOptions.url);
win.loadURL(newUrl);
this.windowPools.set(idData, win);
return {
id: idData,
win,
};
}
private initPool() {
for (let i = 0; i < this.windowPoolSize; i++) {
this.createPoolWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
},
url: `/preWindow/${i}`, // 使用占位符 URL
});
}
}
private usePreWindow(): { id: string, win: BrowserWindow } | null {
const preWindowKey = Array.from(this.windowPools.keys()).find((key) => key.startsWith('/preWindow') && this.windowPools.get(key)?.isVisible() === false);
if (preWindowKey) {
const preWindow = this.windowPools.get(preWindowKey);
if (preWindow) {
this.windowPools.delete(preWindowKey);
return {
id: preWindowKey,
win: preWindow,
};
}
}
return null;
}
public openWindow(windowOptions: WindowPoolOptions): BrowserWindow {
let windowEntry;
const idData = windowOptions.url;
if (idData && this.windowPools.has(idData) && !windowOptions.brandNew) {
const win = this.windowPools.get(idData);
if (win) {
win.show();
win.moveTop();
win.focus();
return win;
}
} else {
windowEntry = this.usePreWindow();
}
if (!windowEntry) {
windowEntry = this.createPoolWindow(windowOptions);
}
const { win } = windowEntry;
win.show();
win.moveTop();
win.focus();
win.loadURL(windowOptions.url);
this.windowPools.set(idData, win);
win.on('closed', () => {
this.windowPools.delete(idData);
this.ensurePreWindowPool();
})
this.ensurePreWindowPool();
return win;
}
private ensurePreWindowPool() {
const currentPreWindowCount = Array.from(this.windowPools.keys()).filter((key) => key.startsWith('/preWindow')).length;
if (currentPreWindowCount < this.windowPoolSize) {
this.createPoolWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
},
url: `/preWindow/${currentPreWindowCount + 1}`,
});
}
}
}
export default WindowPoolManager;
整段代码实现了一个单例模式的WindowPoolManager
类,目的是通过维护一个窗口池来管理 Electron 应用中的窗口,从而优化性能。该类在初始化时根据指定的池大小创建一定数量的预置窗口,这些窗口初始状态为不可见。通过这种方式,可以在需要新窗口时首先尝试复用这些预置窗口,避免频繁的创建和销毁窗口。
WindowPoolManager
类提供了多个关键方法,包括initPool
用于初始化窗口池、createPoolWindow
用于创建新窗口、usePreWindow
用于查找并复用预置窗口、openWindow
用于打开新的或复用已有的窗口,以及ensurePreWindowPool
用于确保池中始终有足够数量的预置窗口。在打开窗口时,如果有符合条件的预置窗口,则会重用该窗口,否则创建新的窗口并将其添加到池中。
整个设计有效地提升了窗口管理的效率和性能,特别适用于需要频繁创建和销毁窗口的场景。
结语
通过窗口池的设计,的确显著提高了 Electron 应用中窗口管理的效率和性能。在实际应用中,尤其是在需要频繁创建和销毁窗口的场景下,采用窗口池可以有效减少窗口的初始化时间,避免了每次打开新窗口时都需要重新加载和渲染内容的开销。与传统的每次创建新窗口的方法相比,窗口池方法不仅能够加速窗口的展示,还能更好地利用系统资源,减少 CPU 和内存的浪费。
通过本文的实现和验证,我们可以看到使用窗口池后,打开窗口的时间显著缩短,尤其在连续操作时,时间差更为明显。无论是桌面应用还是 Electron 的后台管理系统,窗口池模式都是一种非常值得应用的优化策略,可以极大提升用户体验,特别是在需要高频次窗口切换的场景中。
希望这篇文章能够帮助到想要优化 Electron 窗口加载优化的朋友。
源码
https://github.com/Xutaotaotao/electron-proplay/tree/feature-window