React Native原理之跨端通訊機制

語言: CN / TW / HK

圖片來源:https://unsplash.com/photos/gy08FXeM2L4

跨端通訊

在移動端開發場景中,能使用一份程式碼就能同時在安卓和 iOS 系統上執行 APP 的方案,熟稱為跨端方案。而 Webview ,React Native 都是雲音樂大前端團隊用的比較多的跨端方案,這些方案雖然能提高開發效率,但它們不能像原生語言一樣直接呼叫系統的能力,於是在做 HTML5(以下簡稱 H5) 或者 React Native(以下簡稱 RN) 需求的時候,開發者們經常碰到要呼叫 Native 能力的情況。Native 能力用原生語言編寫,有自己的執行環境,RN 頁面使用 JS 編寫,也有獨立的執行環境,這種跨越執行環境的呼叫被稱為 跨端通訊

webView jsb

H5 中的 跨端通訊 稱為 JSBridge ,在進行一次 JSBridge 呼叫的時候會攜帶呼叫引數,預設有 4 個引數:

ModuleId: 模組 ID
MethodId: 方法 ID
params: 引數
CallbackId: JS 回撥名

其中 ModuleIdMethodId 能定位到具體呼叫的原生方法, 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 層負責跨端頁面具體的業務邏輯。

rn 通訊模組

相比起 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。而 JSObjectSetPropertyJSContextGetGlobalObject 也是比較重要的兩個 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 程式碼提供計時器的能力。
  • /Libraries/ReactNative/
    AppRegistery
    callFunctionReturnFlushedQueue
    

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
    

同樣的,C++ 通過 JSCore 的 JSObjectSetProperty 方法在 global 物件中塞入了幾個 Native API,讓 JS 能通過它們來呼叫 C++ 模組。主要 API 有:

nativeFlushQueueImmediate // 立即清空 JS 任務佇列
nativeCallSyncHook // 同步呼叫 Native 方法
nativeRequire  // 載入 Native 模組
  • MessageQueue
    __fbBatchedBridge
    flushedQueue
    flushedQueue
    nativeFlushQueueImmediate
    

平臺(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;

  • __fbBatchedBridge
    jniCallJSFunction
    callFunctionReturnFlushedQueue
    jniCallJSCallback
    invokeCallbackAndReturnFlushedQueue
    

呼叫示例

以 RN 中的 setTimeout 方法為例,走一遍呼叫流程。

  • 初始化過程

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 的呼叫過程

RN 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 原始碼 [1]

  • React Native 原生模組和 JS 模組互動 [2]

  • Handler 與 Looper,MessageQueue 的關係 [3]

  • React Native 通訊機制詳解 [4]

  • React Native 原始碼解析 [5]

  • How React Native constructs app layouts [6]

參考資料

[1]

React Native 原始碼: https://github.com/facebook/react-native

[2]

React Native 原生模組和 JS 模組互動: https://danke77.github.io/2016/12/07/react-native-native-modules-android/

[3]

Handler 與 Looper,MessageQueue 的關係: https://www.cnblogs.com/fuly550871915/p/4889838.html

[4]

React Native 通訊機制詳解: http://blog.cnbang.net/tech/2698/

[5]

React Native 原始碼解析: https://github.com/sucese/react-native/tree/master/doc/ReactNative%E6%BA%90%E7%A0%81%E7%AF%87

[6]

How React Native constructs app layouts: https://www.freecodecamp.org/news/how-react-native-constructs-app-layouts-and-how-fabric-is-about-to-change-it-dd4cb510d055/