likes
comments
collection
share

“Electron” 一个可圈可点的 PC 多端融合方案

作者站长头像
站长
· 阅读数 61

每天都要写第二天的 todoList。有一天在写的时候突然想到,为了让自己清楚知道自己需要做啥、做了多少、还剩多少没做,想写一个电脑端程序,在技术选型的时候就选了 Electron。 本篇文章的目的不是讲解 API 如何使用,想知道这些可以直接看官方文档。本文目的旨在讲明如何技术如何选择、如何快速上手、如何调试、Electron 底层原理、工程体系方面的总结。 如果想快速开发出一个桌面端的跨端产品,可以看看本篇文章。听完后或许在技术选型方面有一些启发、也会对市面上PC端 App 的技术实现有进一步的认识。

一、浅谈 GUI 系统

过程化绘制时代:调用 drawLine / drawRect 风格的 API 来绘制像素。

面向对象抽象时代:按钮、输入框等 UI 控件具备了实例方法

界面与样式分离时代:引入了 XML 风格的语言,专门来表达嵌套式的界面。(HTML / CSS / JS 基础上的经典 Web)

MVC 与 MVVM 时代:为了更好的维护日益复杂的 UI 交互逻辑。(iOS的Cocoa、微软的WPF、Web的Angular) 声明式组件化时代:React 通过 JSX 用 JS 来编写嵌套的、声明式的、更易维护的 UI 组件,并借助 JavaScript 的动态性来实时调试 UI。(Vue、Flutter 、SwiftUI) 我们可以看到不变的是:随着计算机科学技术的发展,为了实现开发效率的提升,一流程序员或者组织不断研发各种技术框架,来提高开发效率和效果。Electron 就是这条历史长河中诞生的 PC 端技术框架之一。

二、 技术选型

3天时间写了个 PC 端应用程序。先看看结果吧 “Electron” 一个可圈可点的 PC 多端融合方案 “Electron” 一个可圈可点的 PC 多端融合方案 “Electron” 一个可圈可点的 PC 多端融合方案

为什么要选 Electron 作为 pc 端开发方案?

史前时代,以 MFC 为代表的技术栈,开发效率较低,维护成本高。 后来使用 QT 技术,特点是使用 DirectUI + 面向对象 + XML 定义 UI,适用于小型软件、性能要求、包大小、UI 复杂度叫高的需求。 再到后来,以 QT Quick 为代表的技术,特点是框架本身提供子控件,基于子控件组合来创建新的控件。类似于 ActionScript 的脚本化界面逻辑代码。 新时代主要是以 ElectronCef 为 代表。特点是界面开发以 Web 技术为主,部分逻辑需要 Native 代码实现。大家都熟悉的 VS Code 就是使用 Electron 开发的。适用于 UI 变化较多、体积限制不大、开发效率高的场景。

拿 C 系列写应用程序的体验不好,累到奔溃。再加上有 Hybrid、React Native、iOS、Vue、React 等开发经验,Electron 是不二选择。

三、 Quick start

执行下面命令快速体验 Hello world,也是官方给的一个 Demo。

git clone https://github.com/Electron/Electron-quick-start
cd Electron-quick-start
npm install && npm start

简单介绍下 Demo 工程,工程目录如下所示 “Electron” 一个可圈可点的 PC 多端融合方案

在终端执行 npm start 执行的是 package.json 中的 scripts 节点下的 start 命令,也就是 Electron .. 代表执行 main.js 中的逻辑。

// Modules to control application life and create native browser window
const {app, BrowserWindow} = require('Electron')
const path = require('path')

function createWindow () {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  // and load the index.html of the app.
  mainWindow.loadFile('index.html')

  // Open the DevTools.
  mainWindow.webContents.openDevTools()
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(createWindow)

// Quit when all windows are closed.
app.on('window-all-closed', function () {
  // On macOS it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') app.quit()
})

app.on('activate', function () {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) createWindow()
})

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

写过 Vue、React、Native 的人看代码很容易。比如应用程序的生命周期钩子函数对开发者很重要,也是一个标准的做法,根据需求在钩子函数里面做相应的视图创建、初始化、销毁对象等等。比如 Electron 中的 activatewindow-all-closed 等。

app 对象在 whenReady 的时候执行 createWindow 方法。内部创建了一个 BrowserWindow 对象,指定了大小和功能设置。

  1. webPreferences:Object (可选) - 网页功能的设置。

    1. preload: String (可选) - 在页面运行其他脚本之前预先加载指定的脚本。无论页面是否集成 Node, 此脚本都可以访问所有 Node API 脚本路径为文件的绝对路径。 当 node integration 关闭时, 预加载的脚本将从全局范围重新引入 node 的全局引用标志。

mainWindow.loadFile('index.html') 加载了同级目录下的 index.html 文件。也可以加载服务器资源(部署好的网页),比如 win.loadURL('https://github.com/FantasticLBP')

接下去看看 preload.js

// All of the Node.js APIs are available in the preload process.
// It has the same sandbox as a Chrome extension.
window.addEventListener('DOMContentLoaded', () => {
  const replaceText = (selector, text) => {
    const element = document.getElementById(selector)
    if (element) element.innerText = text
  }
  console.table(process)
  console.info(process.versions)
  for (const type of ['chrome', 'node', 'Electron']) {
    replaceText(`${type}-version`, process.versions[type])
  }
})

在页面运行其他脚本之前预先加载指定的脚本,无论页面是否集成 Node, 此脚本都可以访问所有 Node API 脚本路径为文件的绝对路径。Demo 中的逻辑很简单,就是读取 process.versions 对象中的 node、chrome、Electron 的版本信息并展示出来。

index.html 中的内容就是主页面显示的内容。一般不会直接写 html、css、js,都会根据技术背景选择前端框架,比如 Vue、React 等,或者模版引擎 ejs 等。

四、 实现原理

前置说明:Electron 分为渲染进程和主进程。和 Native 中的概念不一样的是 Electron 中主进程只有一个,渲染进程(也就是 UI 进程) 有多个。主进程在后台运行,每次打开一个界面,会新开一个新的渲染进程。

  • 渲染进程: 用户看到的 web 界面就是由渲染进程绘制出来的,包括 html、css、js。
  • 主进程:Electron 运行 package.json 中的 main.js 脚本的进程被称为主进程。在主进程中运行的脚本通过创建 web 页面来展示用户界面。一个 Electron 应用程序总是只有一个主进程。

1. 跨端技术演进的底层逻辑是什么

跨平台开发是为了增加业务代码的复用率,减少因为要适配多个平台带来的工作量,从而降低开发成本。

想一想客户端上的跨端解决方案是什么?我将客户端跨端方案分为3个阶段:

第一阶段:Web 容器时代,Hybrid。早期跨端主要是 Webview 容器,然后 Hybrid 提供能力,打通 Native 和前端资源的能力访问,期间,基础架构团队将 Hybrid 层越做越大、越做越全,比如网络、数据持久化、安全、账号体系、页面路由能力,甚至前端会按照通信协议调起客户端宿主环境的很多定制化 UI。此种情况下跨端进入到第二阶段。

第二阶段:Weex/RN 时代。由于大量定制 UI。以及前端诞生了 Vue/React 声明式编程,规范化的 MVVM,各个角色清晰的 Redux,以及性能提升的 VDom 以及高效 diff 算法加持下,客户端跨端方案诞生了 Weex/RN。

做过 Weex 或者 RN 的人都会有一些问题,包括但不限于:

  • 引擎在 VDom 会自动映射到 Native UI 组件上,但是由于系统升级或者能力不支持,导致需要自己去开发和完善
  • Weex 官方很久没有更新了,之前做 APM 发现了大量子线程操作 UI 的 crash
  • 由于 Native UI 还是跟随宿主的系统(iOS/Android)和版本(iOS7、iOS15),所以 UI 会存在不可控。

第三阶段:自绘渲染引擎时代,Flutter 。Dart 些业务逻辑,Skia 渲染引擎去渲染。所以不存在 UI 不一致或者系统升级导致样式不可用或者出现不符合预期的问题

综上,可以看到 Native 跨端都是围绕着如何高效、多端一致的去绘制 UI。那 PC 端跨端要解决的问题也是如此。传统的前端 Vue/React 都只是展示层面的解决方案(不准确,UI 库/框架),可能很方便的构建浏览器环境下的项目。那浏览器到 PC 桌面端差些什么东西???

仔细想想,浏览器都是存在沙盒的,没有文件读取能力、没有 DB 操作能力、没有客户端所需要的基础能力(文件操作/数据库/通知/菜单栏等能力)。所以 Electron 的设计者很巧妙,包装了 Chromium 和 NodeJS 的能力。分别解决了多端一致性的渲染展示问题以及桌面端所需要的基础能力(数据库/文件等)。另外 Electron 提供了一些桌面端基础特性,比如通知、菜单栏等。所以觉得他的设计很取巧、很妙。

2. Chromium 架构演进

浏览器分为单进程和多进程架构。下面先讲讲 Chrome 为代表的浏览器过去和未来。

2.1 单进程浏览器

单进程浏览器指的是浏览器的所有功能模块都是运行在同一个进程里的,这些模块包括网络、插件、Javascript 运行环境、渲染引擎和页面等。如此复杂的功能都在一个进程内运行,所以导致浏览器出现不稳定、不安全、不流畅等问题。

“Electron” 一个可圈可点的 PC 多端融合方案

早在2007年之前,市面上的浏览器都是单进程架构。

  • 问题1: 不稳定

    早期浏览器需要借助插件来实现类似 Web 视频、Web 游戏等各种“强大”的功能。但插件往往是最容易出现问题的模块。因为运行在浏览器进程中,所以一个插件的意外奔溃到导致整个浏览器到的奔溃。

    除了插件之外,渲染引擎模块也是不稳定的。通常一些复杂的 Javascript 代码就有可能导致渲染引擎模块的奔溃。和插件一样,渲染引擎的奔溃会导致整个浏览器奔溃。

  • 问题2: 不流畅

    从单进程浏览器架构图看出,所有页面的渲染模块、Javascript 执行环境、插件都是在一个线程中执行的。这意味着同一时刻只有一个模块可以执行。

    function execUtilCrash() {
      while (true) {
        console.log("Stay hungry, stay foolish.");
      }
    }
    execUtilCrash();
    

    在单进程浏览器架构下,该代码在执行的时候会独占线程,导致其他运行在该线程中的模块没机会执行,浏览器中的所有页面都运行在该线程中,所以页面都没机会去执行任务,表现为整个浏览器失去响应,也就是卡顿。

    脚本、插件 会让单进程浏览器变卡顿外,页面的内存泄露也会导致浏览器卡顿。通常浏览器内核是非常复杂的,运行一个复杂的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是随着使用时间的变长,内存泄漏问题越严重,内存占用越高,可用内存越来越少,浏览器会变得越来越慢。

  • 问题3: 不安全

    一般浏览器插件都是用 C/C++ 编写的,通过插件就可以获取到较多的操作系统资源,当你在页面上运行一个插件的时候,也就意味着这个插件能“完全”控制你的电脑。如果是恶意插件,那它就可以做一些窃取账号密码等,引发安全问题

2.2 早期多进程架构浏览器

“Electron” 一个可圈可点的 PC 多端融合方案

上图2008年 Chrome 发布时的进程架构图。可以看出 Chrome 的页面是运行在单独的渲染进程中,同时页面的插件也是运行在单独的插件进行中的,进程之间通过 IPC 进行通信。

解决了不稳定问题。由于进程之间是彼此隔离的,所以当一个页面或者插件奔溃时,受影响的仅仅是当前的页面或者插件进程,并不会影响到浏览器和其他的页面。也就是说解决了早期浏览器某个页面或者插件奔溃导致整个浏览器的奔溃,从而解决了不稳定问题。

解决了不流畅问题。 同样,Javascript 进行也是运行在渲染进程中的,所以即使当前 Javascript 阻塞了渲染进程,影响到的也只是当前的渲染页面,并不会影响到浏览器和其他页面或者插件进程(其他的页面的脚本是运行在自己的渲染进程中的)。

对于内存泄漏的解决办法更加简单。当关闭某个页面的时候,整个渲染进程就会被关闭,所以该进程所占用的内存都会被系统回收,于是轻松解决了浏览器页面的内存泄漏问题。

解决了安全问题。采用多进程架构可以使用安全沙箱技术。沙箱可以看成是操作系统给浏览器一个小黑盒,黑盒内部可以执行程序,但是不能访问操作系统资源、不能访问硬盘数据,也不能在敏感位置读取任何数据,例如你的文档和桌面。Chrome 把插件进程和渲染进程使用沙箱隔离起来,这样即使在渲染进程或者浏览器进程中执行了恶意代码,恶意代码也无法突破沙箱限制去获取系统权限。

沙箱隔离起来的进程必须使用 IPC 通道才可以与浏览器内核进程通信,通信进程就会进行安全的检查。

沙箱设计的目的是为了让不可信的代码运行在一定的环境中,从而限制这些代码访问隔离区之外的资源。如果因为某种原因,确实需要访问隔离区外的资源,那么就必须通过的指定的通道,这些通道会进行严格的安全检查,来判断请求的合法性。通道会采取默认拒绝的策略,一般采用封装 API 的方式来实现。

2.3 目前多进程架构浏览器

Chrome 团队不断发展,目前架构有了较新变化,最新 Chrome 架构图如下所示

“Electron” 一个可圈可点的 PC 多端融合方案

最新 Chrome 浏览器包括:1个网络进程、1个浏览器进程、1个 GPU 进程、多个渲染进程、多个插件进程。

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储功能。
  • 渲染进程:核心任务是将 HTML、CSS、Javascript 转换为用户可以与之的网页,排版引擎 Blink 和 Javascript 引擎 V8 都是运行在该进程中。默认情况下,Chrome 为每个 tab 标签页创建一个新的渲染进程。出于安全考虑,渲染进程都是运行在沙箱机制下的。
  • GPU 进程:最早 Chrome 刚发布的时候是没有 GPU 进程的,而 GPU 的使用初衷是实现 css 3D 效果。随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍需求。最后 Chrome 多进程架构中也引入了 GPU 进程。
  • 网络进程:主要负责页面的网络资源请求加载。早期是作为一个模块运行在浏览器进程里面的,最近才独立出来作为一个单独的进程。
  • 插件进程:主要负责插件的运行。因插件代码由普通开发者书写,所以在 QA 方面可能不是那么完善,代码质量参差不齐,插件容易奔溃,所以需要通过插件进程来隔离,以保证插件进程的奔溃不会对浏览器和页面造成影响。

所以,你会发现打开一个页面,查看进程发现有4个进程。凡事具有两面性,上面说了多进程架构带来浏览器稳定性、安全性、流畅性,但是也带来一些问题:

  • 更高资源占用:每个进程都会包含公共基础结构的副本(如 Javascript 运行环境),这意味着浏览器将会消耗更多的资源
  • 更复杂的体系结构:浏览器各模块之间耦合度高、拓展性差,会导致现在的架构很难适应新需求。

Chrome 团队一直在寻求新的弹性方案,既可以解决资源占用较高问题吗,也可以解决复杂的体系架构问题。

2.4 未来面向服务的架构

2016年 Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了最新的 Chrome 架构。Chrome 整体架构会向现代操作系统所采用的“面向服务的架构”方向发展。

之前的各种模块会被重构成为单独的服务(Services),每个服务都可以运行在独立的进程中,访问服务必须使用定义好的接口,通过 IPC 进行通信。从而构建一个更内聚、低耦合、易于维护和拓展的系统。

Chrome 最终把 UI、数据库、文件、设备、网络等模块重构为基础服务。下图是 “Chrome 面向服务的架构”的进程模型图 “Electron” 一个可圈可点的 PC 多端融合方案

目前 Chrome 正处于老架构向新架构的过度阶段,且比较漫长。

Chrome 提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是在设备资源受限的情况下,Chrome 会将很多服务整合到一个进程中,从而节省内存占用。 “Electron” 一个可圈可点的 PC 多端融合方案

2.5 小实验

测试环境: MacBook Pro(macOS 10.15.3)、Chrome Version 81.0.4044.138 (Official Build) (64-bit)

操作步骤:

  1. 打开 Chrome 浏览器
  2. 在地址栏输入 https://github.com/FantasticLBP
  3. 点击 Chrome 浏览器右上角 ...,在下拉菜单中选择 More Tools,在对应的展开菜单中点击 Task Manager

实验现象: “Electron” 一个可圈可点的 PC 多端融合方案

实验结论:

仅仅打开了 1 个页面,为什么有 4 个进程?因为打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。

从而印证了上述观点。

2.6 特殊情况

现象:我们在使用 Chrome 的时候还是会出现由于单个页面卡死最终崩溃导致所有页面崩溃的情况,why?

通常情况下是一个页面使用一个进程,但是,有一种情况,叫"同一站点(same-site)",具体地讲,我们将“同一站点”定义为根域名(例如,github.com)加上协议(例如,https:// 或者http://),还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:

developer.github.com www.github.com www.github.com:8080 都是属于同一站点,因为它们的协议都是 https,而根域名也都是 github.com。区别于浏览器同源策略。

Chrome 的默认策略是,每个标签对应一个渲染进程。但是如果从一个页面打开了新页面,而新页面和当前页面属于同一站点时,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫 process-per-site-instance

直白的讲,就是如果几个页面符合同一站点,那么他们将被分配到一个渲染进程里面去。

这种情况下,一个页面崩溃了,会导致同一站点的页面同时崩溃,因为他们使用了同一个渲染进程。

为什么要让他们跑在一个进程里面呢?

因为在一个渲染进程里面,他们就会共享JS的执行环境,也就是说A页面可以直接在B页面中执行脚本。因为是同一家的站点,所以是有这个需求的

2.7 Chromium 架构

“Electron” 一个可圈可点的 PC 多端融合方案

这张图是 chromium 多进程架构图。

多进程架构的浏览器解决了上述问题,至于如何解决的以后的文章会专门讲解,不是本文的主要内容。

简单描述下。

  • 主进程中的 RenderProcessHost 和 render 进程中的 RenderProcess 是用来处理进程间通信的(IPC)。
  • Render 进程中的 RenderView 内容基于 WebKit 排版展示出来的
  • Render 进程中的 ResourceDispatcher 是用来处理资源请求的。Render 进程中如果有请求则创建一个请求 ID,转发到 IPC,由 Browser 进程中处理后返回
  • Chromium 是多进程架构,包括一个主进程,多个渲染进程

对于 chromium 多进程架构感兴趣的可以点击这个链接查看更多资料-Multi-process Architecture

3. Electron 架构

“Electron” 一个可圈可点的 PC 多端融合方案

Electron 架构和 Chromium 架构类似,也是具有1个主进程和多个渲染进程。

  • 主进程用来处理新建页面、IPC 通信(RenderProcessHost)
  • 渲染进程中的 RenderView 基于 Webkit 用于排版。ResourceDispatcher 用于处理资源请求。比如页面所需一个资源或者网络,则创建一个网络请求,记录请求 ID,IPC 转发到主进程,请求完之后返回
  • 在各个进行中暴露了 Native API ,提供了 Native 能力。
  • 引入了 Node.js,所以可以使用 Node 的能力

设计巧妙的地方(也是一个技术难点):由于 Electron 内部整合了 Chromium 和 Node.js,所以可以在页面中使用 Node 的能力、可以在 Node 中管理窗口。主线程在某个时刻只可以执行一个事件循环,但是2者的事件循环机制不一样,Node.js 的事件循环基于 libuv,但是 Chromium 基于 message bump

所以 Electron 原理的重点就是「如何整合事件循环」,2种思路:

  • Chromium 集成到 Node.js 中:用 libuv 实现 messagebump(Node-Webkit 就是这么干的,缺点挺多。Electron 早期也尝试过。在渲染进程中实现比较简单,在主进程中由于各个系统的 GUI 实现机制不一样,比如 Mac 是 NSRunLoop、Linux 是 glib 工程量浩大、很多边界 case 处理不好,ROI 太低了)
  • Node.js 集成到 Chromium 中(Electron 所采用的方式,后来随着 libuv 引入 backend_fd 的概念,它是 libuv 轮询事件的文件描述符。通过轮训 backend_fd 可以知道 libuv 的新事件。所以 Electron 采取的做法就是将 Node.js 集成到 Chromium 中)

“Electron” 一个可圈可点的 PC 多端融合方案

上图描述了 Node.js 如何融入到 Chromium 中。描述下原理

  • Electron 新起一个安全线程去轮训 backend_fd
  • 当检测到一个新的 backend_fd,也就是一个新的 Node.js 事件之后,通过 PostTask 转发到 Chromium 的事件循环中

查看源码:github.com/electron/el…

上述2个步骤完成了 Electron 的事件循环。

“Electron” 一个可圈可点的 PC 多端融合方案 起了一个独立的线程,去轮询具体的事件

“Electron” 一个可圈可点的 PC 多端融合方案

当有了新的事件后,就会唤起主线程。主线程通过 PostTask 转发到 Chromium 的事件循环。这样子 NodeJS 和 Chromium 的事件循环就整合在一起了。

“Electron” 一个可圈可点的 PC 多端融合方案

引申:

大家或许知道 NodeJS 的事件循环机制是 libuv,那么作为 NodeJS 的新解决方案 Deno 中的事件循环机制是什么? 是 Tokio。Deno 最初是用 Go 来编写的,但是由于性能问题和缺乏垃圾回收,很快就用 Rust 重写了。Rust 允许我们将数据存储在栈或堆上,并不需要在编译时再进行分配。这种方法确保了访问内存的高效,消除了对持续运行的垃圾回收的需求。通过直接访问硬件,Rust 成为了底层开发的最佳理想语言,在很多领域取代了 C++。除了技术方面,Rust 社区也非常活跃,我们可以很容易找到大量资料来学习 Rust。因此使用了 Rust,那使用 Tokio 就很自然了,Tokio 上 Rust 的异步 runtime。它对 Deno 的意义,就像 libuv 对于 Node.js 的意义。由于使用多线程来调度程序,它可以用最小的开销提供卓越的性能。Tokio 还有一组内存安全的 API,帮助我们防止和内存相关的错误。这些功能都为 Deno 提供了坚实可靠的基础。

五、 如何调试

调试分为主进程调试和渲染进程调试。

1. 渲染进程调试

看到 Demo 工程中执行 npm start 之后可以看到主界面,Mac 端快捷键 comand + option + i,唤出调试界面,类似于 chrome 下的 devtools。其实就是无头浏览器的那些东西。或者在代码里打开调试模式 mainWindow.webContents.openDevTools()

工程采用 Electron + Vue 技术,下面截图 Vue-devtools 很方便查看 Vue 组件层级等 Vue 相关的调试 “Electron” 一个可圈可点的 PC 多端融合方案

2. 主进程调试方式

主进程调试有2种方法

方法一:利用 chrome inspect 功能进行调试

  • 需要在启动的时候给 package.json 中的 scripts 节点下的 start 命令加上调试开关
--inspect=[port]
// electrom --inspect=8001 yourApp
  • 然后打开浏览器,在地址栏输入 chrome://inspect
  • 点击 configure,在弹出的面板中填写需要调试的端口信息 “Electron” 一个可圈可点的 PC 多端融合方案
  • 重新开启服务 npm start,在 chrome inspect 面板的 Target 节点中选择需要调试的页面
  • 在面板中可以看到主进程执行的 main.js。可以加断点进行调试 “Electron” 一个可圈可点的 PC 多端融合方案

方法二:利用 VS Code 调试 Electron 主进程。

  • 在 VS Code 的左侧菜单栏,第四个功能模块就是调试,点击调试,弹出对话框让你添加调试配置文件 launch.json

  • 编辑 launch.json 的文件内容。如下

    {
        "version": "0.2.0",
        "configurations": [
            {
                "type": "node",
                "request": "launch",
                "name": "Debug main process",
                "cwd": "${workspaceRoot}",
                "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/Electron",
                "windows": {
                    "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/Electron.cmd"
                },
                "args": ["."],
                "outputCapture": "std"
            }
        ]
    }
    
  • 在调试模点击绿色小三角,会运行程序,可以添加断点信息。整体界面如下所示。可以单步调试、可以暂停、鼠标移上去可以看到对象的各种信息。 “Electron” 一个可圈可点的 PC 多端融合方案

3. 主进程调试之 hot reload

Electron 的渲染进程中的代码改变了,使用 Command + R 可以刷新,但是修改主进程中的代码则必须重新启动 yarn run dev 。效率低下,所以为了提升开发效率,有必要解决这个问题

Webpack 有一个 api: watch-run,可以针对代码文件检测,有变化则 Restart “Electron” 一个可圈可点的 PC 多端融合方案

六、 软件更新方式

更新方式手动更新文件覆盖自动更新操作系统级别的应用商店
优点简单、稳定下载过程快稳定、快、打扰少统一、稳定
缺点过程繁琐、慢、影响使用、更新率低慢、实现比较复杂、稳定性差、写文件失败实现复杂受应用商店局限
适用场景低频更新、用户粘性高、作为 各种升级技术的降级方案打补丁高频更新软件、体验要求高操作系统应用商店上架的软件

Electron 官方给出了解决方案 Squirrel,基于 Squirrel 框架完成的自动更新,这其实就是图上“自定更新”的类别。

六、 开发 tips及其优化手段

1. 开发 tips

  1. 或许会为网页添加事件代码,但是页面看到的内容是渲染进程,所以事件相关的逻辑代码应该写在 html 引入的 render.js 中。

  2. render.js 中写 Node 代码的时候需要在 main.js 初始化 BrowserWindow 的时候,在 webPreferences 节点下添加 nodeIntegration: true 。不然会报错:renderer.js:9 Uncaught ReferenceError: process is not defined。

  3. 从 Chrome Extenstion V2 开始,不允许执行任何 inline javascript 代码在 html 中。不支持以内联方式写事件绑定代码。比如 <button onclick="handleCPU">查看</button>

    Refused to execute inline event handler because it violates the following Content Security Policy directive: 
    
  4. 利用 Electron 进行开发的时候,可以看成是 NodeJS + chromium + Web 前端开发技术。NodeJS 拥有文件访问等后端能力,chromium 提供展示功能,以及网络能力(Electron 网络能力不是 NodeJS 提供的,而是 chromium 的 net 模块提供的)。web 前端开发技术方案都可以应用在 Electron 中,比如 Vue、React、Bootstrap、sass 等。

  5. 在工程化角度看,使用 yarn 比 npm 好一些,因为 yarn 会缓存已经安装过的依赖,其他项目只要发现存在缓存,则读取本地的包依赖,会更加快速。

  6. 在使用 Vue、React 开发 Electron 应用时,可以使用 npm 或 yarn install 包,也可以使用 Electron-vue 脚手架工具。

    vue init simulatedgreg/Electron-vue my-project
    cd my-project
    npm install
    npm run dev
    
  7. 开发完毕后需要设置应用程序的图标信息、版本号等,打包需要指定不同的平台。

  8. 新开项目创建后会报错.

初始化工程后会报错 ERROR in Template execution failed: ReferenceError: process is not defined。解决办法是使用 nvm 将 node 版本将为 10。

继续运行还是报错,如下

┏ Electron -------------------

[11000:0615/095124.922:ERROR:CONSOLE(7574)] "Extension server error: Object not found: <top>", source: chrome-devtools://devtools/bundled/inspector.js (7574)

┗ ----------------------------

解决办法是在 main/index.dev.js 修改代码

- require('Electron-debug')({ showDevTools: true });
+ // NB: Don't open dev tools with this, it is causing the error
+ require('Electron-debug')();

在 In main/index.js in the createWindow() function:

mainWindow.loadURL(winURL);
+  // Open dev tools initially when in development mode
+  if (process.env.NODE_ENV === "development") {
+    mainWindow.webContents.on("did-frame-finish-load", () => {
+      mainWindow.webContents.once("devtools-opened", () => {
+        mainWindow.focus();
+      });
+      mainWindow.webContents.openDevTools();
+    });
+  }
  1. Electron 多窗口与单窗口应用区别 “Electron” 一个可圈可点的 PC 多端融合方案

  2. 知道 Electron 开发原理,所以大部分时间是在写前端代码。所以根据团队技术沉淀、选择对应的前端框架,比如 Vue、React、Angular。

  3. 也许开发并不难,难在视觉和 UX。很多写过网页的开发者或者以前端的视觉去写 Electron App 难免会写出网页版的桌面应用程序,说难听点,就是四不像 😂。所以需要转变想法,这是在开发桌面应用程序。

  4. Electron 和 Web 开发相比,各自有侧重点 “Electron” 一个可圈可点的 PC 多端融合方案

  5. 有些人开发 Electron 应用可能不喜欢 electron-vue 这样的工具,喜欢自己自定义。假如自己利用 Vue 或者 React 开发的,开发过网页的同学都会习惯使用 Vue-devtools、React-devtools。所以在选用 Vue 或 React 后,习惯使用强大的 Vue-devtools、React-devtools 来查看 State、Action、Redux、Vuex、组件层级树等。

  • 先去 Github 找到对应的 repo,比如 Vue-devtools
  • 克隆代码到本地
  • 先安装依赖,再构建出 dist 产物 cd vue-devtools; yarn install; yarn run build;

2. 优化手段

2.1 性能分析

因为开发过程中,Electron 体验就是在开发一个前端项目,所以我们使用 Chrome 的 Performance 进行分析。

分析某段代码的执行过程,也可以通过下面命令生成分析文件,然后导入到 Chrome Performance 中分析:

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

2.2 白屏优化

  • 不管是 Native App 还是 Web 网页,骨架屏都是比较常见的一些技术手段。比如弱网下简书 App 的效果

  • 懒加载。优先加载第一屏(主界面)所需的依赖,其他的依赖延迟加载

  • 代码分割。Webpack 工具支持代码分割,这在前端中是很常见的一种优化手段

  • Node 模块延迟加载或合并。Node 属于 CMD 规范,模块查找、文件读取都比较耗时,某些 Node 模块依赖模块较多、子模块较深这样首屏会很慢。所以延迟加载或者选择使用打包工具优化和合并 Node 模块。

  • 打包优化。现代打包工具有非常多优化手段。 Webpack 支持代码压缩、预执行... 裁剪多余的代码, 减少运行时负担。模块合并后还可以减小 IO 。

  • 和 Native App 中的 Hybrid 优化手段机制一样。可以对静态资源进行缓存(公司基础样式文件、基础组件等,设计资源更新策略)。使用 Service-Worker 进行静态资源拦截,或者在 Native 端做请求拦截,比如 Mac 端 NSURLProtocol

  • 预加载机制。在登陆界面或者启动动画阶段,初始化主界面,然后将主界面设置到不可见状态。等启动或者登陆后再将主界面设置为可见状态

  • 使用 Node API 尽量避免同步操作。一般 Node API 都有同步和异步2种 API,比如 fs 文件读取模块

  • 对内存影响较大的功能使用 Native 的能力,比如 Database、Image、Network、Animation 等。虽然 Web 都有对应的解决方案,但是原生对于这些功能的操作权限更大、内存控制更灵活、安全性更高、性能更佳

  • 减小主进程压力。Electron 有1个主进程和多个渲染进程,主进程是所有窗口的父进程,它负责调度各种资源。如果主进程被阻塞,将影响整个应用响应性能。

2.3 包体积优化

Electron 由于实现原理决定,所以包体积会比较大。但有一些解决措施:

缩减包体积:

  • yarn autoclean -F
  • electron-shard:整个 Electron 共享一个运行时
  • miniblink:国内开发者改版的浏览器轻量内核,mini-electron 分支打包后只有6M多。

七、 技术体系搭建:全景

其实一个技术本身的难易程度并不是能否在自己企业、公司、团队内顺利使用的唯一标尺,其配套的 CI/CD、APM、埋点系统、发布更新、灰度测试等能否与现有的系统以较小成本融合才是很大的决定要素。因为某个技术并不是非常难,要是大多数开发者觉得很难,那它设计上就是失败的。

1. 构建

“Electron” 一个可圈可点的 PC 多端融合方案

2. 工程解耦

“Electron” 一个可圈可点的 PC 多端融合方案

3. 问题定位

Electron 提供的 crash 信息进行包装。 “Electron” 一个可圈可点的 PC 多端融合方案

import { BrowserWindow, app, dialog} from 'Electron';
 
  
const mainWindow = BrowserWindow.fromId(global.mainId);
mainWindow.webContents.on('crashed',
   const options = {
      type: 'error',
      title: '进程崩溃了',
      message: '这个进程已经崩溃.',
      buttons: ['重载', '退出'],
    };    
   recordCrash().then(() => {
      dialog.showMessageBox(options, (index) => {
        if (index === 0) reloadWindow(mainWindow);
        else app.quit();
      });
    }).catch((e) => {
      console.log('err', e);
    });
})
 
function recordCrash() { 
    return new Promise(resolve => { 
       // 崩溃日志请求成功.... 
      resolve();
    })
}
  
function reloadWindow(mainWin) {
  if (mainWin.isDestroyed()) {
    app.relaunch();
    app.exit(0);
  } else {
    BrowserWindow.getAllWindows().forEach((w) => {
      if (w.id !== mainWin.id) w.destroy();
    });
    mainWin.reload();
  }
}
// index.js
app.on('will-finish-launching', () => {
  if(!isDev) {
  require('./updater.js')
  }
  require('./crash-reporter').init()
 })
 
 // crash-reporter.js
 const {crashReporter} = require('Electron')

function init() {
    crashReporter.start({
        productName: 'ZanPandora',
        companyName: 'youzan',
        submitURL: 'http://127.0.0.1:33855/crash',

    })
}
module.exports = {init}

// server
const Koa = require('koa')
const app = new Koa()
const Router = require('koa-router')
const serve = require('koa-static-server')
const router = new Router()
const compareVersions = require('compare-versions')
const multer = require('koa-multer')
const uploadCrash = multer({dest: 'crash/'})
router.post('/crash', uploadCrash.single('upload_file_minidump'), (ctx, next) => {
    console.log(ctx.req.body)
    // 存DB
})

4. 软件更新

// package.json
"dependencies": {
    "Electron-is-dev": "^1.1.0",
    "Electron-squirrel-startup": "^1.0.0",
     // ...
  },

// index.js
app.on('will-finish-launching', () => {
  if(!isDev) {
    require('./updater.js')
  }
  require('./crash-reporter').init()
})

// updater.js
const {autoUpdater, app, dialog} = require('Electron')
if(process.platform == 'darwin') {
    autoUpdater.setFeedURL('http://127.0.0.1:33855/darwin?version=' + app.getVersion())
} else {
    autoUpdater.setFeedURL('http://127.0.0.1:33855/win32?version=' + app.getVersion())
}

autoUpdater.checkForUpdates() // 定时轮训、服务端推送
autoUpdater.on('update-available', () => {
    console.log('update-available')
})

autoUpdater.on('update-downloaded', (e, notes, version) => {
    // 提醒用户更新
    app.whenReady().then(() => {
        let clickId = dialog.showMessageBoxSync({
            type: 'info',
            title: '升级提示',
            message: '已为你升级到最新版,是否立即体验',
            buttons: ['马上升级', '手动重启'],
            cancelId: 1,
        })
        if(clickId === 0) {
            autoUpdater.quitAndInstall()
            app.quit()
        }
    })
})

autoUpdater.on('error', (err) => {
    console.log('error', err)
})

// server
// index.js
const Koa = require('koa')
const app = new Koa()
const Router = require('koa-router')
const serve = require('koa-static-server')
const router = new Router()
const compareVersions = require('compare-versions')
const multer = require('koa-multer')
const uploadCrash = multer({dest: 'crash/'})
router.post('/crash', uploadCrash.single('upload_file_minidump'), (ctx, next) => {
    console.log(ctx.req.body)
    // 存DB
})
function getNewVersion(version) {
    if(!version) return null
    let maxVersion = {
        name: '1.0.1',
        pub_date: '2020-02-01T12:26:53+1:00',
        notes: '新增功能AAA',
        url: `http://127.0.0.1:33855/public/ZanPandora-1.0.1-mac.zip`
    }
    if(compareVersions.compare(maxVersion.name , version, '>')) {
        return maxVersion
    }
    return null
}
router.get('/win32/RELEASES', (ctx, next) => {
    let newVersion = getNewVersion(ctx.query.version)
    if(newVersion) {
        ctx.body='BBC6F98A5CD32C675AAB6737A5F67176248B900C ZanPandora-1.0.1-full.nupkg 62177782'
    } else {
        ctx.status = 204
    }
})
router.get('/win32/*.nupkg', (ctx, next) => {
    // redirect s3 静态文件服务
    ctx.redirect(`/public/${ctx.params[0]}.nupkg`)
})
router.get('/darwin', (ctx, next) => {
   // 处理Mac更新, ?version=1.0.0&uid=123
   let {version} = ctx.query
    let newVersion = getNewVersion(version)
    if(newVersion) {
        ctx.body = newVersion
    } else {
        ctx.status = 204
    }
})
app.use(serve({rootDir: 'public', rootPath: '/public'}))
app.use(router.routes())
    .use(router.allowedMethods())

app.listen(33855)

八、 优劣势及业界使用场景分析

优势

  • 开发效率高:Electron 是一个跨端框架,所以一个很明显的优势就是想开发桌面端 App,不需要 Mac、Windows、Linux 写3套代码,快速实现功能的开发。
  • 生态丰富:另一个优点就是实现是前端,所以资料和生态非常丰富,npm 包非常多,只需要专心实现业务就好。
  • 显示效果多端统一:通过对实现原理的分析发现,渲染是通过 chromium 来实现的,所以多端显示效果统一。

劣势

  • 包体积较大:Electron 为了实现跨平台的目的,它将整个 V8 引擎和 Chromium 的内核都打进去了。所以即使是一个空的 Electron 项目,打出的包都有120M。
  • 内存开销较大:上文也讲过,Electron 是 chromium 多进程架构,所以代码写的不好,会出现内存开销较大的问题。

使用场景

业界拿 Electron 做了很多东西,比如大家都在用的 VSCode、Atom、Xmind、Lark 或者一些小工具的实现、很多大厂的面试软件、一些不是非常注重渲染效率的产品、一些集团内部的开发工具等(字节在做 APM 对应的桌面端、阿里客户端上 SDK 收集到的数据和一些功能都在一个 Electron 桌面端 App 上可以查看或者操作)。Electron 大有可为,有很多想象空间去做一些事情。

1. 字节跳动

  • Electron做了一个工具,能直接查看线上包的函数耗时,无任何侵入
  • 调试工具的合集
  • 研发需求管理
  • 代码合并工具(MR)。区别于 gitlab 的特性,没有多仓合代码的能力。 比如同时在主工程系修改了7个 pod 、1个主工程的代码,需要分批次提交,不具备同时将8个仓库的代码原子性合入
  • pod 的 changeLog、版本号等需要设计自动发版的流程
  • 把性能调试和效率工具都整合到一起了。

2. 阿里

  • 沙盒的查看与操作,比如在 PC 端查看移动沙盒内文件内容、数据库文件的查看与 SQL 执行等
  • lint 功能、检测无用方法、在线日志解密查看、数据库文件的解密查看、jspatch 的 mock、
  • 针对网络请求和响应的操作,比如自定义请求、延迟、mock response、
  • Mock、查看
  • 性能测试:cpu、load、fps、启动耗时(机房有高速摄像机解帧,模拟点击 icon、启动页启动、App 首页出现、首页图片出现、App 可以滚动交互了。不需要 hook 去监控耗时)
  • pre-main、main、首页打点
  • 数据板:埋点体系的数据,看坑位的点击效果
  • 应用数据:沙盒、Cookie、数据
  • 开发工具:CPU、内存、OOM
  • 视觉:移动端元素的参考线
  • 网络、开关数据、卡顿、内存泄漏

其他各个公司

VSCode

“Electron” 一个可圈可点的 PC 多端融合方案

“Electron” 一个可圈可点的 PC 多端融合方案

Lark(早期是 Electron 实现的,后来换掉了 Electron,不过也是采用 Webview,对 Chromium 做了一些裁剪和优化)

“Electron” 一个可圈可点的 PC 多端融合方案 Xmind

“Electron” 一个可圈可点的 PC 多端融合方案

一言以蔽之:该工具只做数据的查看、Mock 等工作,不做线上数据的干扰和生产。

主要实现方式:通过 deviceID、ip 地址等与设备产生连接,将一切可以标准化的流程都抽象、自动化、比如性能调试和效率工具都整合到一起。

结束语

最后简单概括下:本文讲了 Native 跨端框架的演进逻辑,从而带出了 PC 端跨端框架 Electron,并介绍了它的实现原理,同时也讲了 Electron 的开发、调试的一些东西,讲了它的优缺点。最后也聊聊了业界或者我们日常的哪些软件是 Electron 写的。

或者以后大家要开发桌面端了,在做技术选型的时候可以想到本文,想到 Electron 并知道它的优缺点,以及一些解决方法。这也是我本篇文章选这个题目的目的和意义。

转载自:https://juejin.cn/post/6906148548094951432
评论
请登录