使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

在初識 React Native 時,非常令人困惑的一個地方就是 JS 和 Native 兩個端之間是如何相互通信的。本篇文章對 iOS 端 React Native 啟動時的調用流程做下簡要總結,以此窺探其背后的通信機制。

JS 啟動過程

React Native 的 iOS 端代碼是直接從 Xcode IDE 里啟動的。在啟動時,首先要對代碼進行編譯,不出意外,在編譯后會彈出一個命令行窗口,這個窗口就是通過 Node.js 啟動的 development server

問題是這個命令行是怎么啟動起來的呢?實際上,Xcode 在 Build Phase 的最后一個階段對此做了配置:

使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

因此,代碼編譯后,就會執行 packager/react-native-xcode.sh 這個腳本。

查看這個腳本中的內容,發現它主要是讀取 XCode 帶過來的環境變量,同時加載 nvm 包使得 Node.js 環境可用,最后執行 react-native-cli 的命令:

react-native bundle \
  --entry-file index.ios.js \
  --platform ios \
  --dev $DEV \
  --bundle-output "$DEST/main.jsbundle" \
  --assets-dest "$DEST"

react-native 命令是全局安裝的,在我本機上它的地址是 /usr/local/bin/react-native 。查看該文件,它調用了 react-native 包里的 local-cli/cli.js 中的 run 方法,最終進入了 private-cli/src/bundle/buildBundle.js 。它的調用過程為:

  1. ReactPackager.createClientFor
  2. client.buildBundle
  3. processBundle
  4. saveBundleAndMap

上面四步完成的是 buildBundle 的功能,細節很多很復雜。總體來說,buildBundle 的功能類似于 browerify 或 webpack :

  1. 從入口文件開始分析模塊之間的依賴關系;
  2. 對 JS 文件轉化,比如 JSX 語法的轉化等;
  3. 把轉化后的各個模塊一起合并為一個 bundle.js 。

之所以 React Native 單獨去實現這個打包的過程,而不是直接使用 webpack ,是因為它對模塊的分析和編譯做了不少優化,大大提升了打包的速度,這樣能夠保證在 liveReload 時用戶及時得到響應。

Tips: 通過訪問 http://localhost:8081/debug/bundles 可以看到內存中緩存的所有編譯后的文件名及文件內容,如:

使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

Native 啟動過程

Native 端就是一個 iOS 程序,程序入口是 main 函數,像通常一樣,它負責對應用程序做初始化。

除了 main 函數之外, AppDelegate 也是一個比較重要的類,它主要用于做一些全局的控制。在應用程序啟動之后,其中的 didFinishLaunchingWithOptions 方法會被調用,在這個方法中,主要做了幾件事:

  • 定義了 JS 代碼所在的位置,它在 dev 環境下是一個 URL,通過 development server 訪問;在生產環境下則從磁盤讀取,當然前提是已經手動生成過了 bundle 文件;
  • 創建了一個 RCTRootView 對象,該類繼承于 UIView ,處于程序所有 View 的最外層;
  • 調用 RCTRootView 的 initWithBundleURL 方法。在該方法中,創建了 bridge 對象。顧名思義,bridge 起著兩個端之間的橋接作用,其中真正工作的是類就是大名鼎鼎的 RCTBatchedBridge

RCTBatchedBridge 是初始化時通信的核心,我們重點關注的是 start 方法。在 start 方法中,會創建一個 GCD 線程,該線程通過串行隊列調度了以下幾個關鍵的任務。

loadSource

該任務負責加載 JS 代碼到內存中。和前面一致,如果 JS 地址是 URL 的形式,就通過網絡去讀取,如果是文件的形式,則通過讀本地磁盤文件的方式讀取。

initModules

該任務會掃描所有的 Native 模塊,提取出要暴露給 JS 的那些模塊,然后保存到一個字典對象中。

一個 Native 模塊如果想要暴露給 JS,需要在聲明時顯示地調用 RCT_EXPORT_MODULE 。它的定義如下:

#define RCT_EXPORT_MODULE(js_name) \
RCT_EXTERN void RCTRegisterModule(Class); \
+ (NSString *)moduleName { return @#js_name; } \
+ (void)load { RCTRegisterModule(self); }

可以看到,這就是一個宏,定義了 load 方法,該方法會自動被調用,在方法中對當前類進行注冊。模塊如果要暴露出指定的方法,需要通過 RCT_EXPORT_METHOD 宏進行聲明,原理類似。

setupExecutor

這里設置的是 JS 引擎,同樣分為調試環境和生產環境:

在調試環境下,對應的 Executor 為 RCTWebSocketExecutor,它通過 WebSocket 連接到 Chrome 中,在 Chrome 里運行 JS;

在生產環境下,對應的 Executor 為 RCTContextExecutor,這應該就是傳說中的 javascriptcore 。

moduleConfig

根據保存的模塊信息,組裝成一個 JSON ,對應的字段為 remoteModuleConfig。

injectJSONConfiguration

該任務將上一個任務組裝的 JSON 注入到 Executor 中。

下面是一個 JSON 示例,由于實際的對象太大,這里只截取了前面的部分:

使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

JSON 里面就是所有暴露出來的模塊信息。

executeSourceCode

該任務中會執行加載過來的 JS 代碼,執行時傳入之前注入的 JSON。在調試模式下,會通過 WebSocket 給 Chrome 發送一條 message,內容大致為:

{
    id = 10305;
    inject = {remoteJSONConfig...};
    method = executeApplicationScript;
    url = "http://localhost:8081/index.ios.bundle?platform=ios&dev=true";
}

JS 接收消息后,執行打包后的代碼。如果是非調試模式,則直接通過 javascriptcore 的虛擬環境去執行相關代碼,效果類似。

JS 調用 Native

前面我們看到, Native 調用 JS 是通過發送消息到 Chrome 觸發執行、或者直接通過 javascriptcore 執行 JS 代碼的。而對于 JS 調用 Native 的情況,又是什么樣的呢?

在 JS 端調用 Native 一般都是直接通過引用模塊名,然后就使用了,比如:

var RCTAlertManager = require('NativeModules').AlertManager

可見,NativeModules 是所有本地模塊的操作接口,找到它的定義為:

var NativeModules = require('BatchedBridge').RemoteModules;

而BatchedBridge中是一個MessageQueue的對象:

let BatchedBridge = new MessageQueue(
  __fbBatchedBridgeConfig.remoteModuleConfig,
  __fbBatchedBridgeConfig.localModulesConfig,
);

在 MessageQueue 實例中,都有一個 RemoteModules 字段。在 MessageQueue 的構造函數中可以看出,RemoteModules 就是 __fbBatchedBridgeConfig.remoteModuleConfig 稍微加工后的結果。

class MessageQueue {

  constructor(remoteModules, localModules, customRequire) {
    this.RemoteModules = {};
    this._genModules(remoteModules);
    ...
    }
}

所以問題就變為: __fbBatchedBridgeConfig.remoteModuleConfig 是在哪里賦值的?

實際上,這個值就是 從 Native 端傳過來的JSON 。如前所述,Executor 會把模塊配置組裝的 JSON 保存到內部:

[_javaScriptExecutor injectJSONText:configJSON
                  asGlobalObjectNamed:@"__fbBatchedBridgeConfig"
                             callback:onComplete];

configJSON 實際保存的字段為: _injectedObjects['__fbBatchedBridgeConfig'] 。

在 Native 第一次調用 JS 時,_injectedObjects 會作為傳遞消息的 inject 字段。

JS 端收到這個消息,經過下面這個重要的處理過程:

'executeApplicationScript': function(message, sendReply) {
    for (var key in message.inject) {
      self[key] = JSON.parse(message.inject[key]);
    }
    importScripts(message.url);
    sendReply();
  },

看到沒,這里讀取了 inject 字段并進行了賦值。self 是一個全局的命名空間,在瀏覽器里 self===window 。

因此,上面代碼執行過后,window.__fbBatchedBridgeConfig 就被賦值為了傳過來的 JSON 反序列化后的值。

總之:

NativeModules = __fbBatchedBridgeConfig.remoteModuleConfig = JSON.parse(message.inject[‘__fbBatchedBridgeConfig’]) = 模塊暴露出的所有信息

好,有了上述的前提之后,接下來以一個實際調用例子說明下 JS 調用 Native 的過程。

首先我們通過 JS 調用一個 Native 的方法:

RCTUIManager.measureLayoutRelativeToParent(
   React.findNodeHandle(scrollComponent),
   logError,
   this._setScrollVisibleLength
);

所有 Native 方法調用時都會先進入到下面的方法中:

fn = function(...args) {
  let lastArg = args.length > 0 ? args[args.length - 1] : null;
  let secondLastArg = args.length > 1 ? args[args.length - 2] : null;
  let hasSuccCB = typeof lastArg === 'function';
  let hasErrorCB = typeof secondLastArg === 'function';
  let numCBs = hasSuccCB + hasErrorCB;
  let onSucc = hasSuccCB ? lastArg : null;
  let onFail = hasErrorCB ? secondLastArg : null;
  args = args.slice(0, args.length - numCBs);
  return self.__nativeCall(module, method, args, onFail, onSucc);
};

也就是倒數后兩個參數是錯誤和正確的回調,剩下的是方法調用本身的參數。

在 __nativeCall 方法中,會將兩個回調壓到 callback 數組中,同時把 (模塊、方法、參數) 也單獨保存到內部的隊列數組中:

onFail && params.push(this._callbackID);
this._callbacks[this._callbackID++] = onFail;
onSucc && params.push(this._callbackID);
this._callbacks[this._callbackID++] = onSucc;
this._queue[0].push(module);
this._queue[1].push(method);
this._queue[2].push(params);

到這一步,JS 端告一段落。接下來是 Native 端,在調用 JS 時,經過如下的流程:

使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

總之,就是在調用 JS 時,順便把之前保存的 queue 作為返回值 一并返回,然后會對該返回值進行解析。

在 _handleRequestNumber 方法中,終于完成了 Native 方法的調用:

- (BOOL)_handleRequestNumber:(NSUInteger)i
                    moduleID:(NSUInteger)moduleID
                    methodID:(NSUInteger)methodID
                      params:(NSArray *)params
{
  // 解析模塊和方法
  RCTModuleData *moduleData = _moduleDataByID[moduleID];
  id<RCTBridgeMethod> method = moduleData.methods[methodID];
  @try {
    // 完成調用
    [method invokeWithBridge:self module:moduleData.instance arguments:params];
  }
  @catch (NSException *exception) {
  }

  NSMutableDictionary *args = [method.profileArgs mutableCopy];
  [args setValue:method.JSMethodName forKey:@"method"];
  [args setValue:RCTJSONStringify(RCTNullIfNil(params), NULL) forKey:@"args"];
}

與此同時,執行后還會通過 invokeCallbackAndReturnFlushedQueue 觸發 JS 端的回調。具體細節在 RCTModuleMethod 的 processMethodSignature 方法中。

再小結一下,JS 調用 Native 的過程為 :

  • JS 把(調用模塊、調用方法、調用參數) 保存到隊列中;
  • Native 調用 JS 時,順便把隊列返回過來;
  • Native 處理隊列中的參數,同樣解析出(模塊、方法、參數),并通過 NSInvocation 動態調用;
  • Native方法調用完畢后,再次主動調用 JS。JS 端通過 callbackID,找到對應JS端的 callback,進行一次調用

整個過程大概就是這樣,剩下的一個問題就是,為什么要等待 Native 調用 JS 時才會觸發,中間會不會有很長延時?

事實上,只要有事件觸發,Native 就會調用 JS。比如,用戶只要對屏幕進行觸摸,就會觸發在 RCTRootView 中注冊的 Handler,并發送給JS:

[_bridge enqueueJSCall:@"RCTEventEmitter.receiveTouches"
                  args:@[eventName, reactTouches, changedIndexes]];

除了觸摸事件,還有 Timer 事件,系統事件等,只要事件觸發了,JS 調用時就會把隊列返回。這塊理解可以參看 React Native通信機制詳解 一文中的“事件響應”一節。

總結

俗話說一圖勝千言,整個啟動過程用一張圖概括起來就是:

使用 JS 構建跨平臺的原生應用:React Native iOS 通信機制初探

本文簡要介紹了 iOS 端啟動時 JS 和 Native 的交互過程,可以看出 BatchedBridge 在兩端通信過程中扮演了重要的角色。Native 調用 JS 是通過 WebSocket 或直接在 javascriptcore 引擎上執行;JS 調用 Native 則只把調用的模塊、方法和參數先緩存起來,等到事件觸發后通過返回值傳到 Native 端,另外兩端都保存了所有暴露的 Native 模塊信息表作為通信的基礎。由于對 iOS 端開發并不熟悉,文中如有錯誤的地方還請指出。

參考資料:

來自: http://taobaofed.org/blog/2015/12/30/the-communication-scheme-of-react-native-in-ios/

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!