分享这半年的 Electron 应用开发和优化经验

2019 年最后一发,谈谈这半年 Electron 应用开发和优化心得。干货也挺多,但愿能给你带来一点启发。javascript

下半年能够拿出来讲一说的项目,估计就是咱们用 Electron 重构了一个桌面端应用。这个应用相似于钉钉或者企业微信,主要功能有即时通讯、语音/视频、会议,基本功能和交互体验和 PC 端微信差很少(其实就是模仿),具体细节就不展开了, 这些对本文不重要。以下图html



文章大纲前端


为何选择 Electron?

缘由也很简单: 咱们的应用要兼容多个平台,原生开发效率低,咱们没有资源java

说了跟白说同样,大部分选择 Electron 框架的动机都是差很少的,无非就是穷,尤为是在夹缝中生存的企业。node

为了优化客户端开发资源,'混合化'成为了咱们今年客户端重构的主题git

先来看一下咱们如今的客户端基本架构:github


混合化对咱们来讲有两层意思:web

  1. 咱们的应用架构'混合'了多种技术。通用底层 C/C++, 平台原生(iOS, Android, PC, MacOS),Web 技术
  2. 跨平台

基于咱们原有的客户端基础和状况,混合化重构天然而然分化为了两个方向:chrome

  1. 业务下沉。将通用的、核心的业务下沉。例如消息处理、语音/视频、会议、数据存储等核心模块, 核心协议是 XMPP、SIP。这些模块变更频率较低、对性能要求也比较高,并且有跨平台需求,所以适合用 C/C++ 来实现。
  2. UI 混合。视图层混合化目前也有较多的解决方案,例如 Electron、React Native、Flutter、或者是 HTML Hybrid。咱们选择先从 Electron 开始,由于它在桌面端开发中已经有很是成熟的表现,市场上也有不少大型的 Electron 应用,例如 VSCode、Atom、Slack。在移动端,咱们对 React Native 和 Flutter 还比较保守,后续可能会进行尝试。

理解了咱们的动机,如今再看上面的图, 应该就好理解多了, 这是典型的三层结构, 和 MVC 很是类似:shell

  • M -- 通用混合层。 C/C++ 封装核心、通用的业务模块以及业务数据存储。
  • V -- UI 层。视图层,使用跨平台视图解决方案,对于性能要求较高的部分使用原生实现。好比 Electron
  • C -- 平台桥接层。介于 M 和 V 之间,桥接通用混合层接口,同时也为 UI 层暴露一些平台相关的特性。好比在桌面端,这里会经过 Node 原生模块桥接通用混合层, 同时也补充一些 Electron 缺失或不完美的功能。


进程模型

Electron 的主从进程模型是基本的常识。每一个 Electron 应用有且只要一个主进程(Main Process)、以及一个或多个渲染进程(Renderer Process), 对应多个 Web 页面。除此以外还有 GPU 进程、扩展进程等等。能够经过 Electron Application Architecture 了解 Electron 的基本架构。

主进程负责建立页面窗口、协调进程间通讯、事件分发。为了安全考虑,原生 GUI 相关的 API 是没法在渲染进程直接访问的,它们必须经过 IPC 调用主进程。这种主从进程模型缺点也很是明显,即主进程单点故障。主进程崩溃或者阻塞,会影响整个应用的响应。好比主进程跑长时间的 CPU 任务,将阻塞渲染进程的用户交互事件。


对咱们的应用来讲,目前有如下进程, 以及它们的职责:

① 主进程

  • 进程间通讯、窗口管理
  • 全局通用服务。
  • 一些只能或适合在主进程作的事情。例如浏览器下载、全局快捷键处理、托盘、session。
  • 维护一些必要的全局状态
  • 上面说的通用混合层也跑在这个进程。经过 Node C++ 插件暴露接口。

② 渲染进程

负责 Web 页面的渲染, 具体页面的业务处理。


③ Service Worker

负责静态资源缓存。缓存一些网络图片、音频。保证静态资源的稳定加载。



技术选型与代码组织

说说咱们的技术选型。

  • UI 框架 - React
  • 状态管理 - Mobx
  • 国际化 - i18next
  • 打包 - 自研 CLI

源码组织

bridge/                  # 桥接层代码
resources/               # 构建资源,以及第三方DLL
src/

  main/                  # 🔴主进程代码
    services/            # 📡**经过 RPC 暴露给渲染进程的全局服务**
      tray.ts            # 托盘状态管理
      shortcut.ts        # 全局快捷键分发
      preferences.ts     # 用户配置管理
      windows.ts         # 窗口管理
      screen-capture.ts  # 截屏
      bridge.ts          # 桥接层接口封装
      context-menu.ts    # 右键菜单
      state.ts           # 全局状态管理, 保存一些必要的全局状态,例如主题、当前语言、当前用户
      ...
    lib/                 # 封装库
      bridge.ts          # 桥接层API 分装
      logger.ts          # 日志
      ...
    bootstrap.ts         # 启动程序
    index.ts             # 🔴入口文件

  renderer/              # 🔴渲染进程
    services/            # 📡主进程的全局服务的客户端
      windows.ts         # 窗口管理客户端
      tray.ts
      ...
    assets/              # 静态资源
    hooks/               # React Hooks
    components/          # 通用组件
      Webview
      Editor
      toast
      ...
    pages/               # 🔴页面
      Home
        ui/              # 🔴视图代码,由前端团队维护
        store/           # 🔴状态代码,由客户端团队维护,前端Store的公开状态
        translation/     # 国际化翻译文件
        index.tsx        # 页面入口
      Settings
      Login
    page.json            # 🔴声明全部页面及页面配置。相似小程序
复制代码

眼尖的读者会发现每一个页面下有 uistore 目录,分别对应视图和状态。为何这么划分?

首先这是由于这个项目由两个团队共同来开发的,即原有的原生客户端团队和咱们的前端团队。分离视图和状态有两个好处:

  • 前端前期不须要关心客户端底层业务,而客户端也不须要关心前端的页面实现。职责明确,各自干好本身事情。
  • 下降学习成本。咱们状态管理选用了 Mobx,对于客户端同窗,只须要掌握少许的 Typescript 语言知识就能够立刻上手。若是熟悉 Java、C# 那就更没什么问题了。每一个 Store 只是一个简单的类:
class CounterStore extends MobxStore {
  @observable
  public count: number = 0

  @action
  public incr = () => {
    this.count++
  }

  private pageReady() {
    // 页面就绪,能够在这里作一些准备工做

    // 事件监听
    // addDisposer 将释放函数添加到队列中,在页面退出时释放
    this.addDisposer(
      addListener('someevent', evt => {
        this.dosomething(evt)
      })
    )

    // ...
    this.initial()
  }

  private pageWillClose() {
    // 页面释放,能够在这里作一些资源释放
    releaseSomeResource()
  }

  // ....
}
复制代码

使用 Mobx 做为状态管理,相比 Redux,面向对象思想对他们更好理解。在这种场景,简单才是真理;

分离了状态和业务逻辑,前端页面实现也简化了,视图只是状态的映射,这让咱们的页面和组件更好被维护和复用。



性能优化(硬货)

前戏完了,关于 Electron 的一些性能优化才是本篇文章的重头戏。

Electron 不是银弹,鱼和熊掌不可兼得。Electron 带来开发效率的提高,其自己也有不少硬伤,譬如常被人吐槽的内存占用高,和原生客户端性能差别等等。为了优化 Electron 应用,咱们也作了不少工做。

性能优化通常都分两步走:


1. 性能分析

最好的分析工具是 Chrome 开发者工具的 Performance。经过火焰图, JavaScript 执行过程的任何蛛丝马迹均可以直观的看到。


对于主进程,开启调试后也能够经过 Profile 工具收集 JavaScript 执行信息。

若是你要分析某段代码的执行过程,也能够经过下面命令生成分析文件,而后导入到 Chrome Performance 中分析:

# 输出 cpu 和 堆分析文件
node --cpu-prof --heap-prof -e "require('request’)”“
复制代码


2. 优化策略

2.1 继续和白屏做斗争

即便 Electron 一般从本地文件系统加载 JavaScript 代码,没有网络加载延迟,咱们仍是须要继续和页面白屏作斗争,由于 JavaScript 等资源的加载、解析和执行仍是有至关大的代价(参考The cost of JavaScript in 2019)。做为一个桌面端应用,细微的白屏延迟用户均可以感受的到。咱们要尽可能让用户感受不到这是一个 Web 页面。

影响 Electron 白屏的主要因素有:页面窗口的建立、静态资源的加载、JavaScript 解析和执行

见招拆招,针对页面白屏咱们作了这些优化:


① 骨架屏

最简单的方式。在资源未加载完毕以前,先展现页面的骨架。避免用户看到白茫茫的屏幕。

另外须要设置背景色或者延迟显示窗口,来避免闪烁。

VSCode骨架屏


② 惰性加载

优先加载核心的功能,保证初次加载效率,让用户能够尽快进行交互。



  • 代码分割 + 预加载: 代码分割是最多见优化方式。咱们把隐藏的内容、或者次优先级的模块拆分出去,启动模块中只保留关键路径。咱们也能够在浏览器空闲时预加载这些模块。

  • 延后加载 Node 模块: Nodejs 模块的加载和执行须要花费较大的代价, 例如模块查找、模块文件读取、接着才是模块解析和执行。这些操做都是同步了,别忘了,node_modules 黑洞,某块模块可能会引用大量的依赖....

    Node 应用和 Electron 应用不太同样,一般 Node 服务器应用都会将模块放置在文件顶部, 而后同步加载进来。这个放到 Electron 用户界面上就没法忍受了。 用户界面的启动速度和交互阻塞, 用户是能够感知到的,并且忍耐程度会较低。

    因此要充分评估模块的大小和依赖。或者能够选择使用打包工具优化和合并 Node 模块。

  • 划分加载优先级:既然咱们没办法一开始将全部东西都加载出来,那就按照优先级渐进式地将在它们。举个例子,当咱们使用 VSCode 打开一个文件时,VScode 会先展现代码面板、接着是目录树、侧边栏、代码高亮、问题面板、初始化各类插件...


③ 使用现代的 JavaScript/CSS 代码

Electron 每一个版本都会预装当时最新的 Chrome,对于前端来讲,这是最爽的一件事情:

  • 没有负担地使用最新的 JavaScript 特性
  • 没有 Polyfill、没有 runtime-helper。相比老旧浏览器,代码量更少,性能也更好
  • 咱们须要主动抛弃一些老旧的依赖。保持使用最新的库

④ 打包优化

即便使用最新最牛逼的浏览器,打包工具仍是颇有用。

  • 减小代码体积: 现代打包工具备很是多优化手段,例如 Webpack 支持做用域提高、摇树,还有代码压缩、预执行... 这能够合并代码、压缩代码体积,裁剪多余的代码, 减小运行时负担。
  • 优化I/O: 咱们将模块合并以后,能够减小模块查找和加载的I/O往返。

v8 Snapshot or v8 Code Cache

Atom 有不少优质的文章,分享了他们优化Atom的经历。例如它们使用了 V8 的snapshot 来优化启动时间

这是一种 AOT 优化策略,简单说 Snapshot 是堆快照,你能够认为它是 JavaScript 代码在V8中的内存表示形态。

它有两个好处: 一是相比普通 JavaScript 加载更快,二是它是二进制的,若是你为了‘安全’考虑,能够将模块转换成snapshot,这样更难被‘破解’。

不过它也有较多限制。对架构的影响比较大。好比要求在初始化的过程当中不要有‘反作用’,例如DOM访问。由于在‘编译时‘这些东西不存在。

这篇文章详细介绍了如何在 Electron 中应用 v8 snapshot: How Atom Uses Chromium Snapshots


还有一个更加普遍使用的方案是 v8 Code Cache。NodeJS 12 开始在构建时提早为内置库生成代码缓存,从而提高 30% 的启动耗时。

经过这些文章,深刻了解 Code Cache 扩展阅读:



⑥ 窗口预热 与 窗口池、窗口常驻

为了追赶原生窗口的打开和展现速度,咱们运用了不少技巧,用空间来换取时间。

例如咱们的应用首页,用户在打开登陆页面时,咱们就会在后台预热,将该加载的资源都准备好,在登陆成功后,就能够当即渲染显示。窗口打开的延时很短,基本接近原生的窗口体验。

这里用到了一些 Hack 手段,咱们将这些窗口放到了屏幕以外,并设置 skipTaskBar 来实现隐藏或者关闭的效果。


对于频繁开启/关闭的窗口,也可使用窗口池来优化。好比 Webview 页面,打开的一个 Webview 页面时,会优先从窗口池中选取,当窗口池为空时才建立新的窗口, 后面页面关闭后会再放回窗口池中,方便后续复用。

另外,对于业务无关的、通用的窗口,也能够采用常驻模式,例如通知,图片查看器。这些窗口一旦建立就不会释放,打开效果会更好。


⑦ 跟进 Electron 最新版本

保持版本的更新。


2.2 追赶原生的交互体验

白屏时间的优化只是一个开始,应用使用过程当中的交互体验也是一个很是重要的部分。下面讲讲咱们的一些优化手段:


① 静态资源缓存

对于一些网络资源,咱们采起了一些缓存手段,保证它们展现的速度。咱们目前采用的是 Service-Worker + Workbox 的方式,利用 Service-Worker 能够拦截多个页面的网络请求,从而实现跨页面的静态资源缓存,这种方式实现比较简单。

除了 Service Worker,也能够经过协议拦截方式来实现。详见: protocol。后面有时间再尝试一下,看效果怎么样。


② 预加载机制

若是你看过个人 《这多是最通俗的 React Fiber(时间分片) 打开方式》, 应该见识到 requestIdleCallback 的强大,React 利用它来调度一些渲染任务,保证浏览器响应用户的交互。

这个 API 对于咱们的应用优化也有重要的意义。经过它咱们能够知道浏览器的资源利用状况,利用浏览器空闲时间来预执行一些低优先级的任务。好比:

  • 渲染隐藏的 Tab
  • 延后加载的模块代码
  • 惰性加载的图片
  • 未激活的会话
  • 执行低优先级的任务
  • ...

例如 React 代码分割:

export default function lazy(factory, Fallback) {
  const Comp = l(factory)
  // 预加载调度
  scheduleIdle({
    name: 'LazyComponent',
    size: TaskSize.Heavy,
    task: factory,
    timeout: 2000,
  })

  return function LazyComponent(props) {
    return (
      <Suspense fallback={Fallback ? <Fallback /> : null}> <Comp {...props} /> </Suspense> ) } as typeof Comp } 复制代码

使用:

const List = lazy(() => import('./List'))
复制代码

③ 避免同步操做

Electron 能够经过 NodeJS 进行 I/O 操做,可是咱们必定要尽可能避免同步 I/O。例如同步的文件操做、同步的进程间通讯。它们会阻塞页面的渲染和事件交互。


④ 减小主进程负荷

Electron 的主进程很是重要。它是全部窗口的父进程,它负责调度各类资源。若是主进程被阻塞,将影响整个应用响应性能。

你能够作一个简单的实验,在主进程上打一个断点,你会发现全部的页面窗口都会失去响应,尽管它们在各自不一样的进程。这是由于全部用户交互都是由主进程分发给渲染进程的,主进程阻塞了,渲染进程固然没法接收用户事件啦。

因此不要让主进程干脏活累活,能在渲染进程作的,就在渲染进程作。千万避免在主进程中跑计算密集任务和同步I/O


⑤ 分离CPU密集型操做到单独进程或Worker, 避免阻塞UI


⑥ React 优化

《React 性能优化的方向》


⑦ 放弃CSS-in-js

咱们为了压缩运行时性能,能在编译时作的就在编译时作,放弃了 CSS-in-js 方案,使用纯 CSS + BEM 来编写样式。主要有两个缘由:

  • Electron 使用较新的 Chrome,现代 CSS 已经很强大
  • 咱们使用了窗口预热机制,能够率先解析这部分 CSS 代码。而 CSS-in-js 方案则是组件渲染时,动态生成的。

⑧ 没有退路了,那就只能上 Node 原生模块了

真好,还有退路



2.3 优化进程通讯

涉及到多页面/窗口的 Electron 应用,IPC 会很是频繁,搞很差会成为性能瓶颈。


① 不要滥用 remote

remote 提供了一种简便的、无侵入的形式来访问主进程的API和数据。其底层基于同步的 IPC。你能够经过我这篇文章来了解它的原理。

坑在哪里呢?

① 它是同步的 ② 属性动态获取。为了确保你可以获取到最新的值,remote底层并不会进行缓存,而是每次获取一个属性就动态到主进程中取。

好比获取一个主进程中的对象:

// 主进程
global.foo = {
  foo: 1,
  bar: {
    baz: 2
  }
}
复制代码

渲染进程访问:

import {remote} from 'electron'

JSON.stringify(remote.getGlobal('foo'))
复制代码

这里会触发 4 次 同步 IPC: getGlobal、foo、bar、bar.baz。对于复杂的数据,这个消耗就很难忍受了。

避免使用 remote,除非你知道你本身在干什么。



② 封装IPC 库

为了优化 IPC 通讯,咱们本身基于Electron 的IPC接口, 封装了本身的一套 RPC 库。主要特征有:

  • 异步的。没有同步的选项。避免干蠢事
  • 消息合并。合并事件推送,批量传递
  • 序列化。直接传递 JSON 字符串,不让 Electron 干涉序列化。Electron 内部序列化稍微有点复杂,好比会处理 Buffer 等特殊类型。
  • 一致化的、简单易用的 API。使用同样在接口支持主进程与渲染进程,以及渲染进程与渲染进程之间双向通讯。

举个例子:

import rpc from 'myrpc'

// 注册方法
rpc.registerHandler('echo', async data => {
  return data
})

// 事件监听
rpc.on('some-event', (data, source) => {
  // dosomething
})
复制代码

客户端:

import rpc from 'myrpc'

rpc.emit(target, 'some-event') // target 为接收的窗口或者主进程。

// 方法调用
const res = await rpc.callHandler(target, 'echo', 'hello-world')
复制代码

还不够,咱们还在优化,后续再分享给你们。



坑仍是会有的

一路走来也遇到不少坑。痛并快乐着。

  • 窗口阴影、圆角
  • 剪切板不够强大
  • 一些兼容问题
  • 主进程崩溃,渲染进程不会退出,致使进程‘溢出’
  • 截屏。刚开始用 Electron 实现,效果很差,如今是原生实现
  • ...


扩展资料



回复: ivan 进群
相关文章
相关标签/搜索