从零开始的electron开发-主进程-窗口关闭与托盘处理

窗口关闭与托盘处理

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

网易云关闭

关闭的概念

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

窗口的关闭:

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

注意:窗口的关闭不必定会触发软件的关闭,可是一般状况下咱们只有一个窗口,若是这个窗口关闭了,会触发app的window-all-closed(当全部的窗口都被关闭时触发)这个事件,在这个事件里咱们能够调用软件的关闭app.quit(),故大多数状况下,咱们把窗口关闭了,软件也就退出了。
那么形成这个差别的缘由也就浮出水面了:node

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不会被触发

总结一下简单来讲软件的关闭要知足两个条件:react

  • 全部窗口都关闭了
  • 调用了app.quit()

因此软件的关闭通常就是下面几种状况了web

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

那么要达成咱们的目标只有使用方法2了。vue-cli

进程通讯配置

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

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

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

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。async

功能实现

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

  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的参数来实现,更多的参数请参考连接设置。