{T}

如何管控应用的窗口(上)?

在上一节中我们为工程引入了 vue-router 组件,而且制定了一套工程代码的管控规则,这使得我们可以更好地组织工程的代码。接下来我就依据这个规则为每个窗口创建不同的路由页面,比如:src\renderer\Window\WindowMain.vue就是我们的主窗口页面。

本节我们讲解的重点不是窗口内的业务逻辑,而是如何管控应用内的窗口,比如:什么时候显示窗口、如何通过自定义窗口标题栏来管控单个窗口等内容。

除此之外,我们还介绍了一个困扰业界很多年的问题:窗口加载过慢的问题及其应对的解决方案。

什么时候显示主窗口

引入vue-router组件之后,我们要先着手解决窗口显示时机的问题。

不知道你有没有发现,在我们的第一个窗口初始化的瞬间,会有一个黑窗口闪现一下,如下图所示:

按照 Electron 官网的建议,窗口一开始应该是隐藏的,在ready-to-show事件触发后再显示窗口,如下代码所示:

js
const { BrowserWindow } = require("electron");
const win = new BrowserWindow({ show: false });
win.once("ready-to-show", () => {
  win.show();
});

但这个事件对于我们来说触发的还是太早了,因为 Vue 项目的 HTML 加载之后,JavaScript 脚本还需要做很多事情才能把组件渲染出来。况且开发者可能还会在 Vue 组件初始化的早期做很多额外的工作(比如准备基础数据之类的事情),所以显示窗口不能依赖ready-to-show事件,必须手动控制才好

主窗口对象mainWindow初始化时,把配置属性show设置为false,就可以让主窗口初始化成功后处于隐藏状态。接下来再在合适的时机让渲染进程控制主窗口显示出来即可。这里我们在WindowMain.vue组件渲染完成之后来完成这项工作,如下代码所示,showWindow事件是在我们上一节讲的CommonWindowEvent类注册的。

ts
// src\renderer\Window\WindowMain.vue
import { ipcRenderer } from "electron";
import { onMounted } from "vue";
onMounted(() => {
  ipcRenderer.invoke("showWindow");
});

自定义窗口标题栏

与菜单组件BarLeft.vue同级目录下还有一个组件名为BarTop.vue,这是我们为窗口设计的标题栏组件。这个组件的代码如下所示:

ts
//src\renderer\Component\BarTop.vue
<script setup lang="ts">
import { onMounted, ref, onUnmounted } from "vue";
import { ipcRenderer } from "electron";
defineProps<{ title?: string }>();
let isMaximized = ref(false);
//关闭窗口
let closeWindow = () => {
  ipcRenderer.invoke("closeWindow");
};
//最大化窗口
let maxmizeMainWin = () => {
  ipcRenderer.invoke("maxmizeWindow");
};
//最小化窗口
let minimizeMainWindow = () => {
  ipcRenderer.invoke("minimizeWindow");
};
//还原窗口
let unmaximizeMainWindow = () => {
  ipcRenderer.invoke("unmaximizeWindow");
};
//窗口最大化事件
let winMaximizeEvent = () => {
  isMaximized.value = true;
};
//窗口取消最大化事件
let winUnmaximizeEvent = () => {
  isMaximized.value = false;
};
onMounted(() => {
  ipcRenderer.on("windowMaximized", winMaximizeEvent);
  ipcRenderer.on("windowUnmaximized", winUnmaximizeEvent);
});
onUnmounted(() => {
  ipcRenderer.off("windowMaximized", winMaximizeEvent);
  ipcRenderer.off("windowUnmaximized", winUnmaximizeEvent);
});
</script>
html
//html代码
<template>
  <div class="topBar">
    <div class="winTitle">{{ title }}</div>
    <div class="winTool">
      <div @click="minimizeMainWindow">
        <i class="icon icon-minimize" />
      </div>
      <div v-if="isMaximized" @click="unmaximizeMainWindow">
        <i class="icon icon-restore" />
      </div>
      <div v-else @click="maxmizeMainWin">
        <i class="icon icon-maximize" />
      </div>
      <div @click="closeWindow">
        <i class="icon icon-close" />
      </div>
    </div>
  </div>
</template>
css
//样式代码
.topBar {
  display: flex;
  height: 25px;
  line-height: 25px;
  -webkit-app-region: drag; /* 可拖拽区域 */
  width: 100%;
}
.winTitle {
  flex: 1;
  padding-left: 12px;
  font-size: 14px;
  color: #888;
}
.winTool {
  height: 100%;
  display: flex;
  -webkit-app-region: no-drag; /* 可拖拽区域内的不可拖拽区域 */
}
.winTool div {
  height: 100%;
  width: 34px;
  text-align: center;
  color: #999;
  cursor: pointer;
  line-height: 25px;
}
.winTool .icon {
  font-size: 10px;
  color: #666666;
  font-weight: bold;
}
.winTool div:hover {
  background: #efefef;
}
.winTool div:last-child:hover {
  background: #ff7875;
}
.winTool div:last-child:hover i {
  color: #fff !important;
}

以上代码有以下几点需要注意。

  • 要想自定义一个窗口的标题栏必须把窗口默认的标题栏取消掉才行。只要我们在初始化mainWindow对象时(主进程里的逻辑),把窗口配置对象的frame属性设置为false就可以使这个窗口成为无边框窗口了。

  • 窗口标题是通过 props 数据传递给标题栏组件的,也就是说标题栏的标题是由其父组件来确定的。

  • 标题栏中可拖拽区域是通过样式-webkit-app-region: drag定义的,鼠标在这个样式定义的组件上拖拽可以移动窗口,双击可以放大或者还原窗口,如果这个组件内有子组件不希望拥有该能力,可以通过-webkit-app-region: no-drag;取消此能力。

  • 最大化、最小化、还原、关闭窗口等按钮的点击事件,都是通过ipcRenderer.invoke方法来调用主进程CommonWindowEvent类提供的消息管道来实现对应的功能的,这个类的代码稍后会介绍。

  • 由于窗口最大化(或还原)不一定是通过点击最大化按钮(或还原按钮)触发的,有可能是通过双击标题栏可拖拽区域触发的,所以这里我们只能通过ipcRenderer.on来监听窗口的最大化或还原事件,以此来改变对应的最大化或还原按钮的显隐状态。不能在按钮点击事件中来完成这项工作。windowMaximized消息和windowUnmaximized消息也是由主进程的CommonWindowEvent类发来的。

  • 由于多个二级路由页面会引用BarTop.vue为了避免在切换路由的时候,反复通过ipcRenderer.on注册消息监听器,所以我们在组件的onUnmounted事件内注销了消息监听器,避免事件泄漏。

CommonWindowEvent类的实现代码如下所示:

ts
//src\main\CommonWindowEvent.ts
import { BrowserWindow, ipcMain, app } from "electron";
// 主进程公共消息处理逻辑
export class CommonWindowEvent {
  private static getWin(event: any) {
    return BrowserWindow.fromWebContents(event.sender);
  }
  public static listen() {
    ipcMain.handle("minimizeWindow", (e) => {
      this.getWin(e)?.minimize();
    });
 
    ipcMain.handle("maxmizeWindow", (e) => {
      this.getWin(e)?.maximize();
    });
 
    ipcMain.handle("unmaximizeWindow", (e) => {
      this.getWin(e)?.unmaximize();
    });
 
    ipcMain.handle("hideWindow", (e) => {
      this.getWin(e)?.hide();
    });
 
    ipcMain.handle("showWindow", (e) => {
      this.getWin(e)?.show();
    });
 
    ipcMain.handle("closeWindow", (e) => {
      this.getWin(e)?.close();
    });
    ipcMain.handle("resizable", (e) => {
      return this.getWin(e)?.isResizable();
    });
    ipcMain.handle("getPath", (e, name: any) => {
      return app.getPath(name);
    });
  }
  //主进程公共事件处理逻辑
  public static regWinEvent(win: BrowserWindow) {
    win.on("maximize", () => {
      win.webContents.send("windowMaximized");
    });
    win.on("unmaximize", () => {
      win.webContents.send("windowUnmaximized");
    });
  }
}

上述代码中listen方法和regWinEvent方法都是静态的,所以可以直接在类型上调用这个方法:CommonWindowEvent.listen();(不用实例化这个类,可以直接使用类型调用这个方法),由于listen方法是静态的,而listen方法内部又调用了getWin私有方法,所以getWin方法也应该是静态的。

我们在listen方法内部注册了一系列消息管道,方便渲染进程控制主进程的一些行为,标题栏组件的窗口的最大化、最小化、还原等功能都是在这里实现的。在 app ready 之后调用CommonWindowEvent.listen();这个方法即可注册这些消息管道。

regWinEvent方法负责为窗口对象注册事件,当窗口最大化或还原后,这些事件的处理函数负责把消息发送给渲染进程。标题栏的对应按钮的图标也会发生相应的变化,同样也是在 app ready 之后调用CommonWindowEvent.regWinEvent(mainWindow);这个方法即可。

BrowserWindow 的问题

如果你仔细观察,你就会发现 Electron 创建一个BrowserWindow对象,并让它成功渲染一个页面是非常耗时的,在一个普通配置的电脑上,这大概需要 2~5 秒左右的时间(少量用户反馈没这个问题)。

这个问题在 2015 年就有人提出来了:Slow startup due to antivirus softwares,提问者也明确指出了这个问题与微软的反病毒软件有关。Electron 官方给出的回复是:

There is nothing we can do on Electron's side.

解决这个问题最直接的方法是关闭 Windows 系统的病毒和威胁防护功能,或者为 Windows 系统的病毒和威胁防护功能添加例外,如下图所示:

只要做了这个工作,2~5 秒的时间就会缩短到几百毫秒,如下图所示:

但作为桌面应用的开发者是很难要求用户关闭病毒和威胁防护程序的,也很难要求用户为我们的产品添加例外。

当然如果你有这个能力,那么我这里贴一段 powershell 脚本,方便你的用户为你的产品添加例外。

powershell
Add-MpPreference -ExclusionProcess "C:\Users\liuxiaolun\AppData\Roaming\Electron Fiddle\electron
-bin\19.0.4\electron.exe"

上述代码中最后一个参数为你的产品的安装路径。

可能有些开发者觉得给应用做签名即可解决这个问题,实际上是不行的,即使为应用做了商业签名,性能损耗的问题还是存在。

而且不但BrowserWindow存在这个问题,<webview>BrowserView都存在这个问题(它们背后都使用了相同的多进程渲染技术)。

然而并不是没有应对这个问题的方案,接下来我就介绍两种技术方案。

窗口池解决方案

对于重要资源的管控,软件世界很早就有应对方案,比如:线程池和数据库的连接池,按照这个思路,我们可以创建一个窗口池来解决这个问题。

提前准备 1 个或多个隐藏的窗口,让它们加载一个骨架屏页面,放到一个数组里,当应用程序需要打开一个新窗口时,就从这个数组里取出一个窗口,执行页内跳转,从骨架屏页面跳转到业务页面,然后再把这个窗口显示出来。这就消费掉了一个窗口。

当应用程序消费掉一个窗口之后,马上再创建一个新的加载了骨架屏页面的窗口放入数组,保证有足够的待命隐藏窗口。

当用户关闭某个加载了业务页面的窗口时,就把它从数组中删除掉。避免数组里存在无用的窗口。

这个方案之所以行之有效是因为我们在没有使用窗口时就提前准备好了窗口,等真正需要使用窗口时,仅仅是完成了一次页面跳转的工作,这个跳转工作可以在很短的时间内就完成。所以给用户的感知就是打开子窗口特别快。

<webview>BrowserView创建慢的问题也可以使用类似的方案解决。

然而这个方案有以下三个缺点。

  1. 无法优化整个应用的第一个窗口
  2. 系统内部始终会有 1 到 2 个隐藏窗口处于待命状态,这无形中增加了用户的内存消耗
  3. 虽然这个方案看上去逻辑比较简单,但要控制好所有的细节(比如,窗口间的通信、界面代码如何控制窗口的外观、如何实现模态子窗口等)还是非常繁琐的。

这个方案不是我们介绍的重点,所以不再给出源码,下一节将给出一个更方便驾驭的方案。

总结

本节我们主要讲解了三部分内容。

  • 首先,我们介绍了什么时候显示应用的第一个窗口,为什么官方推荐的ready-to-show方案不是最佳方案。

  • 接着,我们介绍了如何自定义窗口的标题栏,以及如何使用窗口的标题栏控制窗口的最大化、最小化、还原等行为。

  • 最后,我们介绍了一个困扰业界很久的问题:窗口加载过慢的问题,还介绍了解决这个问题的一种方案(窗口池方案),然而这个方案存在几个缺点,所以我们只介绍了实现逻辑,其他并没有详细介绍,下一节将详细介绍另外一种方案。

源码

本节示例代码请通过如下地址自行下载:

源码仓储