electron 通讯原理与设计

1,640 阅读17分钟

前言

因为目前有时间了,所以在整理一下自己这几年写过的一些东西的相关文档,准备把一些东西改一下发出来,有的内容可能并不复杂,甚至有点浅显,但是也是对自己这几年的一些复盘和总结了

(以下内容来自ppt,可能写的不会特别详细)

如果有需要,转载前请向我确认



什么是Electron环境 ?

image.png

Electron环境 是一种使用Web技术(HTML, CSS, JavaScript)来构建跨平台桌面应用程序的框架。

Electron 允许开发者使用 JavaScript, HTML 和 CSS 等前端技术来编写桌面应用程序,这些应用程序可以在 Windows、macOS 和 Linux 等操作系统上运行。

其主要特点有:

  1. 跨平台:由于 Electron 基于 Chromium 和 Node.js,因此它可以轻松地构建出跨平台的桌面应用程序。
  2. 使用Web技术:开发者可以使用熟悉的 HTML, CSS 和 JavaScript 技术来构建 Electron 应用程序,这使得开发过程更加直观和高效。
  3. 功能丰富:Electron 应用程序可以访问系统级 API,如文件系统、网络请求等,这使得 Electron 应用程序可以具有与原生桌面应用程序相似的功能和性能。
  4. 社区支持:Electron 拥有庞大的社区支持,这意味着开发者可以轻松地找到教程、示例代码和库来加速开发过程。

简单介绍 - Chromium 基础架构

image.png

Chromium作为Electron架构中的关键组成部分,提供了强大的Web内容渲染能力。

我们在这里需要关注的有两点:

父进程:Browser,负责管理整个浏览器的状态,运行UI,消息派发(如申请文件资源)

子进程:Render,负责渲染网页,与Webkit(Blink)交互,向外(Browser、GPU)发送请求

主进程管理所有的web页面和它们对应的渲染进程。 每个渲染进程都是独立的,它只关心它所运行的 web 页面。

在页面中调用与 GUI 相关的原生 API 是不被允许的,因为在 web 页面里操作原生的 GUI 资源是非常危险的,而且容易造成资源泄露。 如果你想在 web 页面里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

简单介绍 - 主/渲染进程环境

image.png

主进程 (Main Process)

主进程是 Electron 应用程序的主控制进程,负责控制应用的生命周期、管理窗口和处理系统级事件。

特点:

  1. 单个实例:每个 Electron 应用只有一个主进程。
  2. Node.js 环境:主进程可以完全使用 Node.js 的 API 和模块。
  3. 负责窗口管理:主进程创建和管理应用的所有浏览器窗口。
  4. 与渲染进程通信:主进程通过 ipcMain 模块与渲染进程进行通信。

主要功能:

  • 创建窗口:使用 BrowserWindow 类创建和管理浏览器窗口。
  • 应用生命周期管理:处理应用启动、退出等生命周期事件。
  • 菜单和托盘:创建应用菜单和系统托盘图标。
  • 文件系统访问:直接访问和操作文件系统。
  • 与系统集成:如剪贴板、原生对话框、通知等。

渲染进程 (Renderer Process)

渲染进程负责显示应用的用户界面。每个 BrowserWindow 实例在其自身的渲染进程中运行。渲染进程是 Chromium 浏览器的一个实例。

特点:

  1. 多个实例:每个窗口(BrowserWindow)都有自己的渲染进程。
  2. 网页环境:渲染进程运行在浏览器环境中,可以使用 Web 技术(HTML、CSS、JavaScript)。
  3. 部分 Node.js 支持:如果启用了 Node.js 集成,可以使用部分 Node.js API,但不建议直接在渲染进程中执行高负载的后台任务。
  4. 与主进程通信:通过 ipcRenderer 模块与主进程通信。

主要功能:

  • UI 渲染:负责渲染应用的用户界面。
  • 与主进程通信:发送和接收消息。
  • 前端逻辑处理:处理用户交互和前端逻辑。

简单介绍 - 主/渲染进程通信方式

主/渲染进程通信 - ipcMain/ipcRenderer

Electron 提供了 ipcMainipcRenderer 模块来实现主进程和渲染进程之间的通信。

示例

// 主进程:main.js
ipcMain.on('asynchronous-message', (event, arg) => {
  console.log(arg); // 收到渲染进程的消息
  event.reply('asynchronous-reply', 'pong');
});

ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg); // 收到渲染进程的消息
  event.returnValue = 'pong';
});

// 渲染进程:renderer.js
const { ipcRenderer } = require('electron');

ipcRenderer.send('asynchronous-message', 'ping');

ipcRenderer.on('asynchronous-reply', (event, arg) => {
  console.log(arg); // 收到主进程的回复
});

const result = ipcRenderer.sendSync('synchronous-message', 'ping');
console.log(result); // 收到主进程的同步回复

ipcMain 模块

ipcMain 是一个事件发射器,用于监听渲染进程发送的异步或同步消息。它在主进程中使用。

主要方法和事件:
  • ipcMain.on(channel, listener)

    • 监听指定频道上的异步消息。
    • channel 是消息频道的名称。
    • listener 是消息处理函数,接收两个参数:event 和消息内容 args
  • ipcMain.handle(channel, handler)

    • 监听指定频道上的异步消息,并返回一个 Promise。
    • channel 是消息频道的名称。
    • handler 是消息处理函数,接收两个参数:event 和消息内容 args
  • ipcMain.once(channel, listener)

    • 监听指定频道上的异步消息,只处理一次,然后移除监听器。
    • channel 是消息频道的名称。
    • listener 是消息处理函数,接收两个参数:event 和消息内容 args

ipcRenderer 模块

ipcRenderer 是一个事件发射器,用于发送异步或同步消息到主进程,并监听主进程返回的消息。它在渲染进程中使用。

主要方法和事件:
  • ipcRenderer.send(channel, ...args)

    • 发送异步消息到主进程。
    • channel 是消息频道的名称。
    • args 是消息内容,可以是多个参数。
  • ipcRenderer.sendSync(channel, ...args)

    • 发送同步消息到主进程,并等待返回值。
    • channel 是消息频道的名称。
    • args 是消息内容,可以是多个参数。
  • ipcRenderer.on(channel, listener)

    • 监听指定频道上的异步消息。
    • channel 是消息频道的名称。
    • listener 是消息处理函数,接收两个参数:event 和消息内容 args
  • ipcRenderer.once(channel, listener)

    • 监听指定频道上的异步消息,只处理一次,然后移除监听器。
    • channel 是消息频道的名称。
    • listener 是消息处理函数,接收两个参数:event 和消息内容 args
  • ipcRenderer.invoke(channel, ...args)

    • 发送异步消息到主进程,并返回一个 Promise。
    • channel 是消息频道的名称。
    • args 是消息内容,可以是多个参数。

总结

  • 异步通信:使用 ipcRenderer.sendipcMain.on 实现异步通信,event.reply 用于回复。
  • 同步通信:使用 ipcRenderer.sendSyncipcMain.on 实现同步通信,event.returnValue 用于返回结果。
  • Promise 风格的异步通信:使用 ipcRenderer.invokeipcMain.handle 实现基于 Promise 的异步通信。

问题:在ipc通讯中 主进程为什么会阻塞渲染进程?

image.png

image.png

在chromium中,页面渲染时,UI进程需要和main process不断的进行sync IPC,若此时main process忙,则UI process就会在IPC时阻塞

主/渲染进程通信 - 广播

在 Electron 中,主进程和渲染进程之间的通信除了点对点的消息传递之外,还可以采用广播的方式。
这种方式允许一个进程发送消息,其他进程(一个或多个)接收并处理这些消息。

在 Electron 中,广播方式可以通过以下两种方式实现:

  1. 主进程广播消息给所有渲染进程

    • 使用 webContents 模块的 send 方法,可以将消息发送给所有渲染进程。
  2. 渲染进程之间的广播

    • 渲染进程可以通过主进程作为中介来实现广播。

示例一:主进程广播消息给所有渲染进程

主进程

const { app, BrowserWindow, ipcMain } = require('electron');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    }
  });

  mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);

// 监听渲染进程的广播请求
ipcMain.on('broadcast-message', (event, message) => {
  // 向所有渲染进程广播消息
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('broadcast', message);
  });
});

渲染进程

const { ipcRenderer } = require('electron');

// 发送广播请求
document.getElementById('broadcastButton').addEventListener('click', () => {
  const message = document.getElementById('messageInput').value;
  ipcRenderer.send('broadcast-message', message);
});

// 接收广播消息
ipcRenderer.on('broadcast', (event, message) => {
  console.log('Received broadcast message:', message);
});

示例二:渲染进程之间的广播

主进程

const { app, BrowserWindow, ipcMain } = require('electron');

let windows = [];

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    }
  });

  win.loadFile('index.html');
  windows.push(win);

  win.on('closed', () => {
    windows = windows.filter(w => w !== win);
  });
}

app.whenReady().then(createWindow);

ipcMain.on('renderer-broadcast', (event, message) => {
  windows.forEach(win => {
    win.webContents.send('broadcast', message);
  });
});

渲染进程

const { ipcRenderer } = require('electron');

ipcRenderer.on('broadcast', (event, message) => {
  console.log('Broadcast message received:', message);
});

// 发送广播消息请求
document.getElementById('broadcastButton').addEventListener('click', () => {
  ipcRenderer.send('renderer-broadcast', 'Hello, other windows!');
});

无论是主进程广播消息给所有渲染进程,还是渲染进程广播消息给其他渲染进程,都需要通过主进程作为中介来完成。

这种广播消息机制在复杂的 Electron 应用中非常有用,尤其是当需要协调多个窗口之间的状态或事件时。使用 ipcMainipcRenderer 模块,以及 webContents.send 方法,可以灵活地实现主进程和渲染进程之间的高效通信。

主/渲染进程通信 - remote

image.png

在 Electron 中,remote 模块曾是用于在渲染进程中调用主进程模块和方法的方式。它简化了主进程和渲染进程之间的通信,使得渲染进程可以直接访问主进程的 API。但是,从 Electron 12 开始,remote 模块被废弃了,并建议开发者迁移到更安全和高效的 IPC 模式。以下是详细介绍 remote 模块及其替代方法的通信方式。

使用 remote 模块

remote 模块允许你在渲染进程中调用主进程的方法或使用主进程的模块,而无需显式地通过 IPC 通信。

示例

主进程(main.js):

const { app, BrowserWindow } = require('electron');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    }
  });

  mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

渲染进程(renderer.js):

const { remote } = require('electron');
const { BrowserWindow } = remote;

document.getElementById('createWindowButton').addEventListener('click', () => {
  let win = new BrowserWindow({
    width: 400,
    height: 300
  });
  win.loadURL('https://example.com');
});

存在缺陷:

  1. 当渲染进程调用远程对象的方法/函数时,是进行同步IPC通信的,会阻塞两端的
  2. 主进程会保持引用每一个渲染进程访问的对象,包括函数的返回值
  3. 渲染进程访问到的对象为值的拷贝而非引用,当主进程对象发生属性扩展或变化时无法同步到渲染进程
  4. 远程对象在渲染进程中发生泄漏,则主进程中对应的应用对象也将被泄露

什么是IPC通信?

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

image.png

image.png

介绍

PIPE & FIFO(named PIPE)

PIPE

  • 原理:管道是一种单向通信机制,通常用于父子进程之间的通信。数据在管道中按顺序传递,类似于流。

  • 特性

    • 只能用于单向通信(半双工)。
    • 数据在内存中以 FIFO(先入先出)的方式存储。
    • 只有相关联的进程才能使用匿名管道。

image.png

image.png

亲缘关系
定义
  • 亲缘关系:管道通常用于具有亲缘关系的进程之间的通信,这意味着这些进程之间存在父子关系或它们是由同一祖先进程派生的。
使用场景
  • 父子进程:一个常见的使用场景是父进程创建一个子进程,然后使用管道在父进程和子进程之间传递数据。例如,父进程可以创建一个管道,并在创建子进程之前将管道的一端传递给子进程。
  • 无关进程:虽然传统的匿名管道只能用于亲缘进程之间,但命名管道(FIFO)可以在没有亲缘关系的进程之间使用,因为它们通过文件系统路径进行通信。
半双工

image.png

定义
  • 半双工:管道通常是半双工的,这意味着数据只能在一个方向上流动。如果需要双向通信,则需要创建两个管道。
特性
  • 单向数据流:一个管道只能在一个方向上传递数据,即数据只能从写端流向读端。要实现双向通信,需要两个管道,一个用于发送数据,一个用于接收数据。
#include <unistd.h>
#include <stdio.h>

int main() {
    int pipefds[2];
    char buffer[30];

    pipe(pipefds); // 创建管道

    if (fork() == 0) {
        // 子进程
        close(pipefds[1]); // 关闭写端
        read(pipefds[0], buffer, sizeof(buffer));
        printf("Child read: %s\n", buffer);
        close(pipefds[0]);
    } else {
        // 父进程
        close(pipefds[0]); // 关闭读端
        write(pipefds[1], "Hello from parent", 18);
        close(pipefds[1]);
    }
    return 0;
}

缓存区限制
定义
  • 缓存区限制:管道在内核中有一个固定大小的缓冲区。这个缓冲区限制了管道可以暂时存储的数据量。
特性
  • 阻塞行为:如果管道的写端试图写入的数据超过了缓冲区的容量,那么写操作将被阻塞,直到有足够的空间可以写入。
  • 读取限制:同样,如果管道的读端试图读取的数据多于缓冲区中可用的数据,读操作将被阻塞,直到有足够的数据可以读取。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

#define BUF_SIZE 10

int main() {
    int pipefds[2];
    char buffer[BUF_SIZE];
    pipe(pipefds);

    if (fork() == 0) {
        // 子进程
        close(pipefds[1]);
        int n = read(pipefds[0], buffer, BUF_SIZE);
        printf("Child read %d bytes: %s\n", n, buffer);
        close(pipefds[0]);
    } else {
        // 父进程
        close(pipefds[0]);
        write(pipefds[1], "Hello", 5); // 小于缓冲区大小
        sleep(1); // 等待子进程读取
        write(pipefds[1], " World", 6); // 依旧小于缓冲区大小
        close(pipefds[1]);
    }
    return 0;
}

无格式字节流
定义
  • 无格式字节流:管道传输的数据是无格式的字节流,不会对数据进行任何解释或修改。
特性
  • 无数据结构:管道仅仅传递字节流,数据的解析和处理需要由读取数据的进程负责。这意味着发送方和接收方必须就数据的格式达成一致。
  • 透明传输:数据以原始字节形式传输,不附加任何元数据。
#include <unistd.h>
#include <stdio.h>

int main() {
    int pipefds[2];
    char buffer[20];

    pipe(pipefds);

    if (fork() == 0) {
        // 子进程
        close(pipefds[1]);
        read(pipefds[0], buffer, 20);
        printf("Child read: %s\n", buffer);
        close(pipefds[0]);
    } else {
        // 父进程
        close(pipefds[0]);
        write(pipefds[1], "1234567890", 10); // 传递无格式字节流
        close(pipefds[1]);
    }
    return 0;
}

FIFO(Named PIPE)

  • 原理
    命名管道(FIFO)类似于匿名管道,但它在文件系统中具有一个名称,因此不相关的进程也可以通过名称进行通信。

  • 特性

    • 可以用于无关进程之间的通信。
    • 支持半双工和全双工通信。
    • 在文件系统中有路径,可以通过路径访问。

FIFO不同于管道之处在于它提供一个路径名与之关联,以FIFO的文件形式存储于文件系统中。
命名管道是一个设备文件,因此,即使进程与创建FIFO的进程不存在亲缘关系,只要可以访问该路径,就能够通过FIFO相互通信。值得注意的是, FIFO(first input first output)总是按照先进先出的原则工作,第一个被写入的数据将首先从管道中读

image.png

image.png

image.png

信号(signal)

原理

  • 信号是一种用于进程间通信的机制,允许进程向其他进程发送简单的通知。信号是一种异步通信方式。

特性

  • 只能传递非常有限的信息(通常是一个信号编号)。
  • 用于通知进程某些事件的发生(如终止、暂停、继续等)。
  • 不适合传递复杂数据。

Linux系统中常用信号:
(1)SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
(2)SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号。
(3)SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\键将产生该信号。
(4)SIGBUS和SIGSEGV:进程访问非法地址。
(5)SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
(6)SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号。
(7)SIGTERM:结束进程信号。shell下执行kill 进程pid发送该信号。
(8)SIGALRM:定时器信号。
(9)SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。

image.png

消息队列

原理

  • 消息队列是一种先进先出(FIFO)的队列,允许进程将消息添加到队列中或从队列中读取消息。消息队列可以在多个进程之间传递复杂的数据结构。

特性

  • 支持多个进程间的异步通信。
  • 可以存储和传递复杂的数据。
  • 消息有优先级,允许优先级高的消息先被读取。

共享内存

原理

  • 共享内存是最快的进程间通信方式,允许多个进程直接访问同一个内存段。进程可以通过这个共享内存段读写数据,实现高效通信。

特性

  • 高效,速度快。
  • 需要同步机制(如信号量)来避免并发读写冲突。
  • 适用于需要频繁读写大数据的应用场景。

套接字(socket)

原理

  • 套接字是一种通用的进程间通信机制,支持本地和远程进程间的通信。套接字既可以用于同一台机器上的进程通信(Unix 域套接字),也可以用于不同机器上的进程通信(网络套接字)。

image.png

特性

  • 支持跨网络的进程间通信。
  • 可以传输大量数据。
  • 支持可靠的 TCP 连接和不可靠的 UDP 连接。

进程通信模块设计方案

设计目标

1、支持跨进程之间的通信:

  • 主进程与渲染进程
  • 渲染进程与渲染进程

2、支持同进程不同组件之间的通信;

3、对外提供的接口和参数需与 electron-common-ipc 对齐

4、职责单一、对内聚合,不依赖其它模块

方案选型

调研了市面上比较常用的几种进程通信的封装,《深入浅出Electron》的作者和 Sugar-Electron 里的 ipc 模块

本质都是使用 ipcRenderer.on() 和 webContents.send() 等 API 进行封装,在渲染进程与渲染进程之间进行通信时,得经过主进程进行消息转发。

image.png

Electron 官方还提供了 MessagePort 这种方案允许在不同上下文(进程)之间传递消息,类似于浏览器中的 window.postMessage。

这种方案的好处是:假设两个渲染进程互相保存了对方的 port,那么它们就可以直接用这对 port 进行通信,不需要通过主进程中继的性能开销。

如果你之前对 MessagePort 不了解的话,可以看看这里的一个小例子:

MessagePort 对象的创建依赖于 MessageChannel 类:

const channel = new MessageChannel();
const port1 = channel.port1
const port2 = channel.port2
 
// 或者简写为:
const { port1, port2 } = new MessageChannel();
举个小例子,假设现在:

渲染进程一有了 port1
渲染进程二有了 port2
那么现在这两个进程就可以通过 port.onmessage 和 port.postMessage 来收发彼此间的消息了:

// 渲染进程一:
port1.onmessage = (event) => {
  console.log('received result:', event.data)
};
port1.postMessage('我是渲染进程一发送的消息');
 
 
// 渲染进程二:
port2.onmessage = (event) => {
  console.log('received result:', event.data)
};
port2.postMessage('我是渲染进程二发送的消息');

从上面的分析中得出,如果我们能设计一套机制,让渲染进程互相存有对方的 port,通过 port 进行收发消息,显然是比通过主进程进行消息转发要更好一些。

至于同进程不同组件之间的通信,利用 NodeJS 的 events 模块就可以实现。

设计方案

整体设计

  • 渲染进程初始化时,向主进程注册,并创建一对 port,主进程分发给渲染进程
  • 之后渲染进程使用 port 进行收发消息,不再经过主进程

image.png

注册进程时序图

image.png