揭開Electron remote模塊的神祕面紗

Electron的remote模塊是一個比較神奇的東西,爲渲染進程主進程通訊封裝了一種簡單方法,經過remote你能夠'直接'獲取主進程對象或者調用主進程函數或對象的方法, 而沒必要顯式發送進程間消息, 相似於 Java 的 RMI. 例如:前端

const { remote } = require('electron')
const myModal = remote.require('myModal') // 讓主進程require指定模塊,並返回到渲染進程
myModal.dosomething()                     // 調用方法
複製代碼

本質上,remote模塊是基於Electron的IPC機制的,進程之間的通訊的數據必須是可序列化的,好比JSON序列化。因此本文的目的是介紹Electron是如何設計remote模塊的,以及裏面有什麼坑。git



文章大綱github


通訊協議的定義

上文說到,remote本質上基於序列化的IPC通訊的,因此首先關鍵須要定義一個協議來描述一個模塊/對象的外形,其中包含下列類型:web

  • 原始值。例如字符串、數字、布爾值
  • 數組。
  • 對象。對象屬性、對象的方法、以及對象的原型
  • 函數。普通函數和構造方法、異常處理
  • 特殊對象。Date、Buffer、Promise、異常對象等等

Electron使用MetaData(元數據)來描述這些對象外形的協議. 下面是一些轉換的示例:api

  • 基本對象: 基本對象很容易處理,直接值拷貝傳遞便可數組

    • 輸入promise

      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'來監聽對象的垃圾回收,普通開發場景是作不到的。

本文完.



擴展

相關文章
相關標籤/搜索