圖片來源:https://unsplash.com/photos/g...
作者:五廿
跨端通訊
在移動端開發場景中,能使用一份程式碼就能同時在安卓和 iOS 系統上執行 APP 的方案,熟稱為跨端方案。而 Webview ,React Native 都是雲音樂大前端團隊用的比較多的跨端方案,這些方案雖然能提高開發效率,但它們不能像原生語言一樣直接呼叫系統的能力,於是在做 HTML5(以下簡稱 H5) 或者 React Native(以下簡稱 RN) 需求的時候,開發者們經常碰到要呼叫 Native 能力的情況。Native 能力用原生語言編寫,有自己的執行環境,RN 頁面使用 JS 編寫,也有獨立的執行環境,這種跨越執行環境的呼叫被稱為跨端通訊
。
H5 中的跨端通訊
稱為 JSBridge
,在進行一次 JSBridge 呼叫的時候會攜帶呼叫引數,預設有 4 個引數:
ModuleId: 模組 ID
MethodId: 方法 ID
params: 引數
CallbackId: JS 回撥名
其中 ModuleId
和 MethodId
能定位到具體呼叫的原生方法,params
引數作為原生方法呼叫的引數,最後通過 CallbackId
回撥 JS 的回撥函式,H5 就能從回撥函式中拿到呼叫結果。該流程中主要使用了 Webview 容器中攔截請求
和客戶端呼叫 JS 函式
的能力,比如安卓中通常使用的是 WebChromeClient.onJsPrompt
方法來攔截 H5 的請求,evaluateJavascript
方法用來執行回撥。但是 React Native 中沒有引入 Webview 來實現這些呼叫的能力,它採用了完全不同的方式來處理。另外,在雲音樂團隊的 APP 中, 會同時存在 H5 和 RN 頁面,也就是同一個 APP 中兩種跨端通訊方式並存,但它們最後呼叫的原生方法卻是來自同一個原生模組。本文主要從 Android 系統的 RN 實現來介紹 RN 的通訊機制和橋接能力(以下簡稱 Bridge),並結合以上通訊場景中會碰到的問題來講解如何實現一個業務中可用的 Bridge。大體由三部分組成,首先介紹 RN 中不同的組成模組和它們各自的角色;第二部分是各個模組之間的呼叫方式和具體的示例;最後一部分探討業務中的 Bridge 的實現。
RN 組成
在 RN 中,主要有三個重要的組成模組:平臺層
( Android 或者 OC 環境),橋接層
( C++ )和JS 層
。
- 平臺層負責原生元件的渲染和提供各式各樣的原生能力,由原生語言實現;
- 橋接模組負責解析 JS 程式碼,JS 和 Java/OC 程式碼互調,由 C++ 語言實現;
- JS 層負責跨端頁面具體的業務邏輯。
相比起 Webview 的結構來說,RN 的結構多了一層橋接層
,也就是 C++ 層。文章先來介紹一下這個模組的作用,以及為什麼會多出這麼一個模組。
橋接層(C++ 層)
React Native 和 H5 一樣,使用了 JS 作為跨端頁面的開發語言,因此它必須要有一個 JS 執行引擎,而在使用 H5 的情況下,Webview 是 JS 的執行引擎,同時 Webview 還是頁面的渲染引擎。RN 不一樣的地方在於,已經有了自己的渲染層,這個功能交給了 Java 層,因為 RN 的 JS 元件程式碼最後都會渲染成原生元件。因此 RN 只需要一個 JS 執行引擎來跑 React 程式碼。 RN 團隊選擇了 JSCore
作為 JS 的執行引擎,而 JSCore
的對外介面是用 C 和 C++ 編寫的。因此平臺層的 Java 程式碼 / OC 程式碼想要通過 JSCore
拿到 JS 的模組和回撥函式,只能通過 C++ 提供的介面獲取,再加上 C++ 在 iOS 和安卓系統上也有良好的跨端執行的功能,選它作為橋接層是不錯的選擇。
JSCore
JSCore 是橋接層中的主要模組,它是 RN 架構中的 JS 引擎,負責 JS 程式碼的載入和解析。先來看下它的主要 API :
JSContextGetGlobalObject:獲取JavaScript執行環境的Global物件。
JSObjectSetProperty/JSObjectGetProperty:JavaScript物件的屬性操作:set和get。
JSEvaluateScript:在JavaScript環境中執行一段JS指令碼。
JSObjectCallAsFunction:在JavaScript環境中呼叫一個JavaScript函式
通過 API 可以看出來,開發者可以用 JSEvaluateScript
在 JSCore 環境中執行一段 JS 程式碼,也可以通過 JSContextGetGlobalObject
拿到 JS 上下文的 Global 變數,然後把它轉化成 C++ 可以使用的資料結構並且操作它,注入 API。而 JSObjectSetProperty
和 JSContextGetGlobalObject
也是比較重要的兩個 API ,稍後會在通訊流程中發揮作用。
Native 模組和 JavaScript 模組
說起通訊的話,整個過程肯定存在信源和信宿,也就是訊息的傳送者和接收者,在 RN 的通訊中,它們是 Native 和 JS 的模組,它們向對方提供能力都是以模組為功能單位的,類似 JSBridge 協議中的 ModuleID 的概念。
- Native 模組在 Android 系統下是 Java 模組,由平臺程式碼實現,JS 通過模組 ID(moduleID) 和方法 ID(methodID) 來進行呼叫,一般都在 RN 原始碼工程的
java/com/facebook/react/modules/
目錄下,可以給 RN 頁面開放原生系統的能力,如計時器的實現模組Timing
,給 JS 程式碼提供計時器的能力。 - JavaScript 模組是由 JS 實現,程式碼在
/Libraries/ReactNative/
目錄下,如 App 啟動模組AppRegistery
,對 Java 環境來說,作用是提供操作 JS 環境的 API,如回撥,廣播等。Java 的呼叫方法是通過 JS 暴露出來的callFunctionReturnFlushedQueue
API。
JS 環境中會維護一份所有 Native 模組的 moduleID 和 methodID 的對映 NativeModules
,用來呼叫 Native 模組的時候查詢對應 ID;Java 環境中也會維護一份 JavaScript 模組的對映 JSModuleRegistry
,用來呼叫 JS 程式碼。而在實際的程式碼中,Native 模組和 JS 模組的通訊需要通過中間層也就是 C++ 層的過渡,也就是說 Native 模組和 JS 模組實際上都只是在和 C++ 模組進行通訊。
C++ 和 JS 通訊
上面提到,JSCore 可以讓 C++ 拿到 JS 執行環境的 global 物件並能操作它的屬性,而 JS 程式碼會在 global 物件中注入一些原生模組需要的 API,這是 JS 向 C++ 提供操作 API 的主要方式。
- RN 環境中 JS 會在 global 物件中設定了
__fbBatchedBridge
變數,並在變數塞入了 4 個的 API,作為 JS 被呼叫的入口,主要 API 包括:
<!---->
callFunctionReturnFlushedQueue // 讓 C++ 呼叫 JS 模組
invokeCallbackAndReturnFlushedQueue // 讓 C++ 呼叫 JS 回撥
flushedQueue // 清空 JS 任務佇列
callFunctionReturnResultAndFlushedQueue // 讓 C++ 呼叫 JS 模組並返回結果
- JS 還在 global 中還設定了
__fbGenNativeModule
方法,用來給 C++ 呼叫後在 JS 環境生成 Java 模組的對映物件,也就是NativeModules
模組。它的資料結構類似於(跟實際的資料結構有偏差):
<!---->
{
"Timing": {
"moduleID": "1001",
"method": {
"createTimer": {
"methodID": "10001"
}
}
}
}
- 通過
NativeModules
的對映,開發者能拿到呼叫模組和方法的moduleID
和methodID
,在呼叫過程中會對映到具體的 Native 的方法。
同樣的,C++ 通過 JSCore 的 JSObjectSetProperty
方法在 global 物件中塞入了幾個 Native API,讓 JS 能通過它們來呼叫 C++ 模組。主要 API 有:
nativeFlushQueueImmediate // 立即清空 JS 任務佇列
nativeCallSyncHook // 同步呼叫 Native 方法
nativeRequire // 載入 Native 模組
- 上面介紹 API 的時候,有多個 API 的功能比較類似,就是清空 JS 的任務佇列,那是因為 JS 在呼叫 Native 模組是非同步呼叫,它會把呼叫引數包裝成一個呼叫任務放入 JS 任務佇列
MessageQueue
中,然後等待 Native 的呼叫。呼叫時機一般是在觸發事件的時候,事件會觸發 Native 回撥 JS 的回撥函式,Native 模組需要通過__fbBatchedBridge
的四個 API 回撥 JS 程式碼,而這四個 API,都有flushedQueue
功能:清空任務佇列並執行所有的任務,藉此來消費佇列中的 Native 呼叫任務。但是如果某一次呼叫距離上一次的flushedQueue
行為有點久(一般是大於 5 ms),就會觸發立即呼叫的邏輯,JS 呼叫nativeFlushQueueImmediate
API,主動觸發任務消費。
平臺(Java)和 C++ 的通訊
Java 跟 C++ 的互相呼叫通過 JNI(Java Native Interface),通過 JNI,C++ 層會暴露出來一些 API 來給 Java 層呼叫,來讓 Java 能跟 JS 層進行通訊。下面是 C++ 通過 JNI 暴露給 Java 的一些方法:
initializeBridge // 初始化:C++ 從 Java 拿到 Native 模組,作為引數傳給 JS 生成 NativeModules
jniLoadScriptFromFile // 載入 JS 檔案
jniCallJSFunction // 呼叫 JS 模組
jniCallJSCallback// 呼叫 JS 回撥
setGlobalVariable // 編輯 global 變數
getJavaScriptContext // 獲取 JS 執行環境
- 由上面的 API 基本可以判斷出,C++ 負責的是一些中間層的角色,有 JS 的載入,解析的工作,還有提供操作 JS 執行環境的 API;
- 這裡操作 JS 的 API 都會走到上一節
__fbBatchedBridge
的四個 API 上,如jniCallJSFunction
會呼叫callFunctionReturnFlushedQueue
。jniCallJSCallback
會呼叫invokeCallbackAndReturnFlushedQueue
。由此,三個模組的呼叫鏈路就連線了起來。
呼叫示例
以 RN 中的 setTimeout 方法為例,走一遍呼叫流程。
- 初始化過程
- Timing Class:Native 中的延時呼叫的實現類,被 @reactModule 裝飾器描述為一個 Native 模組,在 RN 初始化的時候被放入 ModuleRegistry 對映表,用於後面的呼叫對映。
- ModuleRegistry 對映表構造完成後,呼叫 C++ 的 initializeBridge ,把 ModuleRegistry 的模組通過 \__fbGenNativeModule 函式註冊進 JS 環境。
JS 程式碼中的 JSTimer 類 引用 Timing 模組的 createTimer 來實現 setTimeout,延遲執行函式。
// 原始碼位置:/Libraries/Core/Timers/JSTimers.js const {Timing} = require('../../BatchedBridge/NativeModules'); function setTimeout(func: Function, duration: number, ...args: any): number { // 建立回撥函式 const id = _allocateCallback( () => func.apply(undefined, args), 'setTimeout', ); Timing.createTimer(id, duration || 0, Date.now(), /* recurring */ false); return id; },
- setTimeout 的呼叫過程
- 當 setTimeout 在 JSTimer.js 被呼叫,通過 NativeModules 找到 Timing Class 的 moduleID 和 methodID,放進任務佇列
MessageQueue
中; - Native 通過事件或者主動觸發清空
MessageQueue
佇列,C++ 層把 moduleID ,methodID 和其他呼叫引數交給ModuleRegistry
,由它來找到 Native 模組的程式碼,Timing 類; - Timing 呼叫
createTimer
方法,呼叫系統計時功能實現延遲呼叫; 計時結束,Timing 類需要回撥 JS 函式
// timerToCall 是回撥函式的 ID 陣列 getReactApplicationContext().getJSModule(JSTimers.class) .callTimers(timerToCall);
getJSModule
方法會通過JSModuleRegistry
找到需要呼叫的 JS 模組,並呼叫對應的方法,該流程中呼叫JSTimers
模組的callTimers
方法。- Java 程式碼通過 JNI 介面
jniCallJSFunction
通過 C++ 呼叫 JS 模組,並傳入 module:JSTimers
和 method:callTimers
; - C++ 呼叫 JS 暴露出來的
callFunctionReturnFlushedQueue
API,帶上 module 和 method,回到 JS 的呼叫環境; - JS 執行
callFunctionReturnFlushedQueue
方法找到 RN 初始化階段註冊好的 JSTimer 模組的callTimers
函式,進行呼叫。呼叫完畢後清空一下任務佇列MessageQueue
。
RN 的 JSBridge
以上通過 RN 的 setTimeout 函式走了一遍 RN 內 Java 程式碼和 JS 程式碼的通訊流程。簡單來說,Java 模組和 JS 模組可以通過 NativeModules 和 JS 回撥函式互相呼叫,來達成一次跨端呼叫。但是業務中的 Bridge 需要包含一些額外的場景,比如併發呼叫,事件監聽等。
- 併發呼叫:類似於在 web 端同時發多個請求,為了將請求結果回撥到正確的回撥函式內,需要儲存一個請求到回撥函式的對映,在 Bridge 的呼叫中也是一樣的。而這份對映可以維護在 JS 程式碼中,也可以維護在 Native 程式碼中,在跨端方案中,兩者都可行的情況下一般選擇 JS 程式碼的方案來保持靈活性,Native 只負責處理結果並回撥。
- 事件監聽:比如 JS 程式碼監聽頁面是否切換到後臺,同一個回撥函式在頁面多次切換到後臺的時候,應該要被呼叫多次,但是 RN 的 JSCallback 只允許呼叫一次(每一個 callback 例項會帶上是否呼叫過的標記), 回撥顯然不適合這種場景,雲音樂的 Bridge 使用 RN 的事件通知:
RCTDeviceEventEmitter
來代替回撥。RCTDeviceEventEmitter
是一個純 JS 實現的事件訂閱分發模組,Native 模組通過getJSModule
可以拿到它的方法,因此可以在 Native 端發出一個 JS 事件並帶上回撥的引數和對映 ID 等,而不用走 JSCallback。
回到之前的問題:如何實現 RN 的 Bridge,能讓一個 Bridge 的 API 同時支援 H5 和 RN 的呼叫。因為 H5 和 RN 大多數的業務場景都是相同的,比如獲取使用者資訊 user.info,裝置資訊 device.info 類似的介面,在 H5 和 RN 中都是會用到的。除了跨端呼叫的協議要保持一致外,具體的實現模組,協議解析模組都是可以複用的。其中不一樣的就是呼叫鏈路。RN 鏈路中的主要模組包括:
- 給 JS 程式碼呼叫的 NativeModule,作為呼叫入口,JS 程式碼呼叫它暴露出來的方法傳入呼叫引數並開始呼叫流程,但是該模組不解析協議和引數,可以稱作
RNRPCNativeModule
; - 在 Native 模組處理完後,
RNRPCNativeModule
使用RCTDeviceEventEmitter
生成一個事件回撥給 JS 程式碼,並帶上執行結果。
除了以上兩個不一樣的模組外,其他模組都是可以複用的,如協議解析和任務分發模組,解析協議的呼叫模組,方法,引數等,並把它分發給具體的 Native 模組;還有 Native 具體的功能實現模組,都可以保持一致。
結合前面介紹的呼叫流程,開發者如果呼叫 User.info
這個 JSBridge 來獲取使用者資訊,呼叫流程如下:
這樣的處理,能保證 H5 和 RN 能用同一份 moduleID 和 methodID 來呼叫 Native 的功能,而且保證在同一個模組進行處理。從開發者的角度來看,就是一個 Bridge 的 API 可以同時支援 H5 和 RN 的呼叫。
以上。
相關資料
- React Native 原始碼
- React Native 原生模組和 JS 模組互動
- Handler 與 Looper,MessageQueue 的關係
- React Native 通訊機制詳解
- React Native 原始碼解析
- How React Native constructs app layouts
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!