项目背景
为了给部分老师提供一个简单易用的桌面推流工具,我们计划推出一款名为直播伴侣的windows桌面端应用程序,并于2020年春节,快速交付了直播伴侣1.0版本。目前直播伴侣的主要功能有:
- 兼容各站点登录
- 推流直播
- 直播连麦
- 主持人互动聊天
直播伴侣基于Electron进行开发,离项目启动已有一年有余,这里抛砖引玉,简单分享一年多以来的Electron开发经验,并主要分享Web开发转型到Electron开发时的一些需要注意的点,希望能给大家带来一点收获。
技术选型
在做推流工具前,我们调研了公司内部抖音直播的桌面端-直播伴侣,并在底层推流SDK侧,选用了和抖音直播相同的Mediasdk
,最后确定了字节直播的直播伴侣的技术选型:
GUI框架 | UI框架 | 国际化 | 应用打包 | 推流SDK | 使用平台 |
---|---|---|---|---|---|
Electron | React | react-intl | electron-builder | Mediasdk | Windows |
代码结构
目前直播伴侣的目录结构如下:
app
: 业务代码assest
:图片,svg等静态资源components
:业务组件containers
:比components高一级的组件,通常组合多个componentpages
:对应每一个BrowserWindow的容器locale
:国际化相关语言配置main
主进程中调用的模块lib
: mediasdk,RTC等在主进程调用的模块window
:BrowserWindow相关创建和进程通信处理
reducers
: redux相关typings
: ts类型定义utils
: 工具类package.json
: app目录的pkg,用于安装需要在主进程中调用的依赖html文件
:BrowserWindow加载的htmlmain.development.ts
: 项目入口文件,用于启动整个electron项目
builder-config
: electron-builder相关配置,包含本地打包和线上打包的配置config
: webpack相关配置script
: 打包时用到的脚本test
: jest测试package.json
: 根目录的pkg,前端依赖安装到这里,可以避免node_modules出现在打包后的文件中 从代码结构其实可以看出,编写Electron应用程序和Web开发是有很多相似之处的:
- 可以用Webpack进行打包,也有各类React组件的封装,package.json等等文件……
- 在多数情况下,页面上的UI和逻辑都是可以继续沿用Web开发中所使用的
综上,从Web开发转战Electron,早期上手的门槛是较低的。
当然,Web开发和Electron开发也必然会有差距,这也是本次分享的重点。
分享中涉及的Electron专用术语会附上Electron官方文档的链接以供查阅
Electron开发和web开发差异之处
运行机制
- Web开发时,我们开发的页面运行在浏览器的一个Tab内:
对于单页应用(SPA)来说,一般是一个html文件作为入口文件,前端资源打包后,在html上加上对应的bundle.js,用户通过浏览器访问时,下载html文件并加载完成对应的资源后即可开始使用。
同时,浏览器的每一个tab加载的页面独立运行在一个进程中。
- Electron开发时,我们的页面运行在桌面应用的一个窗口中:
在Electron的Web页面中,可以在代码里引入Nodejs的原生模块使用,如fs,path
在这里我们引入窗口(在Electron中对应着BrowserWindow)的概念,Electron应用由一个或多个窗口共同组成,在Electron中,每个窗口会对应着一个html文件,这里和Web开发是相通的。
但我们仍然需要一个更上层的入口文件,我们在这里组织应用中各种窗口的展示等逻辑。
也就是说对于Electron应用,“通过浏览器访问”这一步操作,其实是需要开发者处理的。
想象有一个浏览器,这里有三个问题可以思考一下:
- 谁指定想访问的页面
- 谁处理浏览器本身的操作,打开关闭标签页,退出浏览器
- 谁负责页面的Tab内渲染的内容
在Web开发时,通常我们只需要关心前端页面的UI和交互部分,窗口的关闭等操作我们不用关心。
在Electron应用中,我们需要在用户点击时便给用户展示一个页面,在应用中包含多个窗口时,也需要我们去管理各个窗口的显隐逻辑,窗口的关闭,显示,窗口位置等都是需要开发者感知的。
在Electron中,每一个窗口会对应着一个渲染进程,而又有一个主进程可以将这些渲染进程管理起来,所以我们一般把主进程看作这样的管理者。
对于上面的问题,转成Electron的概念再来看:
- 谁指定想打开的窗口 -->主进程
- 谁处理应用的逻辑,打开关闭窗口,退出应用 -> 主进程
- 谁负责窗口内渲染的内容 --->渲染进程
不同的窗口间通过与主进程协作,主进程发号施令,组成完整的Electron应用:
- 例如登录窗口中,登录接口调用成功后,需要隐藏登录窗口,并显示后续的直播列表窗口
- 在开播页面中,需要弹出一个Dialog提示用户
BrowserWindow之间的交互,数据通信,都需要由对应的渲染进程发送事件到主进程,主进程处理后,再发送事件到指定的渲染进程。
简单来说,单页应用SPA的开发是一个html对应一个应用,而Electron的开发,可以是多个单页应用,通过进程间的通信处理好分工,构成一个整体的应用。
Electron 生命周期
以字节直播的项目举栗,代码结构中有main.development.ts
这样的一个文件,这是项目的入口文件,在这个文件中,我们处理了一些BrowserWindow的初始化,在主进程上注册一些必要的事件监听,监控数据上报等等……在其中最重要的是,我们处理了Electron app 生命周期里的ready事件。
Electron 生命周期
- 应用启动
当Electron应用完成基础的初始化时,会发出一次ready
事件,这时对于开发者而言,需要处理自定义的初始化逻辑,并展示UI界面给用户。
在main.development.ts
中,我们在app的ready
事件触发时,创建了登录页面、推流页面等BrowserWindow
,并在登录窗口ready-to-show
事件触发时,通过show
方法,将直播伴侣的登录窗口展示给用户。
- 应用退出
直播伴侣目前总共有6个BrowserWindow
,当需要退出应用时,我们会销毁当前所有的BrowserWindow
实例,这时会触发window-all-close
事件,在这里通过electron.app.quit
方法,完全退出应用。
const { app, BrowserWindow } = require('electron')
const path = require('path')
function createWindow () {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
官方Electron入口文件示例
体验优化
预先创建好BrowserWindow
这里我们需要注意,每个BrowerWindow创建后,都需要占用一定的资源,开发中需要尽可能的将操作在一个窗口内完成,减少BrowserWindow的数量。
BrowserWindow类似于Chrome浏览器的一个标签页,在Electron应用中,每一个窗口都对应着一个BrowserWindow
实例,它拥有自己的渲染进程。
创建时,我们可以指定这个窗口的大小,颜色,可见性等属性,具体参考BrowserWindow文档,当我们创建好窗口后,通过loadURL
方法加载指定的html,从而进行我们前端页面的加载。
理想情况下,打开一个新窗口的流程是new BrowserWindow
->load js
->执行初始化逻辑
,关闭窗口就将这个实例通过close
方法销毁,而在现实情况中,每次重新创建一个BrowserWindow
的开销较大,从创建到页面完全可交互,用户等待的时间往往长到难以忍受。
在实际情况下,我们通常是将常用的BrowserWindow提前创建好,等到需要使用的时候,直接通过show
方法展示,点击关闭时,通过hide
方法将其隐藏到后台,以便下次能直接弹出,减少用户等待时间,这里是一个简单的用空间换时间的策略,关于Electron的交互优化,以及如何追赶原生的交互体验,也有很多讨论的点。
当我们在展示Electron界面时使用show-hide
的模式取代create-close
时,这里有一些和Web页面开发不同的点:
- 预先创建好
BrowserWindow
时,初始化逻辑可能会提前执行 BrowserWindow
hide时,当前React组件并不会被销毁
这里我们构造一个场景,我们登录后进入了直播列表的窗口,在点击某个直播间后,会显示这个直播间的开播页面
以前端页面的初始化举栗,当我们去进行开播页面初始化时,如果是使用hook的方式开发,通常在useEffect里指定一个空数组,这样可以保证整个组件在加载时进行一次初始化,比如从redux中拿到数据,请求接口,更新对应的视图。
useEffect(() => {
init()
}, [])
BrowerWindow中页面的初始化
而我们通常为了优化用户的交互体验,会提前创建BrowserWindow
,而在这之后,便会触发init逻辑,相当于在点击之前,已经执行了init逻辑,这时是不符合预期的。
同时BrowserWindow
hide时,如果不做特殊处理,当前页面的React组件并不会销毁,所以我们第一次点击,第二次点击时,init逻辑也不会再重新执行了。
对于解决这样case,有两种方式:
- useEffect中监听某个变量,当这个值变动时,执行init逻辑
- 通过进程通信,主进程向开播页面的渲染进程发送初始化事件
如果用第一种,监听变量的方法,我们的代码可以改造成:
useEffect(() => {
init(activityId)
}, [activityId])
这样,我们在列表页点击直播间时,更新redux中储存的activityId值,开播页面检测到activityId变动,就可以保证每次切换activityId时,都正确执行init逻辑了。
用这样的方式也有弊端,相当于我们用activityId这个值作为页面执行初始化逻辑的触发依据,这意味着我们需要明确这个值有哪些地方可能触发改动逻辑,防止出现不符合预期的初始化逻辑的执行。
如果我们使用第二种,通过进程通信的方式执行初始化,代码改造会稍微复杂点。
首先我们需要在前端页面中增加监听器,通常是通过ipcRenderer.on
方法实现,ipcRenderer可以接收由主进程发送的事件,我们这里的处理方式是:
- 列表窗口点击后,通过
ipcRenderer.send
方法发送本次点击事件到主进程,同时携带activityId
参数 - 主进程通过开播窗口
BrowserWindow
实例的webContents.send
方法发送SHOW_MAIN_WINDOW
事件,透传activityId
参数 - 开播窗口的渲染进程通过
ipcRenderer.on
监听SHOW_MAIN_WINDOW
事件,读取到activityId并执行页面的初始化流程
按照这种方式,在开播页面的代码我们可以改造成:
const handleInit = (data)=>{
const { activityId } = data
init(activityId)
}
useEffect(() => {
ipcRenderer.on(EVENT.SHOW_MAIN_WINDOW, handleInit)
return ()=>{
ipcRenderer.removeListener(EVENT.SHOW_MAIN_WINDOW, handleInit)
}
}, [])
用这种方式,我们可以保证每一次开播页面的初始化都是由主进程感知并控制,这里在页面逻辑复杂的情况下,推荐用事件的方式来控制初始化,这样可以避免一些冗余的判断是否需要初始化等逻辑。
当然,如果我们用进程通信,使用监听器的时候也需要注意,在hook中读取不到useState
和redux
中的最新值,如果我们的init逻辑还需要一些页面中额外的参数,推荐使用useRef
储存这个变量,在init
时通过ref.current
读取。
总 结
当我们开发Electron项目时,UI层面基本上可以复用Web开发的经验,BrowserWindow
本质上也是加载的html。
简单归纳一下,在 Electron 中我们需要开发:
- Web页面
- 各个窗口的管理
- 应用相关:菜单栏、生命周期(打开和关闭应用等)