揭开Electron的remote模块的面纱
Electron的remote模块是一个比较神奇的东西,为渲染进程
和主进程
通信封装了一种简单方法,通过remote你可以’直接’获取主进程对象或者调用主进程函数或对象的方法, 而不必显式发送进程间消息, 类似于 Java 的 RMI. 例如:
const { remote } = require('electron') |
本质上,remote模块是基于Electron的IPC机制的,进程之间的通信的数据必须是可序列化的,比如JSON序列化。所以本文的目的是介绍Electron是如何设计remote模块的,以及里面有什么坑。
文章大纲
通信协议的定义
上文说到,remote本质上基于序列化的IPC通信的,所以首先关键需要定义一个协议来描述一个模块/对象的外形,其中包含下列类型:
- 原始值。例如字符串、数字、布尔值
- 数组。
- 对象。对象属性、对象的方法、以及对象的原型
- 函数。普通函数和构造方法、异常处理
- 特殊对象。Date、Buffer、Promise、异常对象等等
Electron使用MetaData(元数据)来描述这些对象外形的协议. 下面是一些转换的示例:
基本对象: 基本对象很容易处理,直接值拷贝传递即可
输入
1;
new Date();
Buffer.from('hello world');
new Error('message');输出
{type: "value", value: 1};
{type: "date", value: 1565002306662}; // 序列化为时间戳
{type: "buffer", value: {data: Uint8Array(11), length: 11, type: "Buffer"}}; // 序列化为数组
{
members: [
{
name: "stack",
value: "Error: message\n at Object.<anonymous> (省略调用栈)"
},
{ name: "message", value: "message" },
{ name: "name", value: "Error" }
],
type: "error"
}
数组: 数组也是值拷贝
输入
[1, 2, 3];
输出
数组会递归对成员进行转换. 注意数组和基本类型没什么区别,它也是值拷贝,也就是说修改数组不会影响到对端进程的数组值。
{
"members": [
{"type":"value","value":1},
{"type":"value","value":2},
{"type":"value","value":3}
],
"type":"array"
}
纯对象:
输入
{
a: 1,
b: () => {
this.a;
},
c: {
d: 'd'
}
}输出
{
// 这里有一个id,用于标识主进程的一个对象
id: 1,
// 对象成员
members: [
{ enumerable: true, name: "a", type: "get", writable: true },
{ enumerable: true, name: "b", type: "method", writable: false },
// electron只会转换一层,不会递归转换内嵌对象
{ enumerable: true, name: "c", type: "get", writable: true },
],
name: "Object",
// 对象的上级原型的MetaData
proto: null,
type: "object"
}
函数:
输入
function foo() {
return 'hello world';
};输出
{
// 函数也有一个唯一id标识,因为它也是对象,主进程需要保持该对象的引用
id: 2,
// 函数属性成员
members: [],
name: "Function",
type: "function"
// Electron解析对象的原型链
proto: {
members: [
// 构造函数
{
enumerable: false,
name: "constructor",
type: "method",
writable: false
},
{ enumerable: false, name: "apply", type: "method", writable: false },
{ enumerable: false, name: "bind", type: "method", writable: false },
{ enumerable: false, name: "call", type: "method", writable: false },
{ enumerable: false, name: "toString", type: "method", writable: false }
],
proto: null
},
}
Promise:Promise只需描述then函数
输入:
Promise.resolve();
输入:
// Promise这里关键在于then,详见上面的函数元数据
{
type: "promise"
then: {
id: 2,
members: [],
name: "Function",
proto: {/*见上面*/},
type: "function"
},
};
了解remote的数据传输协议后,有经验的开发者应该心里有底了,它的原理大概是这样的:
主进程和渲染进程之间需要将对象序列化成MetaData描述,转换的规则上面已经解释的比较清楚了。这里面需要特殊处理是对象和函数,渲染进程拿到MetaData后需要封装成一个影子对象/函数,来供渲染进程应用调用。
其中比较复杂的是对象和函数的处理,Electron为了防止对象被垃圾回收,需要将这些对象放进一个注册表中,在这个表中每个对象都有一个唯一的id来标识。这个id有点类似于‘指针’,渲染进程会拿着这个id向主进程请求访问对象。
那什么时候需要释放这些对象呢?下文会讲具体的实现细节。
还有一个上图没有展示出来的细节是,Electron不会递归去转换对象,也就是说它只会转换一层。这样可以安全地引用存在循环引用的对象、另外所有属性值应该从远程获取最新的值,不能假设它的结构不可变。
对象的序列化
先来看看主进程的实现,它的代码位于/lib/browser/rpc-server.js,代码很少而且很好理解,读者可以自己读一下。
这里我们不关注对象序列化的细节,重点关注对象的生命周期和调用的流程.
以remote.require
为例, 这个方法用于让主进程去require指定模块,然后返回模块内容给渲染进程:
handleRemoteCommand('ELECTRON_BROWSER_REQUIRE', function (event, contextId, moduleName) { |
handleRemoteCommand
使用ipcMain监听renderer发送的请求,contextId
用于标识一个渲染进程。
valueToMeta
方法将值序列化为MetaData:
const valueToMeta = function (sender, contextId, value, optimizeSimpleObject = false) { |
影子对象
渲染进程会从MetaData中反序列化的对象或函数, 不过这只是一个‘影子’,我们也可以将它们称为影子对象或者代理对象、替身. 类似于火影忍者中的影分身之术,主体存储在主进程中,影子对象不包含任何实体数据,当访问这些对象或调用函数/方法时,影子对象直接远程请求。
渲染进程的代码可以看这里
来看看渲染进程怎么创建‘影子对象’:
函数的处理:
if (meta.type === 'function') { |
对象成员的处理:
function setObjectMembers (ref, object, metaId, members) { |
对象的生命周期
主进程的valueToMeta
会将每一个对象和函数都放入注册表中,包括每次函数调用的返回值。
这是否意味着,如果频繁调用函数,会导致注册表暴涨占用太多内存呢?这些对象什么时候释放?
首先当渲染进程销毁时,主进程会集中销毁掉该进程的所有对象引用:
// 渲染进程退出时会通过这个事件告诉主进程,但是这个并不能保证收到 |
因为ELECTRON_BROWSER_CONTEXT_RELEASE
不能保证能够收到,所以objectsRegistry
还会监听对应渲染进程的销毁事件:
class ObjectsRegistry { |
等到渲染进程销毁再去释放这些对象显然是无法接受的,和网页不一样,桌面端应用可能会7*24不间断运行,如果要等到渲染进程退出才去回收对象, 最终会导致系统资源被消耗殆尽。
所以Electron会在渲染进程中监听对象的垃圾回收事件,再通过IPC通知主进程来递减对应对象的引用计数, 看看渲染进程是怎么做的:
/** |
简单了解一下ObjectFreer代码:
// atom/common/api/remote_object_freer.cc |
再回到主进程, 主进程监听ELECTRON_BROWSER_DEREFERENCE
事件,并递减指定对象的引用计数:
handleRemoteCommand('ELECTRON_BROWSER_DEREFERENCE', function (event, contextId, id, rendererSideRefCount) { |
如果被上面的代码绕得优点晕,那就看看下面的流程图, 消化消化:
渲染进程怎么给主进程传递回调
在渲染进程中,通过remote还可以给主进程的函数传递回调。其实跟主进程暴露函数/对象给渲染进程的原理一样,渲染进程在将回调传递给主进程之前会放置到回调注册表中,然后给主进程暴露一个callbackID。
渲染进程会调用wrapArgs
将函数调用参数序列化为MetaData:
function wrapArgs (args, visited = new Set()) { |
回到主进程,这里也有一个对应的unwrapArgs
函数来反序列化函数参数:
const unwrapArgs = function (sender, frameId, contextId, args) { |
渲染进程响应就比较简单了:
handleMessage('ELECTRON_RENDERER_CALLBACK', (id, args) => { |
那回调什么时候释放呢?这个相比渲染进程的对象引用要简单很多,因为主进程只有一个。通过上面的代码可以知道, setRemoteCallbackFreer
会监听影子回调是否被垃圾回收,一旦被垃圾回收了则通知渲染进程:
// 渲染进程 |
按照惯例,来个流程图:
一些缺陷
remote机制只是对远程对象的一个‘影分身’,无法百分百和远程对象的行为保持一致,下面是一些比较常见的缺陷:
- 当渲染进程调用远程对象的方法/函数时,是进行同步IPC通信的。换言之,同步IPC调用会阻塞用户代码的执行,而且跨端的通信效率无法和原生函数调用相比,所以频繁的IPC调用会影响主进程和渲染进程的性能.
- 主进程会保持引用每一个渲染进程访问的对象,包括函数的返回值。同理,频繁的远程对象请求,对内存的占用和垃圾回收造成不小的压力
- 无法完全模拟JavaScript对象的行为。比如在remote模块中存在这些问题:
- 数组属于’基本对象’,它是通过值拷贝传递给对端的。也就是说它不是一个‘引用对象’,在对端修改它们时,无法反应到原始的数组.
- 对象在第一次引用时,只有可枚举的属性可以远程访问。这也意味着,一开始对象的外形就确定下来了,如果远程对象动态扩展了属性,是无法被远程访问到的
- 渲染进程传递的回调会被异步调用,而且主进程会忽略它的返回值。异步调用是为了避免产生死锁
- 对象泄露。
- 如果远程对象在渲染进程中泄露(例如存储在映射中,但从未释放),则主进程中的相应对象也将被泄漏,所以您应该非常小心,不要泄漏远程对象。
- 在给主进程传递回调时也要特别小心,主进程会保持回调的引用,直到它被释放。所以在使用remote模块进行一些‘事件订阅’时,切记要解除事件订阅.
- 还有一种场景,下文会提到
remote模块实践和优化
上面是我参与过的某个项目的软件架构图,Hybrid
层使用C/C++编写,封装了跨平台的核心业务逻辑,在此之上来构建各个平台的视图。其中桌面端我们使用的是Electron技术。
如上图,Bridge进是对Hybrid的一层Node桥接封装。一个应用中只能有一个Bridge实例,因此我们的做法是使用Electron的remote模块,让渲染进程通过主进程间接地访问Bridge.
页面需要监听Bridge的一些事件,最初我们的代码是这样的:
// bridge.ts |
监听Bridge事件:
import bridge from '~/bridge' |
流程图如下:
这种方式存在很多问题:
主进程需要为每一个addListener回调都维持一个引用。上面的代码会在页面关闭时释放订阅,但是它没有考虑用户刷新页面或者页面崩溃的场景。这会导致回调在主进程泄露。
然而就算Electron可以在调用回调时发现回调在渲染进程已经被释放掉了,但是开发者却获取不到这些信息, Bridge会始终保持对影子回调的引用.
另外一个比较明显的是调用效率的问题。假设页面监听了N次A事件,当A事件触发时,主进程需要给这个页面发送N个通知。
后来我们抛弃了使用remote进行事件订阅这种方式,让主进程来维护这种订阅关系, 如下图:
我们改进了很多东西:
主进程现在只维护‘哪个页面’订阅了哪个事件,从‘绑定回调’进化成为‘绑定页面’。这样可以解决上面调用效率和回调泄露问题、比如不会因为页面刷新导致回调泄露, 并且当事件触发时只会通知一次页面。
另外这里参考了remote本身的实现,在页面销毁时移除该页面的所有订阅。相比比remote黑盒,我们自己来实现这种事件订阅关系比之前要更好调试。
总结
remote模块对于Electron开发有很重要的意义,毕竟很多模块只有在主进程才能访问,比如BrowserWindow、dialog.
相比ipc通信,remote实在方面很多。通过上文我们也了解了它的基本原理和缺陷,所以remote虽好,切忌不要滥用。
remote的源码也很容易理解,值得学习. 毕竟前端目前跨端通信非常常见,例如WebViewBridge、Worker.
remote可以给你一些灵感,但是要完全照搬它是不可行的,因为比如它依赖一些v8 ‘Hack’来监听对象的垃圾回收,普通开发场景是做不到的。
本文完.