1. 程式人生 > >RN中JS與原生端相互通訊方式解析-IOS

RN中JS與原生端相互通訊方式解析-IOS

JavaScriptCore框架 是一個蘋果在iOS7引入的框架,該框架讓 Objective-C 和 JavaScript 程式碼直接的互動變得更加的簡單方便。

而JavaScriptCore是蘋果Safari瀏覽器的JavaScript引擎,或許你聽過Google的V8引擎,在WWDC上蘋果演示了最新的Safari,據說JavaScript處理速度已經大大超越了Google的Chrome,這就意味著JavaScriptCore在效能上也不輸V8了。

1.OC中主動呼叫JS

1.編寫原生元件繼承 RCTViewManager

接下來你需要一些Javascript程式碼來讓這個檢視變成一個可用的React元件:

提供原生檢視很簡單:

  • 首先建立一個子類
  • 新增RCT_EXPORT_MODULE()標記巨集
  • 實現-(UIView *)view方法
// RNTMapManager.m
#import <MapKit/MapKit.h>

#import <React/RCTViewManager.h>

@interface RNTMapManager : RCTViewManager
@end

@implementation RNTMapManager

RCT_EXPORT_MODULE()

- (UIView *)view
{
  return [[MKMapView alloc] init];
}

@end

接下來你需要一些Javascript程式碼來讓這個檢視變成一個可用的React元件:

// MapView.js

var { requireNativeComponent } = require('react-native');

// requireNativeComponent 自動把這個元件提供給 "RNTMapManager"
module.exports = requireNativeComponent('RNTMap', null);

現在我們就已經實現了一個完整功能的地圖元件了,諸如捏放和其它的手勢都已經完整支援。但是現在我們還不能真正的從Javascript端控制它。(╯﹏╰)

屬性

我們能讓這個元件變得更強大的第一件事情就是要能夠封裝一些原生屬性供Javascript使用。舉例來說,我們希望能夠禁用手指捏放操作,然後指定一個初始的地圖可見區域。禁用捏放操作只需要一個布林值型別的屬性就行了,所以我們新增這麼一行:

// RNTMapManager.m
RCT_EXPORT_VIEW_PROPERTY(pitchEnabled, BOOL)

注意我們現在把型別宣告為BOOL型別——React Native用RCTConvert來在JavaScript和原生程式碼之間完成型別轉換。如果轉換無法完成,會產生一個“紅屏”的報錯提示,這樣你就能立即知道程式碼中出現了問題。如果一切進展順利,上面這個巨集就已經包含了匯出屬性的全部實現。

現在要想禁用捏放操作,我們只需要在JS裡設定對應的屬性:

// MyApp.js
<MapView pitchEnabled={false} />

但這樣並不能很好的說明這個元件的用法——使用者要想知道我們的元件有哪些屬性可以用,以及可以取什麼樣的值,他不得不一路翻到Objective-C的程式碼。要解決這個問題,我們可以建立一個封裝元件,並且通過PropTypes來說明這個元件的介面。

// MapView.js
import React, { Component, PropTypes } from 'react';
import { requireNativeComponent } from 'react-native';

var RNTMap = requireNativeComponent('RNTMap', MapView);
import PropTypes  from 'prop-types';
export default class MapView extends Component {
  static propTypes = {
    /**
    * 當這個屬性被設定為true,並且地圖上綁定了一個有效的可視區域的情況下,
    * 可以通過捏放操作來改變攝像頭的偏轉角度。
    * 當這個屬性被設定成false時,攝像頭的角度會被忽略,地圖會一直顯示為俯視狀態。
    */
    pitchEnabled: PropTypes.bool,
  };
  render() {
    return <RNTMap {...this.props} />;
  }
}

譯註:使用了封裝元件之後,你還需要注意到module.exports匯出的不再是requireNativeComponent的返回值,而是所建立的包裝元件。

現在我們有了一個封裝好的元件,還有了一些註釋文件,使用者使用起來也更方便了。注意我們現在把requireNativeComponent的第二個引數從null變成了用於封裝的元件MapView。這使得React Native的底層框架可以檢查原生屬性和包裝類的屬性是否一致,來減少出現問題的可能。

現在,讓我們新增一個更復雜些的region屬性。我們首先新增原生程式碼:

// RNTMapManager.m
RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, RNTMap)
{
  [view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES];
}

這裡還可以通過bridge提供的方法直接呼叫JSModule

/**
 * This method is used to call functions in the JavaScript application context.
 * It is primarily intended for use by modules that require two-way communication
 * with the JavaScript code. Safe to call from any thread.
 */
- (void)enqueueJSCall:(NSString *)moduleDotMethod args:(NSArray *)args;
- (void)enqueueJSCall:(NSString *)module method:(NSString *)method args:(NSArray *)args completion:(dispatch_block_t)completion;

1.OC暴露api,JS主動來呼叫

在React Native中,一個“原生模組”就是一個實現了“RCTBridgeModule”協議的Objective-C類,其中RCT是ReaCT的縮寫。

// CalendarManager.h
#import <React/RCTBridgeModule.h>
#import <React/RCTLog.h>

@interface CalendarManager : NSObject <RCTBridgeModule>
@end

為了實現RCTBridgeModule協議,你的類需要包含RCT_EXPORT_MODULE()巨集。這個巨集也可以新增一個引數用來指定在Javascript中訪問這個模組的名字。如果你不指定,預設就會使用這個Objective-C類的名字。

// CalendarManager.m
@implementation CalendarManager

RCT_EXPORT_MODULE();

@end

你必須明確的宣告要給Javascript匯出的方法,否則React Native不會匯出任何方法。宣告通過RCT_EXPORT_METHOD()巨集來實現:

RCT_EXPORT_METHOD(addEvent:(NSString *)name location:(NSString *)location)
{
  RCTLogInfo(@"Pretending to create an event %@ at %@", name, location);
}

現在從Javascript裡可以這樣呼叫這個方法:

import { NativeModules } from 'react-native';
var CalendarManager = NativeModules.CalendarManager;
CalendarManager.addEvent('Birthday Party', '4 Privet Drive, Surrey');

注意: Javascript方法名

匯出到Javascript的方法名是Objective-C的方法名的第一個部分。React Native還定義了一個RCT_REMAP_METHOD()巨集,它可以指定Javascript方法名。當許多方法的第一部分相同的時候用它來避免在Javascript端的名字衝突。

橋接到Javascript的方法返回值型別必須是void。React Native的橋接操作是非同步的,所以要返回結果給Javascript,你必須通過回撥或者觸發事件來進行。(參見本文件後面的部分)

引數型別

RCT_EXPORT_METHOD 支援所有標準JSON型別,包括:

  • string (NSString)
  • number (NSInteger, float, double, CGFloat, NSNumber)
  • boolean (BOOL, NSNumber)
  • array (NSArray) 包含本列表中任意型別
  • object (NSDictionary) 包含string型別的鍵和本列表中任意型別的值
  • function (RCTResponseSenderBlock)

除此以外,任何RCTConvert類支援的的型別也都可以使用(參見RCTConvert瞭解更多資訊)。RCTConvert還提供了一系列輔助函式,用來接收一個JSON值並轉換到原生Objective-C型別或類。

在我們的CalendarManager例子裡,我們需要把事件的時間交給原生方法。我們不能在橋接通道里傳遞Date物件,所以需要把日期轉化成字串或數字來傳遞。我們可以這麼實現原生函式:

RCT_EXPORT_METHOD(addEvent:(NSString *)name location:(NSString *)location date:(nonnull NSNumber *)secondsSinceUnixEpoch)
{
  NSDate *date = [RCTConvert NSDate:secondsSinceUnixEpoch];
}

或者這樣:

RCT_EXPORT_METHOD(addEvent:(NSString *)name location:(NSString *)location date:(NSString *)ISO8601DateString)
{
  NSDate *date = [RCTConvert NSDate:ISO8601DateString];
}

不過我們可以依靠自動型別轉換的特性,跳過手動的型別轉換,而直接這麼寫:

RCT_EXPORT_METHOD(addEvent:(NSString *)name location:(NSString *)location date:(NSDate *)date)
{
  // Date is ready to use!
}

在Javascript既可以這樣:

CalendarManager.addEvent('Birthday Party', '4 Privet Drive, Surrey', date.getTime()); // 把日期以unix時間戳形式傳遞

也可以這樣:

CalendarManager.addEvent('Birthday Party', '4 Privet Drive, Surrey', date.toISOString()); // 把日期以ISO-8601的字串形式傳遞

兩個值都會被轉換為正確的NSDate型別。但如果提供一個不合法的值,譬如一個Array,則會產生一個“紅屏”報錯資訊。

隨著CalendarManager.addEvent方法變得越來越複雜,引數的個數越來越多,其中有一些可能是可選的引數。在這種情況下我們應該考慮修改我們的API,用一個dictionary來存放所有的事件引數,像這樣:

#import <React/RCTConvert.h>

RCT_EXPORT_METHOD(addEvent:(NSString *)name details:(NSDictionary *)details)
{
  NSString *location = [RCTConvert NSString:details[@"location"]];
  NSDate *time = [RCTConvert NSDate:details[@"time"]];
  ...
}

然後在JS裡這樣呼叫:

CalendarManager.addEvent('Birthday Party', {
  location: '4 Privet Drive, Surrey',
  time: date.toTime(),
  description: '...'
})

注意: 關於陣列和對映

Objective-C並沒有提供確保這些結構體內部值的型別的方式。你的原生模組可能希望收到一個字串陣列,但如果JavaScript在呼叫的時候提供了一個混合number和string的陣列,你會收到一個NSArray,裡面既有NSNumber也有NSString。對於陣列來說,RCTConvert提供了一些型別化的集合,譬如NSStringArray或者UIColorArray,你可以用在你的函式宣告中。對於對映而言,開發者有責任自己呼叫RCTConvert的輔助方法來檢測和轉換值的型別。

 回撥函式

從原始碼我們可以看出,RN提供了兩種回撥方式:普通回撥,Promise回撥。

/**
 * The type of a block that is capable of sending a response to a bridged
 * operation. Use this for returning callback methods to JS.
 */
typedef void (^RCTResponseSenderBlock)(NSArray *response);

/**
 * The type of a block that is capable of sending an error response to a
 * bridged operation. Use this for returning error information to JS.
 */
typedef void (^RCTResponseErrorBlock)(NSError *error);

/**
 * Block that bridge modules use to resolve the JS promise waiting for a result.
 * Nil results are supported and are converted to JS's undefined value.
 */
typedef void (^RCTPromiseResolveBlock)(id result);

/**
 * Block that bridge modules use to reject the JS promise waiting for a result.
 * The error may be nil but it is preferable to pass an NSError object for more
 * precise error messages.
 */
typedef void (^RCTPromiseRejectBlock)(NSString *code, NSString *message, NSError *error);

原生模組還支援一種特殊的引數——回撥函式。它提供了一個函式來把返回值傳回給JavaScript。

RCT_EXPORT_METHOD(findEvents:(RCTResponseSenderBlock)callback)
{
  NSArray *events = ...
  callback(@[[NSNull null], events]);
}

RCTResponseSenderBlock只接受一個引數——傳遞給JavaScript回撥函式的引數陣列。在上面這個例子裡我們用Node.js的常用習慣:第一個引數是一個錯誤物件(沒有發生錯誤的時候為null),而剩下的部分是函式的返回值。

CalendarManager.findEvents((error, events) => {
  if (error) {
    console.error(error);
  } else {
    this.setState({events: events});
  }
})

原生模組通常只應呼叫回撥函式一次。但是,它可以儲存callback並在將來呼叫。這在封裝那些通過“委託函式”來獲得返回值的iOS API時最為常見。RCTAlertManager中就屬於這種情況。

如果你想傳遞一個更接近Error型別的物件給Javascript,可以用RCTUtils.h提供的RCTMakeError函式。現在它僅僅是傳送了一個和Error結構一樣的dictionary給Javascript,但我們考慮在將來版本里讓它產生一個真正的Error物件。

注意

如果你傳遞了回撥函式,那麼在原生端就必須執行它(如果傳遞了兩個,比如onSuccess和onFail,那麼執行其中一個即可),否則會導致記憶體洩漏。

Promises

譯註:這一部分涉及到較新的js語法和特性,不熟悉的讀者建議先閱讀ES6的相關書籍和文件。

原生模組還可以使用promise來簡化程式碼,搭配ES2016(ES7)標準的async/await語法則效果更佳。如果橋接原生方法的最後兩個引數是RCTPromiseResolveBlockRCTPromiseRejectBlock,則對應的JS方法就會返回一個Promise物件。

我們把上面的程式碼用promise來代替回撥進行重構:

RCT_REMAP_METHOD(findEvents,
                 resolver:(RCTPromiseResolveBlock)resolve
                 rejecter:(RCTPromiseRejectBlock)reject)
{
  NSArray *events = ...
  if (events) {
    resolve(events);
  } else {
    reject(error);
  }
}

現在JavaScript端的方法會返回一個Promise。這樣你就可以在一個聲明瞭async的非同步函式內使用await關鍵字來呼叫,並等待其結果返回。(雖然這樣寫著看起來像同步操作,但實際仍然是非同步的,並不會阻塞執行來等待)。

async function updateEvents() {
  try {
    var events = await CalendarManager.findEvents();

    this.setState({ events });
  } catch (e) {
    console.error(e);
  }
}

updateEvents();

匯出常量

原生模組可以匯出一些常量,這些常量在JavaScript端隨時都可以訪問。用這種方法來傳遞一些靜態資料,可以避免通過bridge進行一次來回互動。

- (NSDictionary *)constantsToExport
{
  return @{ @"firstDayOfTheWeek": @"Monday" };
}

Javascript端可以隨時同步地訪問這個資料:

console.log(CalendarManager.firstDayOfTheWeek);

但是注意這個常量僅僅在初始化的時候匯出了一次,所以即使你在執行期間改變constantToExport返回的值,也不會影響到JavaScript環境下所得到的結果。

列舉常量

NS_ENUM定義的列舉型別必須要先擴充套件對應的RCTConvert方法才可以作為函式引數傳遞。

假設我們要匯出如下的NS_ENUM定義:

typedef NS_ENUM(NSInteger, UIStatusBarAnimation) {
    UIStatusBarAnimationNone,
    UIStatusBarAnimationFade,
    UIStatusBarAnimationSlide,
};

你需要這樣來擴充套件RCTConvert類:

@implementation RCTConvert (StatusBarAnimation)
  RCT_ENUM_CONVERTER(UIStatusBarAnimation, (@{ @"statusBarAnimationNone" : @(UIStatusBarAnimationNone),
                                               @"statusBarAnimationFade" : @(UIStatusBarAnimationFade),
                                               @"statusBarAnimationSlide" : @(UIStatusBarAnimationSlide)}),
                      UIStatusBarAnimationNone, integerValue)
@end

接著你可以這樣定義方法並且匯出enum值作為常量:

- (NSDictionary *)constantsToExport
{
  return @{ @"statusBarAnimationNone" : @(UIStatusBarAnimationNone),
            @"statusBarAnimationFade" : @(UIStatusBarAnimationFade),
            @"statusBarAnimationSlide" : @(UIStatusBarAnimationSlide) }
};

RCT_EXPORT_METHOD(updateStatusBarAnimation:(UIStatusBarAnimation)animation
                                completion:(RCTResponseSenderBlock)callback)

你的列舉現在會用上面提供的選擇器進行轉換(上面的例子中是integerValue),然後再傳遞給你匯出的函式。