揭开Electron remote模块的神秘面纱

6,678 阅读14分钟

Electron的remote模块是一个比较神奇的东西,为渲染进程主进程通信封装了一种简单方法,通过remote你可以'直接'获取主进程对象或者调用主进程函数或对象的方法, 而不必显式发送进程间消息, 类似于 Java 的 RMI. 例如:

const { remote } = require('electron')
const myModal = remote.require('myModal') // 让主进程require指定模块,并返回到渲染进程
myModal.dosomething()                     // 调用方法

本质上,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: {dataUint8Array(11), length11type"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) {
  // 调用require
  const returnValue = process.mainModule.require(moduleName)

  // 将returnValue序列化为MetaData
  return valueToMeta(event.sender, contextId, returnValue)
})

handleRemoteCommand 使用ipcMain监听renderer发送的请求,contextId用于标识一个渲染进程。


valueToMeta方法将值序列化为MetaData:

const valueToMeta = function (sender, contextId, value, optimizeSimpleObject = false) {
  // Determine the type of value.
  const meta = { type: typeof value }
  if (meta.type === 'object') {
    // Recognize certain types of objects.
    if (value === null) {
      meta.type = 'value'
    } else if (bufferUtils.isBuffer(value)) {
      // ... 🔴 基本类型
    }
  }

  if (meta.type === 'array') {
    // 🔴 数组转换
    meta.members = value.map((el) => valueToMeta(sender, contextId, el, optimizeSimpleObject))
  } else if (meta.type === 'object' || meta.type === 'function') {
    meta.name = value.constructor ? value.constructor.name : ''
    // 🔴 将对象保存到注册表中,并返回唯一的对象id.
    // Electron会假设渲染进程会一直引用这个对象, 直到渲染进程退出
    meta.id = objectsRegistry.add(sender, contextId, value)
    meta.members = getObjectMembers(value)
    meta.proto = getObjectPrototype(value)
  } else if (meta.type === 'buffer') {
    meta.value = bufferUtils.bufferToMeta(value)
  } else if (meta.type === 'promise') {
    // 🔴promise
    value.then(function () {}, function () {})
    meta.then = valueToMeta(sender, contextId, function (onFulfilled, onRejected) {
      value.then(onFulfilled, onRejected)
    })
  } else if (meta.type === 'error') {
    // 🔴错误对象
    meta.members = plainObjectToMeta(value)
    meta.members.push({
      name: 'name',
      value: value.name
    })
  } else if (meta.type === 'date') {
    // 🔴日期
    meta.value = value.getTime()
  } else {
    // 其他
    meta.type = 'value'
    meta.value = value
  }
  return meta
}


影子对象

渲染进程会从MetaData中反序列化的对象或函数, 不过这只是一个‘影子’,我们也可以将它们称为影子对象或者代理对象替身. 类似于火影忍者中的影分身之术,主体存储在主进程中,影子对象不包含任何实体数据,当访问这些对象或调用函数/方法时,影子对象直接远程请求。

渲染进程的代码可以看这里

来看看渲染进程怎么创建‘影子对象’:

函数的处理:

  if (meta.type === 'function') {
    // 🔴创建一个'影子'函数
    const remoteFunction = function (...args) {
      let command
      // 通过new Obj形式调用
      if (this && this.constructor === remoteFunction) {
        command = 'ELECTRON_BROWSER_CONSTRUCTOR'
      } else {
        command = 'ELECTRON_BROWSER_FUNCTION_CALL'
      }
      // 🔴同步IPC远程
      // wrapArgs将函数参数序列化为MetaData
      const obj = ipcRendererInternal.sendSync(command, contextId, meta.id, wrapArgs(args))
      // 🔴反序列化返回值
      return metaToValue(obj)
    }
    ret = remoteFunction


对象成员的处理:

function setObjectMembers (ref, object, metaId, members) {
  for (const member of members) {
    if (object.hasOwnProperty(member.name)) continue

    const descriptor = { enumerable: member.enumerable }
    if (member.type === 'method') {
      // 🔴创建‘影子’方法. 和上面的函数调用差不多
      const remoteMemberFunction = function (...args) {
        let command
        if (this && this.constructor === remoteMemberFunction) {
          command = 'ELECTRON_BROWSER_MEMBER_CONSTRUCTOR'
        } else {
          command = 'ELECTRON_BROWSER_MEMBER_CALL'
        }
        const ret = ipcRendererInternal.sendSync(command, contextId, metaId, member.name, wrapArgs(args))
        return metaToValue(ret)
      }
      // ...

    } else if (member.type === 'get') {
      // 🔴属性的获取
      descriptor.get = () => {
        const command = 'ELECTRON_BROWSER_MEMBER_GET'
        const meta = ipcRendererInternal.sendSync(command, contextId, metaId, member.name)
        return metaToValue(meta)
      }

      // 🔴属性的设置
      if (member.writable) {
        descriptor.set = (value) => {
          const args = wrapArgs([value])
          const command = 'ELECTRON_BROWSER_MEMBER_SET'
          const meta = ipcRendererInternal.sendSync(command, contextId, metaId, member.name, args)
          if (meta != null) metaToValue(meta)
          return value
        }
      }
    }

    Object.defineProperty(object, member.name, descriptor)
  }
}


对象的生命周期

主进程的valueToMeta会将每一个对象和函数都放入注册表中,包括每次函数调用的返回值

这是否意味着,如果频繁调用函数,会导致注册表暴涨占用太多内存呢?这些对象什么时候释放?


首先当渲染进程销毁时,主进程会集中销毁掉该进程的所有对象引用

// 渲染进程退出时会通过这个事件告诉主进程,但是这个并不能保证收到
handleRemoteCommand('ELECTRON_BROWSER_CONTEXT_RELEASE', (event, contextId) => {
  // 清空对象注册表
  objectsRegistry.clear(event.sender, contextId)
  return null
})

因为ELECTRON_BROWSER_CONTEXT_RELEASE不能保证能够收到,所以objectsRegistry还会监听对应渲染进程的销毁事件:

class ObjectsRegistry {
    registerDeleteListener (webContents, contextId) {
    // contextId => ${processHostId}-${contextCount}
    const processHostId = contextId.split('-')[0]
    const listener = (event, deletedProcessHostId) => {
      if (deletedProcessHostId &&
          deletedProcessHostId.toString() === processHostId) {
        webContents.removeListener('render-view-deleted', listener)
        this.clear(webContents, contextId)
      }
    }
    //🔴 监听渲染进程销毁事件, 确保万无一失
    webContents.on('render-view-deleted', listener)
  }
}

到渲染进程销毁再去释放这些对象显然是无法接受的,和网页不一样,桌面端应用可能会7*24不间断运行,如果要等到渲染进程退出才去回收对象, 最终会导致系统资源被消耗殆尽。

所以Electron会在渲染进程中监听对象的垃圾回收事件,再通过IPC通知主进程来递减对应对象的引用计数, 看看渲染进程是怎么做的:

/**
 * 渲染进程,反序列化
 */
function metaToValue (meta) {
  // ...
  } else {
    // 对象类型转换
    let ret
    if (remoteObjectCache.has(meta.id)) {
      // 🔴 对象再一次被访问,递增对象引用计数. 
      // v8Util是electron原生模块
      v8Util.addRemoteObjectRef(contextId, meta.id)
      return remoteObjectCache.get(meta.id)
    }

    // 创建一个影子类表示远程函数对象
    if (meta.type === 'function') {
      const remoteFunction = function (...args) {
        // ...
      }
      ret = remoteFunction
    } else {
      ret = {}
    }

    setObjectMembers(ret, ret, meta.id, meta.members)
    setObjectPrototype(ret, ret, meta.id, meta.proto)
    Object.defineProperty(ret.constructor, 'name', { value: meta.name })

    // 🔴 监听对象的生命周期,当对象被垃圾回收时,通知到主进程
    v8Util.setRemoteObjectFreer(ret, contextId, meta.id)
    v8Util.setHiddenValue(ret, 'atomId', meta.id)
    // 🔴 添加对象引用计数
    v8Util.addRemoteObjectRef(contextId, meta.id)
    remoteObjectCache.set(meta.id, ret)
    return ret
  }
}


简单了解一下ObjectFreer代码:

// atom/common/api/remote_object_freer.cc
// 添加引用计数
void RemoteObjectFreer::AddRef(const std::string& context_id, int object_id) {
  ref_mapper_[context_id][object_id]++;
}

// 对象释放事件处理器
void RemoteObjectFreer::RunDestructor() {
  // ...
  auto* channel = "ELECTRON_BROWSER_DEREFERENCE";
  base::ListValue args;
  args.AppendString(context_id_);
  args.AppendInteger(object_id_);
  args.AppendInteger(ref_mapper_[context_id_][object_id_]);

  // 🔴 清空引用表
  ref_mapper_[context_id_].erase(object_id_);
  if (ref_mapper_[context_id_].empty())
    ref_mapper_.erase(context_id_);

  // 🔴 ipc通知主进程
  electron_ptr->Message(true, channel, args.Clone());
}

再回到主进程, 主进程监听ELECTRON_BROWSER_DEREFERENCE事件,并递减指定对象的引用计数:

handleRemoteCommand('ELECTRON_BROWSER_DEREFERENCE', function (event, contextId, id, rendererSideRefCount) {
  objectsRegistry.remove(event.sender, contextId, id, rendererSideRefCount)
})

如果被上面的代码绕得优点晕,那就看看下面的流程图, 消化消化:



渲染进程怎么给主进程传递回调

在渲染进程中,通过remote还可以给主进程的函数传递回调。其实跟主进程暴露函数/对象给渲染进程的原理一样,渲染进程在将回调传递给主进程之前会放置到回调注册表中,然后给主进程暴露一个callbackID。

渲染进程会调用wrapArgs将函数调用参数序列化为MetaData:

function wrapArgs (args, visited = new Set()) {
  const valueToMeta = (value) => {
    // 🔴 防止循环引用
    if (visited.has(value)) {
      return {
        type: 'value',
        value: null
      }
    }

    // ... 省略其他类型的处理,这些类型基本都是值拷贝
    } else if (typeof value === 'function') {
      return {
        type: 'function',
        // 🔴 给主进程传递callbackId,并添加到回调注册表中
        id: callbacksRegistry.add(value),
        location: v8Util.getHiddenValue(value, 'location'),
        length: value.length
      }
    } else {
      // ...
    }
  }
}

回到主进程,这里也有一个对应的unwrapArgs函数来反序列化函数参数:

const unwrapArgs = function (sender, frameId, contextId, args) {
  const metaToValue = function (meta) {
    switch (meta.type) {
      case 'value':
        return meta.value
      // ... 省略
      case 'function': {
        const objectId = [contextId, meta.id]
        // 回调缓存
        if (rendererFunctions.has(objectId)) {
          return rendererFunctions.get(objectId)
        }

        // 🔴 封装影子函数
        const callIntoRenderer = function (...args) {
          let succeed = false
          if (!sender.isDestroyed()) {
            // 🔴 调用时,通过IPC通知渲染进程
            // 忽略回调返回值
            succeed = sender._sendToFrameInternal(frameId, 'ELECTRON_RENDERER_CALLBACK', contextId, meta.id, valueToMeta(sender, contextId, args))
          }

          if (!succeed) {
            // 没有发送成功则表明渲染进程的回调可能被释放了,输出警告信息
            // 这种情况比较常见,比如被渲染进程刷新了
            removeRemoteListenersAndLogWarning(this, callIntoRenderer)
          }
        }

        v8Util.setHiddenValue(callIntoRenderer, 'location', meta.location)
        Object.defineProperty(callIntoRenderer, 'length', { value: meta.length })

        // 🔴 监听回调函数垃圾回收事件
        v8Util.setRemoteCallbackFreer(callIntoRenderer, contextId, meta.id, sender)
        rendererFunctions.set(objectId, callIntoRenderer)
        return callIntoRenderer
      }
      default:
        throw new TypeError(`Unknown type: ${meta.type}`)
    }
  }

  return args.map(metaToValue)
}

渲染进程响应就比较简单了:

handleMessage('ELECTRON_RENDERER_CALLBACK', (id, args) => {
  callbacksRegistry.apply(id, metaToValue(args))
})

那回调什么时候释放呢?这个相比渲染进程的对象引用要简单很多,因为主进程只有一个。通过上面的代码可以知道, setRemoteCallbackFreer会监听影子回调是否被垃圾回收,一旦被垃圾回收了则通知渲染进程:

// 渲染进程
handleMessage('ELECTRON_RENDERER_RELEASE_CALLBACK', (id) => {
  callbacksRegistry.remove(id)
})

按照惯例,来个流程图:



一些缺陷

remote机制只是对远程对象的一个‘影分身’,无法百分百和远程对象的行为保持一致,下面是一些比较常见的缺陷:

  • 当渲染进程调用远程对象的方法/函数时,是进行同步IPC通信的。换言之,同步IPC调用会阻塞用户代码的执行,而且跨端的通信效率无法和原生函数调用相比,所以频繁的IPC调用会影响主进程和渲染进程的性能.
  • 主进程会保持引用每一个渲染进程访问的对象,包括函数的返回值。同理,频繁的远程对象请求,对内存的占用和垃圾回收造成不小的压力
  • 无法完全模拟JavaScript对象的行为。比如在remote模块中存在这些问题:
    • 数组属于'基本对象',它是通过值拷贝传递给对端的。也就是说它不是一个‘引用对象’,在对端修改它们时,无法反应到原始的数组.
    • 对象在第一次引用时,只有可枚举的属性可以远程访问。这也意味着,一开始对象的外形就确定下来了,如果远程对象动态扩展了属性,是无法被远程访问到的
    • 渲染进程传递的回调会被异步调用,而且主进程会忽略它的返回值。异步调用是为了避免产生死锁
  • 对象泄露。
    • 如果远程对象在渲染进程中泄露(例如存储在映射中,但从未释放),则主进程中的相应对象也将被泄漏,所以您应该非常小心,不要泄漏远程对象。
    • 在给主进程传递回调时也要特别小心,主进程会保持回调的引用,直到它被释放。所以在使用remote模块进行一些‘事件订阅’时,切记要解除事件订阅.
    • 还有一种场景,下文会提到


remote模块实践和优化

上面是我参与过的某个项目的软件架构图,Hybrid层使用C/C++编写,封装了跨平台的核心业务逻辑,在此之上来构建各个平台的视图。其中桌面端我们使用的是Electron技术。

如上图,Bridge进是对Hybrid的一层Node桥接封装。一个应用中只能有一个Bridge实例,因此我们的做法是使用Electron的remote模块,让渲染进程通过主进程间接地访问Bridge.


页面需要监听Bridge的一些事件,最初我们的代码是这样的:

// bridge.ts
// 使用remote的一个好处时,可以配合Typescript实现较好的类型检查
const bridge = electron.remote.require('bridge') as typeof import('bridge')

export default bridge

监听Bridge事件:

import bridge from '~/bridge'

class Store extends MobxStore {
  // 初始化
  pageReady() {
    this.someEventDispose = bridge.addListener('someEvent', this.handleSomeEvent)
  }

  // 页面关闭
  pageWillClose() {
    this.someEventDispose()
  }
  // ...
}

流程图如下:

这种方式存在很多问题:

  • 主进程需要为每一个addListener回调都维持一个引用。上面的代码会在页面关闭时释放订阅,但是它没有考虑用户刷新页面或者页面崩溃的场景。这会导致回调在主进程泄露。

    然而就算Electron可以在调用回调时发现回调在渲染进程已经被释放掉了,但是开发者却获取不到这些信息, Bridge会始终保持对影子回调的引用.

  • 另外一个比较明显的是调用效率的问题。假设页面监听了N次A事件,当A事件触发时,主进程需要给这个页面发送N个通知。


后来我们抛弃了使用remote进行事件订阅这种方式,让主进程来维护这种订阅关系, 如下图:

我们改进了很多东西:

主进程现在只维护‘哪个页面’订阅了哪个事件,从‘绑定回调’进化成为‘绑定页面’。这样可以解决上面调用效率和回调泄露问题、比如不会因为页面刷新导致回调泄露, 并且当事件触发时只会通知一次页面。

另外这里参考了remote本身的实现,在页面销毁时移除该页面的所有订阅。相比比remote黑盒,我们自己来实现这种事件订阅关系比之前要更好调试。



总结

remote模块对于Electron开发有很重要的意义,毕竟很多模块只有在主进程才能访问,比如BrowserWindow、dialog.

相比ipc通信,remote实在方面很多。通过上文我们也了解了它的基本原理和缺陷,所以remote虽好,切忌不要滥用。

remote的源码也很容易理解,值得学习. 毕竟前端目前跨端通信非常常见,例如WebViewBridge、Worker.

remote可以给你一些灵感,但是要完全照搬它是不可行的,因为比如它依赖一些v8 'Hack'来监听对象的垃圾回收,普通开发场景是做不到的。

本文完.



扩展