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
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: {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) {
// 調用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機制只是對遠程對象的一個‘影分身’,沒法百分百和遠程對象的行爲保持一致,下面是一些比較常見的缺陷:
上面是我參與過的某個項目的軟件架構圖,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'來監聽對象的垃圾回收,普通開發場景是作不到的。
本文完.