窗口池
创建Electron窗口慢的应对方案:窗口池(一)
《深入浅出Electron》与《Electron实战》作者
已关注
10 人赞同了该文章
缘起
很多Electron的开发者已经意识到这个问题了:创建并显示一个Electron的BrowserWindow很慢,在一些低配机型上往往需要2秒或者更长的时间,这个问题使得很多Electron应用都只有一个窗口,需要用子窗口时,都用HTML DOM模拟。然而,Dom模拟的窗口必须在父窗口容器内,是没办法脱离父窗口存在的。所以还得回头想办法解决BrowserWindow慢的问题。
BrowserWindow之所以慢,主要是两个问题导致的,第一个就是Electron内部在创建BrowserWindow对象时做了很多初始化工作,如果你要让渲染进程拥有Node的能力的话,那么它还会给你初始化一个Node的环境。第二个是对于一个全新的BrowserWindow对象来说首次加载并渲染HTML页面比较慢;最主要的还是第二个原因。
无论如何想要从根源上解决这个问题是非常难的,所以我们得想个迂回的办法来解决它。
思路
操作线程有线程池,操作数据库连接有连接池,我们为什么不能搞个窗口池呢?
提前准备N个隐藏窗口,让他们都加载一个骨架屏页面,放到一个池子里,当用到时,就从池子里捞一个窗口,执行页内跳转,跳转到业务页面,然后显示出来,紧接着马上再在池子里放一个新的加载了骨架屏页面的备用窗口。当用掉的窗口关闭时,就把它从池子里删除掉。这样就能保证池子里一直有N个窗口待命。而且页内跳转的效率很高,基本上就解决了我们的问题。
思路很简单,实际上还是有很多细节值得关注的。
实现
我们的窗口池就是一个数组:
items: WindowPoolItem[] = []
WindowPoolItem是描述窗口池内的窗口的类,后面我们会讲它的实现
初始化窗口池的方法如下:
init() {
for (let i = 0; i < 3; i++) {
this.items.push(new WindowPoolItem())
}
ipcMain.handle('loadWindow', (e, data) => {
if (this.isWindowInUse(data)) return
this.picAndUse(data)
})
}
在这个方法内,我们给窗口池创建了3个备用窗口;并监听了一个名为loadWindow的跨进程消息,在主进程收到这个消息后,执行了两个方法,我们先来看isWindowInUse
private isWindowInUse(param: WindowParam) {
let item = this.items.find((v) => v.param?.url === param.url)
if (!item) return false
item.effectParam(param)
return true
}
这个方法负责查找窗口池内是不是有一个相同url的窗口,如果没有,则返回false;
如果有,那么重新设置一下这个窗口的行为,并返回true;
有的时候虽然url相同,但窗口是不是resizable,窗口的位置是不是要改变,这些行为可能会发生变化,所以要再次effectParam
如果没有找到相同url的窗口,那么就要执行picAndUse方法了,
private picAndUse(param: WindowParam) {
let item = this.items.find((v) => !v.param) //没有param属性的,就是没用过的
item!.use(param)
this.items.push(new WindowPoolItem())
}
在这个方法中,我们在窗口池中找一个没被使用的窗口(也就是param属性为undefined的WindowPoolItem对象),找到后调用它的use方法,紧接着在创建一个WindowPoolItem对象,把它加入窗口池中。这样就做到了消费一个,随即马上创建一个的原则,让窗口池中始终有待命的窗口。
有些人可能会问,既然消费掉一个就马上补一个,那么为什么我们一开始的时候创建了3个待命的窗口,难道不是创建一个就可以了吗?
这是因为有些特殊场景,用户可能在短时间内,连续消费掉2个窗口,这就会导致待命的窗口全部被用掉了,新窗口还没创建出来。本文开篇时提到的创建BrowserWindow慢的问题又显现出来了。
当然创建3个待命窗口对于你的应用来说可能不是最佳的值,你应该根据你的用户的环境来判断创建几个窗口合适,值得注意的是,一个窗口就是一个进程,会消耗用户的CPU核内存资源,所以并不是待命窗口越多越好。
WindowPoolItem是窗口池内的窗口对象,它有两个属性,一个属性时一个BrowserWindow对象,另一个属性时WindowParam对象(WindowParam我们稍后介绍)
win: BrowserWindow
param?: WindowParam
在WindowPoolItem类型的构造函数中,我们实例化了那个BrowserWindow对象,并让它加载了一个空白页面。如下代码所示:
constructor() {
let config = new ConfigWindow()
this.win = new BrowserWindow(config)
this.initEvent()
loadUrl(this.win, '/blank')
}
其中ConfigWindow并没什么业务,只是对BrowserWindow属性的一个简单封装,提供了一些参数的默认值。如下代码所示:
import { BrowserWindowConstructorOptions } from 'electron'
import { ConfigWebPreferences } from './ConfigWebPreferences'
export class ConfigWindow implements BrowserWindowConstructorOptions {
width?: number
height?: number
maximizable = true
resizable = true
center = true
x?: number
y?: number
alwaysOnTop?: boolean
skipTaskbar?: boolean
frame = false
show = false
webPreferences = new ConfigWebPreferences()
nodeIntegrationInSubFrames = true
nativeWindowOpen = true
momodalable = false
parent?: any
movable = true
thickFrame = true
minHeight?: number
minWidth?: number
}
这里又涉及到ConfigWebPreferences类型,它是对webPreferences属性的简单封装,如下所示:
import { WebPreferences } from 'electron'
export class ConfigWebPreferences implements WebPreferences {
nodeIntegration = true
devTools = true
webSecurity = false
nodeIntegrationInSubFrames = true
nodeIntegrationInWorker = true
worldSafeExecuteJavaScript = true
contextIsolation = false
allowRunningInsecureContent = true
center = true
webgl = false
disableHtmlFullscreenWindowResize = true
enableWebSQL = false
spellcheck = false
}
加载空白页面的实现逻辑如下所示:
private getTotalUrl(url: string) {
if (process.env.ENV_NOW === 'dev') {
return `http://localhost:${process.env.WEB_PORT}/#${url}`
} else if (process.env.ENV_NOW === 'test') {
return `apptest://./index.html/#${url}`
} else {
return `app://./index.html/#${url}`
}
}
loadUrl(win: BrowserWindow, url: string) {
//Electron issue https://github.com/electron/electron/issues/28208#issuecomment-870989112
let totalUrl = this.getTotalUrl(url)
win.webContents.loadURL(totalUrl)
}
这两个方法负责根据当前用户的环境变量组装页面的绝对地址,并让窗口加载这个地址。
initEvent方法的实现逻辑
private initEvent() {
this.win.on('close', () => {
for (let i = 0; i < windowPool.items.length; i++) {
if (windowPool.items[i].param?.url === this.param?.url) {
windowPool.items.splice(i, 1)
}
}
})
this.win.on('maximize', () => {
this.win.webContents.send('windowMaximized')
})
this.win.on('unmaximize', (e) => {
this.win.webContents.send('windowUnmaximized')
})
}
在窗口最大化或者取消最大化的时候,向渲染进程发送了windowMaximized和windowUnmaximized消息,给渲染进程切换最大化图标的机会。
在窗口关闭的时候,根据窗口的URL,找到这个窗口,并把它从窗口池中删掉。
当消费一个窗口的时候,执行use方法
public use(param: WindowParam) {
this.effectParam(param)
loadUrl(this.win, param.url)
}
这个方法首先是窗口参数生效,接着让窗口加载参数中指定的url
使窗口参数生效的方法effectParam代码如下:
effectParam(param: WindowParam) {
this.param = param
this.win.setSize(this.param!.size.width, this.param!.size.height)
this.win.setAlwaysOnTop(this.param!.alwaysTop || false)
if (this.param!.position) this.win.setPosition(this.param!.position.x, this.param!.position.y)
else this.win.center()
this.controlSize()
this.win.moveTop()
this.win.show()
}
这个方法中首先把窗口参数记下来,查找窗口时要用到这个参数对象;
接着设置窗口的一系列属性。其中设置窗口大小的逻辑比较特殊,我们把它抽象成了一个独立的方法:
private controlSize() {
if (this.param?.resizable === false) {
this.win.setResizable(false)
return
} else if (this.param?.minSize) {
this.win.setMinimumSize(this.param.minSize.width, this.param.minSize.height)
this.win.setResizable(true)
return
}
this.win.setResizable(true)
this.win.setMinimumSize(200, 150)
}
如果一个窗口不允许改变大小,那么很显然它的窗口标题栏的最大化和还原按钮是不能显示的,此处我们在渲染进程标题栏组件里获取了窗口的resizable的状态
let { ipcRenderer } = require('electron')
showMaxmizeBtn.value = await ipcRenderer.invoke('resizable')
主进程对应的处理逻辑如下
ipcMain.handle('resizable', (e) => {
return BrowserWindow.fromWebContents(e.sender)?.isResizable()
})
当showMaxmizeBtn.value的值是false时,不显示最大化和还原按钮。
至此我们窗口池的核心逻辑就都讲完了
但还有两个问题:
1:这个窗口池里的窗口时不支持模态窗口的
2:当用户点了关闭按钮,事件还没传递到主进程,主进程的关闭回调还没触发,此时用户马上又要创建一个同url的窗口,这是会出问题的。
我们在后续的文章里再解决这些问题。