Electron窗口关闭与托盘处理

3,937 阅读8分钟

本期主要涉及窗口的关闭处理以及托盘的简单处理。
先说说本期的一个目标功能实现:以网易云音乐为例,在Windows环境下,我们点击右上角的关闭,这个时候会出现一个弹窗(以前没有勾选不在提醒的话)询问是直接退出还是缩小到系统托盘,选择后确定才会进行实际关闭处理,当勾选不再提醒后点击确认后下一次关闭不再提示直接处理,如果是缩小到托盘,对托盘点击退出才会真正关闭。在Mac环境下,点击右上角关闭直接缩小到程序坞,对程序坞右键退出或右上角托盘退出或左上角菜单退出,软件才会真正关闭。
当然,每个软件都有不同的退出逻辑,这里介绍如何实现上面功能的同时会对electron退出的各种事件进行说明,希望能帮助你找到想要的退出方式。
这里升级了一下版本,本版本为electron:12.0.0

网易云关闭

关闭的概念

我们在使用官方例子,打包安装后会发现mac和win在关闭上有所不同,mac是直接缩小到程序坞,对程序坞右键退出才能关闭,win则是直接关闭软件,这是为什么呢?
这里我先简单说一下关闭的概念,很多人把软件的关闭和窗口的关闭混淆在一起了,我这里把窗口和软件区分开说一下:

窗口的关闭:

win:BrowserWindow实例
win.destroy():强制关闭这个窗口,会触发win的closed事件,不会触发close事件
win.close():关闭窗口,触发win的close,closed事件

注意:窗口的关闭不一定会触发软件的关闭,但是通常情况下我们只有一个窗口,如果这个窗口关闭了,会触发app的window-all-closed(当所有的窗口都被关闭时触发)这个事件,在这个事件里我们可以调用软件的关闭app.quit(),故大多数情况下,我们把窗口关闭了,软件也就退出了。 那么造成这个差异的原因也就浮出水面了:

app.on('window-all-closed', () => {
  if (!isMac) {
    app.quit()
  }
})

软件的关闭:

app.quit():调用会先触发app的before-quit事件,然后再触发所有窗口的关闭事件,窗口全部关闭了(调用app.quit()关闭窗口是不会触发window-all-closed的,会触发will-quit),触发app的quit事件。但是如果在quit事件前使用event.preventDefault()阻止了默认行为(win的close事件,app的before-quit和will-quit),软件还是不会关闭。
app.exit():很好理解,最粗暴的强制关闭所有窗口,触发app的quit事件,故win的close事件,app的before-quit和will-quit不会被触发

总结一下简单来说软件的关闭要满足两个条件:

  • 所有窗口都关闭了
  • 调用了app.quit()

所以软件的关闭一般就是下面几种情况了

  1. 所有窗口关闭触发window-all-closed,在window-all-closed里调用app.quit()
  2. 调用app.quit(),触发所有窗口的close事件
  3. app.exit()

那么要达成我们的目标只有使用方法2了。

进程通信配置

进程通信的话,放到后面再说,这里只是介绍进程通信的配置 如果我在渲染进程想使用electron的一些方法的话,使用如下

const { ipcRenderer } = require('electron')
ipcRenderer.send('asynchronous-message', 'ping') // 向主进程发送消息

这样使用没问题,但是如果我们有多个页面都要使用那么我们每个页面都要require,比较麻烦,而且如果我们想既打包electron,又想打包web同样使用(可以通过process.env.IS_ELECTRON处理不同场景),那么引入的electron就无用了。electron的窗口的webPreferences提供了preload可以注入js,我们可以在这里把ipcRenderer挂载到window下面。

vue.config.js:
electronBuilder: {
  nodeIntegration: true, // 这里设置实际上是设置process.env.ELECTRON_NODE_INTEGRATION的值
  preload: 'src/renderer/preload/ipcRenderer.js',
  ......
}

ipcRenderer.js:
import { ipcRenderer } from 'electron'
window.ipcRenderer = ipcRenderer
主进程:
win = createWindow({
    ....
    webPreferences: {
      contextIsolation: false,
      nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
      preload: path.join(__dirname, 'preload.js'),
      scrollBounce: isMac
    }
  }, '', 'index.html')

渲染进程:
if (process.env.IS_ELECTRON) {
  window.ipcRenderer.send('asynchronous-message', 'ping')
}

这里说明一下contextIsolation这个值,在12.0.0以前默认值为false,本例子是12.0.0版本,默认值为true,区别在于为true的话,注入的preload.js可视为一个独立运行的环境,对于渲染进程是不可见的,简单来说就是我们把ipcRenderer挂载到window上,对应的渲染进程是获取不到的,故这里设置为false。

功能实现

如何实现呢?理一下思路,win的close事件有两种触发方式:

  1. 一个是我们点击关闭触发,此时我们并不想关闭窗口,那么应该使用e.preventDefault()阻止窗口的关闭。
  2. 另一个是我们主动使用app.quit()触发关闭,这时close事件里就不做处理。

那么通过一个变量flag的切换来实现,声明一个全局变量willQuitApp,在onAppReady里添加窗口的close事件,当我们点击关闭触发close事件,此时e.preventDefault()禁止了窗口的关闭,我们再通过主进程向渲染进程发出一个关闭的通知。
我们的流程为: 主进程检测关闭─>判断是否是app.quit()触发
──> 否,通知渲染进程关闭消息,渲染进程接收后根据用户操作或本地存储通知主进程将软件关闭或缩小到托盘 ──> 是,关闭软件

主进程:

let willQuitApp = false

onAppReady:
win.on('close', (e) => {
  console.log('close', willQuitApp)
  if (!willQuitApp) {
    win.webContents.send('win-close-tips', { isMac })
    e.preventDefault()
  }
})

我们主动使用`app.quit()`触发关闭时把willQuitApp设置为true,然后会触发win的close事件,让窗口关闭掉,达成方法2。
app.on('activate', () => win.show()) // mac点击程序坞显示窗口
app.on('before-quit', () => {
  console.log('before-quit')
  willQuitApp = true
})

渲染进程:

<a-modal
    v-model:visible="visible"
    :destroyOnClose="true"
    title="关闭提示"
    ok-text="确认"
    cancel-text="取消"
    @ok="hideModal"
  >
    <a-radio-group v-model:value="closeValue">
      <a-radio :style="radioStyle" :value="1">最小化到托盘</a-radio>
      <a-radio :style="radioStyle" :value="2">退出vue-cli-electron</a-radio>
      <a-checkbox v-model:checked="closeChecked">不再提醒</a-checkbox>
    </a-radio-group>
  </a-modal>

import { defineComponent, reactive, ref, onMounted, onUnmounted } from 'vue'
import { LgetItem, LsetItem } from '@/utils/storage'

export default defineComponent({
  setup() {
    const closeChecked = ref(false)
    const closeValue = ref(1)
    const visible = ref(false)
    const radioStyle = reactive({
      display: 'block',
      height: '30px',
      lineHeight: '30px',
    })
    onMounted(() => {
      window.ipcRenderer.on('win-close-tips', (event, data) => { // 接受主进程的关闭通知
        const closeChecked = LgetItem('closeChecked')
        const isMac = data.isMac
        if (closeChecked || isMac) { // mac和win的区分处理
          event.sender.invoke('win-close', LgetItem('closeValue')) // 当是mac或者勾选了不再提示时向主进程发送消息
        } else {
          visible.value = true
          event.sender.invoke('win-focus', closeValue.value) // 显示关闭弹窗并聚焦
        }
      })
    })
    onUnmounted(() => {
      window.ipcRenderer.removeListener('win-close-tips')
    })
    async function hideModal() {
      if (closeChecked.value) {
        LsetItem('closeChecked', true)
        LsetItem('closeValue', closeValue.value)
      }
      await window.ipcRenderer.invoke('win-close', closeValue.value) // 向主进程推送我们选择的结果
      visible.value = false
    }
    return {
      closeChecked,
      closeValue,
      radioStyle,
      visible,
      hideModal
    }
  }
})

主进程接受渲染进程消息,initWindow里win赋值后调用,这里要注意的是Mac的处理,Mac在全屏状态下如果隐藏的话,那么会出现软件白屏或黑屏情况,我们这里要先退出全屏然后再隐藏掉。

import { ipcMain, app } from 'electron'
import global from '../config/global'

export default function () {
  const win = global.sharedObject.win
  const isMac = process.platform === 'darwin'
  ipcMain.handle('win-close', (event, data) => {
    if (isMac) {
      if (win.isFullScreen()) { // 全屏状态下特殊处理
        win.once('leave-full-screen', function () {
          win.setSkipTaskbar(true)
          win.hide()
        })
        win.setFullScreen(false)
      } else {
        win.setSkipTaskbar(true)
        win.hide()
      }
    } else {
      if (data === 1) {  // win缩小到托盘
        win.setSkipTaskbar(true) // 使窗口不显示在任务栏中
        win.hide() // 隐藏窗口
      } else {
        app.quit() // win退出
      }
    }
  })
  ipcMain.handle('win-focus', () => { // 聚焦窗口
    if (win.isMinimized()) {
      win.restore()
      win.focus()
    }
  })
}

实现效果

托盘设置

这里的托盘设置只是为了完成软件的退出功能,故只是简单介绍,其余的功能后面的篇章会详细介绍的。
托盘的右键点击退出直接退出,所以直接调用app.quit()触发退出流程

initWindow里win赋值后调用setTray(win)

import { Tray, nativeImage, Menu, app } from 'electron'
const isMac = process.platform === 'darwin'
const path = require('path')
let tray = null

export default function (win) {
  const iconType = isMac ? '16x16.png' : 'icon.ico'
  const icon = path.join(__static, `./icons/${iconType}`)
  const image = nativeImage.createFromPath(icon)
  if (isMac) {
    image.setTemplateImage(true)
  }
  tray = new Tray(image)
  let contextMenu = Menu.buildFromTemplate([
    {
      label: '显示vue-cli-electron',
      click: () => {
        winShow(win)
      }
    }, {
      label: '退出',
      click: () => {
        app.quit()
      }
    }
  ])
  if (!isMac) {
    tray.on('click', () => {
      winShow(win)
    })
  }
  tray.setToolTip('vue-cli-electron')
  tray.setContextMenu(contextMenu)
}

function winShow(win) {
  if (win.isVisible()) {
    if (win.isMinimized()) {
      win.restore()
      win.focus()
    } else {
      win.focus()
    }
  } else {
    !isMac && win.minimize()
    win.show()
    win.setSkipTaskbar(false)
  }
}

这里的逻辑还是比较简单的,唯一疑惑的点可能是win.show()前为什么要有个win.minimize(),这里的处理呢是因为hide前如果我们渲染进程有可见的改变(我们这里是让关闭提示的弹窗关闭了),后面再show时会出现一个闪烁的问题,有兴趣的同学可以把win.minimize()注释一下再看一下效果。当然你也可以用下面的处理方式:

win.on('show', () => {
  setTimeout(() => {
    win.setOpacity(1)
  }, 200)
})
win.on('hide', () => {
  win.setOpacity(0)
})

补充

Mac系统在处理上有一些逻辑和Windows是不一样的,虽然并没有一个硬性的规定要这样处理,更多的是看个人喜好与约定俗成。
比如托盘的点击处理win上左击直接打开软件,右击打开菜单,而mac上左击除了触发click外还会打开菜单,如果和win上一样处理的话有些不太适宜。
这里再补充一个mac上的,mac软件在全屏时,大多数软件都是把缩小这个按钮给禁用了的,那么electron怎么实现这个呢:

win.on('enter-full-screen', () => {
  isMac && app.commandLine.appendSwitch('disable-pinch', true)
})
win.on('leave-full-screen', () => {
  isMac && app.commandLine.appendSwitch('disable-pinch', false)
})

由于我们的窗口实际上就是chromium,故我们可以通过设置chromium的参数来实现,更多的参数请参考链接设置。

本文地址:xuxin123.com/electron/wi…
本文github地址:链接