分享一年以来的Electron开发经验

avatar
FE @字节跳动

项目背景

为了给部分老师提供一个简单易用的桌面推流工具,我们计划推出一款名为直播伴侣的windows桌面端应用程序,并于2020年春节,快速交付了直播伴侣1.0版本。目前直播伴侣的主要功能有:

  • 兼容各站点登录
  • 推流直播
  • 直播连麦
  • 主持人互动聊天

直播伴侣基于Electron进行开发,离项目启动已有一年有余,这里抛砖引玉,简单分享一年多以来的Electron开发经验,并主要分享Web开发转型到Electron开发时的一些需要注意的点,希望能给大家带来一点收获。

技术选型

在做推流工具前,我们调研了公司内部抖音直播的桌面端-直播伴侣,并在底层推流SDK侧,选用了和抖音直播相同的Mediasdk,最后确定了字节直播的直播伴侣的技术选型:

GUI框架UI框架国际化应用打包推流SDK使用平台
ElectronReactreact-intlelectron-builderMediasdkWindows

代码结构

目前直播伴侣的目录结构如下:

  • app: 业务代码
    • assest:图片,svg等静态资源
    • components:业务组件
    • containers:比components高一级的组件,通常组合多个component
    • pages:对应每一个BrowserWindow的容器
    • locale:国际化相关语言配置
    • main 主进程中调用的模块
      • lib: mediasdk,RTC等在主进程调用的模块
      • window:BrowserWindow相关创建和进程通信处理
    • reducers: redux相关
    • typings: ts类型定义
    • utils: 工具类
    • package.json: app目录的pkg,用于安装需要在主进程中调用的依赖
    • html文件:BrowserWindow加载的html
    • main.development.ts: 项目入口文件,用于启动整个electron项目
  • builder-config: electron-builder相关配置,包含本地打包和线上打包的配置
  • config: webpack相关配置
  • script: 打包时用到的脚本
  • test: jest测试
  • package.json: 根目录的pkg,前端依赖安装到这里,可以避免node_modules出现在打包后的文件中 从代码结构其实可以看出,编写Electron应用程序和Web开发是有很多相似之处的:
  1. 可以用Webpack进行打包,也有各类React组件的封装,package.json等等文件……
  2. 在多数情况下,页面上的UI和逻辑都是可以继续沿用Web开发中所使用的

综上,从Web开发转战Electron,早期上手的门槛是较低的。

当然,Web开发和Electron开发也必然会有差距,这也是本次分享的重点。

分享中涉及的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之间的交互,数据通信,都需要由对应的渲染进程发送事件到主进程,主进程处理后,再发送事件到指定的渲染进程。

image.png

主进程和渲染进程

简单来说,单页应用SPA的开发是一个html对应一个应用,而Electron的开发,可以是多个单页应用,通过进程间的通信处理好分工,构成一个整体的应用。

Electron 生命周期

以字节直播的项目举栗,代码结构中有main.development.ts这样的一个文件,这是项目的入口文件,在这个文件中,我们处理了一些BrowserWindow的初始化,在主进程上注册一些必要的事件监听,监控数据上报等等……在其中最重要的是,我们处理了Electron app 生命周期里的ready事件

image.png

image.png

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时,初始化逻辑可能会提前执行
  • BrowserWindowhide时,当前React组件并不会被销毁

这里我们构造一个场景,我们登录后进入了直播列表的窗口,在点击某个直播间后,会显示这个直播间的开播页面

image.png

image.png 以前端页面的初始化举栗,当我们去进行开播页面初始化时,如果是使用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可以接收由主进程发送的事件,我们这里的处理方式是:

  1. 列表窗口点击后,通过ipcRenderer.send方法发送本次点击事件到主进程,同时携带activityId参数
  2. 主进程通过开播窗口BrowserWindow实例的webContents.send方法发送SHOW_MAIN_WINDOW事件,透传activityId参数
  3. 开播窗口的渲染进程通过ipcRenderer.on监听SHOW_MAIN_WINDOW事件,读取到activityId并执行页面的初始化流程

image.png

按照这种方式,在开播页面的代码我们可以改造成:

  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中读取不到useStateredux中的最新值,如果我们的init逻辑还需要一些页面中额外的参数,推荐使用useRef储存这个变量,在init时通过ref.current读取。

总 结

当我们开发Electron项目时,UI层面基本上可以复用Web开发的经验,BrowserWindow本质上也是加载的html。

简单归纳一下,在 Electron 中我们需要开发:

  • Web页面
  • 各个窗口的管理
  • 应用相关:菜单栏、生命周期(打开和关闭应用等)