在Electron上实现始终置顶且可调整大小的二维码窗口

弹幕派正在筹划使用Electron+Vue.js+Element来重构之前的桌面客户端,在重构过程中需要基于Electron的特性对之前的功能进行调整。其中要实现在弹幕窗口上显示一个始终置顶且可以手动调整大小以及移动位置,在实现过程中遇到一些问题并加以解决,在此进行分享。

怎样展现二维码

第一个问题,是怎样展现二维码。由于二维码叠加在弹幕窗口之上,因此有两种可选方案:一是在原有的弹幕窗口上添加一个Vue组件,将二维码展现上去即可;二是新建一个窗口,单独用于显示二维码。

第一种方案需要手动实现二维码的位置移动和大小调整,但是由于是随着弹幕窗口一起显示,开启和关闭状态的管理较为简单,同时由于在同一窗口内,二维码地址等参数传递方便;第二种方案需要新开一个窗口,因此需要手动管理窗口的状态,且参数传递需要通过主进程进行传递,实现复杂度有所提升,好处在于位置移动和大小调整使用原生实现即可。首先基于第一种方式进行了实现。

与弹幕在同一窗口显示

二维码组件的实现整体较为简单,显示一张二维码图片即可。由于需要手动实现窗口的调整大小和关闭,因此添加两个按钮,分别对应关闭和调整大小,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<template>
<div id="QRCode" :style="location">
<el-button circle class="close">
<font-awesome-icon icon="times"/>
</el-button>
<el-button circle class="resize">
<font-awesome-icon icon="expand-arrows-alt"/>
</el-button>
<img :src="src" alt="QRCode" class="QRCode" :height="size" :width="size">
</div>
</template>

<script>
import {remote} from 'electron'

export default {
name: "QRCode",
data() {
return {
size: Math.floor(document.body.clientHeight / 2),
location: {
top: `${Math.floor(document.body.clientHeight / 4)}px`,
left: `${Math.floor(document.body.clientWidth / 2 - document.body.clientHeight / 4)}px`
}
}
},
props: {
display: {
type: Boolean
},
src: {
type: String
}
}
}
</script>

值得注意的是初始显示位置和大小需要手动计算。

调整z-index

接下来调整CSS样式。原本弹幕窗口的属性已经是始终置顶,下一步所需要做的是保证二维码在弹幕上方显示即可,通过设置z-index大于弹幕层的z-index即可,并设置position属性保证z-index生效。紧接着调整两个按钮的样式,设置position: absolute同时设置topright值使其固定位置,在组件大小调整时仍然保持在窗口右上角/右下角即可。为了避免窗口过小时按钮遮挡住二维码,设置二维码的padding1.5em并设置两个按钮的heightwidth3em,这样可以跟随组件大小进行变化。最终CSS样式为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
div {
display: inline-block;
position: absolute;
padding: 1.5em;
z-index: 4;
}

.close {
position: absolute;
top: 0;
right: 0;
height: 3em;
width: 3em;
}

.resize {
position: absolute;
bottom: 0;
right: 0;
height: 3em;
width: 3em;
}
}

全屏透明窗口的部分穿透

完成以后运行可以看到二维码效果。接下来需要实现二维码的调整大小和关闭。然而这时会发现一个问题,和WPF不同的是,Electron不支持透明窗口的部分穿透。什么意思呢?由于我们的弹幕是全屏播放,因此为了保证不影响正常操作,需要在初始化窗口时设置window.setIgnoreMouseEvents(true)使得鼠标点击穿透到其他程序中。但是这样就导致没有办法通过点击按钮来调整二维码大小,也没有办法通过拖放来调整二维码显示的位置。而设置window.setIgnoreMouseEvents(false)又会导致其他窗口无法响应鼠标操作。在electron的官方GitHub Issue Support click-through of transparency #1335 有许多人遇到了同样的问题。简而言之,早期人们通过主进程捕捉当前鼠标位置像素点来决定是否发送事件给Renderer,非常复杂;在Electron 3.0.0版本后加入了setWindowShape API,如果是开发小部件(Gadget)的话使用这一API是非常得当的,直接将窗口大小设置为小部件的大小即可;但是由于弹幕需要全屏显示,因此需要其他方式来解决这一问题。

在Issue的最后有人提出一种解决方案,目前官方文档也已经写明:通过在元素上添加mouseentermouseleave事件,在鼠标进入二维码时设置window.setIgnoreMouseEvents(false),捕捉鼠标事件;在鼠标离开元素时设置window.setIgnoreMouseEvents(true)释放鼠标事件,在本例中由于只有二维码一个元素需要处理鼠标事件,因此不会显得过于冗杂。完整实现为:

1
2
3
4
5
6
7
8
let win = require('electron').remote.getCurrentWindow()
let el = document.getElementById('QRCode')
el.addEventListener('mouseenter', () => {
win.setIgnoreMouseEvents(false)
})
el.addEventListener('mouseleave', () => {
win.setIgnoreMouseEvents(true, { forward: true })
})

经过实现以后发现一个问题:

由于弹幕是Canvas动画,在鼠标移入/移出二维码时会导致明显的动画卡顿,在低性能设备上表现尤为明显,因此决定将这一方案弃置,转为用新窗口打开。

新窗口打开

新窗口打开在实现上会比前一方案简单一点(并不),Vue组件基本和之前一样,不过由于调整二维码大小用的是原生的调整窗口大小,因此去掉resize按钮,同时把locationwidthheight等属性去掉即可。

接下来需要在主进程中实现二维码窗口,在主进程中创建createQRCodeWindow方法,通过传入screen.getAllDisplays()方法返回的screen对象获得窗口大小和位置进而调整每一个二维码窗口的显示;然后设置窗口属性(setAlwaysOnToploadURL等等)即可。值得注意的是需要在Vue Router里面配置QRCode.vue的路由。紧接着ipcMain中注册两个事件,一个是创建,另一个是关闭,传入参数为screen列表,根据列表在不同屏幕创建窗口即可。在渲染进程中通过按钮/开关等方式注册事件通过ipcRenderer触发事件并将当前已开启弹幕的屏幕的screen对象作为参数传入即可。

值得注意的是,为了避免窗口出错关闭后不断调用空引用提示错误,可以用try...catch...进行处理:

1
2
3
4
5
6
7
8
9
10
arg.forEach((idx) => {
if (QRCodeWindows[idx]) {
try {
QRCodeWindows[idx].close()
QRCodeWindows[idx] = null
} catch (e) {
danmakuWindows[idx] = null
}
}
})

如何传递参数

打开窗口后我们要考虑两件事:一是如何使Vue页面知道自己所属的窗口,进而在调用ipcMain的关闭事件时指定自己所属的窗口;二是如何将二维码图片的地址从主进程传递到渲染进程中。一种方案是通过Vue Router的路由参数传递,另一种是通过ipcRenderer传递。前一种方案实现简单,但是将地址作为参数写入路由中总觉得会使地址过于丑陋,因此尝试第二种方案。

第二种方案在创建完窗口的同时通过调用window.webContents.send(channel, arguments)将参数传递过去,然后在Vue页面created环节通过注册ipcRender事件接收参数。但是实现后发现,参数并没有按照预期传递过去。这是由于我们在创建窗口并LoadURL后Vue生命周期还没有执行到created环节事件已经发出,渲染进程也就无法接收到参数。

Vue的生命周期

那么为了保证事件可以被接收到,我们可以将发送参数作为一个事件放到ipcMain注册的事件中,等待渲染进程中Vue初始化完成了再进行调用,这样就确保渲染进程可以接收到参数。实现细节为:

主进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ipcMain.on('initQRCode', () => {
for (let k in QRCodeWindows) {
if (QRCodeWindows.hasOwnProperty(k) && QRCodeWindows[k]) {
try {
QRCodeWindows[k].webContents.send('initQRCode', {
idx: k,
src: 'http://static.danmakupie.com/qrcodes/VqUkcCq4Oj2ZmLDiROw3GA.png'
})
} catch (e) {
QRCodeWindows[k] = null
}
}
}
})

渲染进程:

1
2
3
4
5
6
7
8
created() {
this.loading = true
ipcRenderer.once('initQRCode', (event, arg) => {
this.idx = arg.idx
this.src = arg.src
})
ipcRenderer.send('initQRCode', {})
}

也就是说,等到Vue页面加载至created环节,注册事件监听后,再去触发主进程事件使其发送参数即可。但是这样有个问题是,渲染进程仍然不知道自己所在的窗口,因此主进程需要遍历所有的窗口逐一发送参数:如果窗口已经打开,那么窗口将接收参数;窗口没有打开则没有影响。这样会导致一个新问题:所有窗口初始化页面时都会触发主进程的事件,导致之前已经初始化过的窗口会不断响应并更新参数,这当然不是我们想要的,因此在渲染进程注册事件时使用once而非on方法,当触发时即注销事件,这样可以避免多次更新。

细节优化

Loading界面

由于二维码是从远端服务器获取图片,可能会出现图片加载时间过长,为了优化用户体验,可以加一个Loading界面。Element中提供了Loading界面,直接在div元素添加v-loading然后绑定一个Boolean变量即可。当开始载入页面时将loading变量设置为true,然后在图片加载完成时将loading变量设置为false即可。Vue中提供了图片加载完成事件v-on:load(简写为@load),直接绑定函数即可。

居中显示

接下来要保证二维码图片始终居中显示。横向居中直接让div的属性设置为:

1
2
3
4
.container {
text-align: center;
margin: auto;
}

即可。纵向居中稍微麻烦一点,具体可参考:

CSS设置居中的方案总结-超全盘点8种CSS实现垂直居中水平居中的绝对定位居中技术

由于是在Electron中实现,不需要考虑兼容性问题,因此这里使用兼容性比较差但是实现较为简单的flex,即添加一个父div,然后设置父div属性为:

1
2
3
4
#QRCodeParentContainer {
display: flex;
align-items: center;
}

这样就能保证垂直居中和水平居中同时实现了。

等比例缩放

但实际上这时会遇到一个问题,就是二维码并不是和窗口大小等比例缩放,且窗口没有办法等比例缩放。由官方Issue#8036可知目前Electron提供的API window.setAspectRatio(aspectRatio, extraSize: {width: float, height: float})只适用于macOS而非Windows,而Issue中提供的解决方案都不能很好地解决这一问题,因此接下来着重于保证二维码能够和窗口等比例缩放且按照最短边来显示,保证显示完整,不会出现滚动条。

首先设置图片的CSS样式为:

1
2
3
4
5
.QRCode {
display: block;
height: 100%;
widht: 100%
}

然后设置包含图片的子div的样式为:

1
2
3
4
#QRCodeChildContainer {
width: 100vmin;
height: 100vmin;
}

其中vmin是指视口高度和宽度之间的最小值的 1/100。视口高度是指浏览器当前文档可见部分的高度,视口宽度同理。CSS引入了vh表示视口高度的 1/100,vw表示视口宽度的 1/100,vmin表示视口高度和宽度之间的最小值的 1/100,vmax表示视口高度和宽度之间的最大值的 1/100。在这里我们用100vmin就可以保证图片的大小始终是最短边来显示,不会出现滚动条。接着设置包含子div的父div格式为:

1
2
3
4
#QRCodeParentContainer {
height: 100vh;
width: 100vw;
}

这样就可以保证二维码能够跟随窗口进行大小变化。

参考:

纯css实现容器高度随宽度等比例变化的四种解决方案

vh,vw单位你知道多少?

- MDN 文档

拖拽

拖拽移动的实现非常简单,在div上添加属性-webkit-app-region: drag即可。为了防止拖拽override掉按钮点击事件,需要在按钮上添加属性-webkit-app-region: no-drag

1
2
3
4
5
6
7
#QRCodeChildContainer {
-webkit-app-region: drag;
}

.close {
-webkit-app-region: no-drag
}

总结

看似简单的二维码窗口却花了很长时间去调整和实现,本次实现主要涉及到以下一些知识点:

  • 设置position使得z-index生效
  • Electron中透明窗口的部分穿透
  • Electron中如何用ipc从主进程向渲染进程传递初始化参数
  • Element中的Loading控制
  • CSS中元素的居中显示
  • CSS中元素等比例缩放
  • CSS实现Electron窗口拖拽

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×