導(dǎo)語
React Native是一套由 Facebook 開源的跨平臺、動態(tài)更新的 Javascript 框架,其主張 “Learn once, write anywhere”,即學(xué)會一次 React,就可以編寫所支持的兩大移動平臺(iOS,Android)的應(yīng)用。通過結(jié)合 Web 端和 Native 端的開發(fā)優(yōu)勢,可以使用 JavaScript 來開發(fā) iOS 和 Android 原生應(yīng)用,并使用戶獲得和原生應(yīng)用一致的順暢 UI 體驗。
1. ReactNative 概要
1.1 ReactNative 結(jié)構(gòu)
ReactNative 這個單詞能拆成兩部分:React 和 Native,除此之外,還有一個連接 React 和 Native 的 Bridge 橋梁。
-
React
React 代表的是前端框架 React.JS,整個RN框架的JS代碼部分,就是React.JS,所有這個框架的特點,完全都可以在RN中使用。我們知道RN采用了 React 和 ES6 的語法,因為要想學(xué)習(xí)RN,對于這些語法的了解是必不可少的。
關(guān)于 Javascript 的基礎(chǔ)語法 ,推薦 W3School 里的 JS語法 ;在此之上,關(guān)于 React ,推薦阮一峰寫的 React 入門實例教程;關(guān)于 ES6, 推薦這篇博客進行了解,中文的比較好理解,當(dāng)然也可以翻看這里。
-
Native
顧名思義,使用RN開發(fā)出的應(yīng)用具有原生的UI體驗,其實質(zhì)調(diào)用的也是純原生的UI組件。React Native可以同時支持對 iOS、Android 兩端的原生模塊的調(diào)用。
-
Bridge
作為一個專注 View 層的一個前端框架,React.JS 會計算每個頁面元素的位置大小并把數(shù)據(jù)傳遞給瀏覽器,讓瀏覽器進行渲染。但是在RN中,這些UI數(shù)據(jù)傳輸?shù)哪康牡夭辉偈菫g覽器了,而是通過一個 JS/OC 的橋梁,去映射成原生下的 View 的初始化布局方法,以此用原生的方式渲染出了界面,相當(dāng)于用 React.JS 繪制出了一個 native 的 View。
當(dāng)用戶在這個 View 上發(fā)生了觸摸點擊等事件時,也是通過一個 OC/JS 的橋梁去將事件傳遞回 React.JS 的事件處理方法中進行處理。
這樣 React.JS 還是那個 React.JS ,他的使用方法沒發(fā)生變化,但是卻獲得了原生 native 的體驗。
1.2 ReactNative 優(yōu)勢
-
跨平臺+代碼復(fù)用
React Native 可以支持 iOS、Android 兩大平臺。一般而言,同一款產(chǎn)品下的 Android 和 iOS 兩端除 UI 有些許不同外,多數(shù)業(yè)務(wù)邏輯幾乎完全一致,因此在RN下,iOS、Android 兩大平臺下能夠復(fù)用絕大部分代碼。
Instagram 的官博 React Native at Instagram 一文中提到,利用RN開發(fā)的 feature 可以實現(xiàn) 85% - 99% 的代碼復(fù)用率。這意味著利用RN進行開發(fā)的產(chǎn)品,我們可以用更少的人力成本來達到相同的效果。
-
動態(tài)更新
App的發(fā)布時,React等一系列資源會被打包成 js bundle 文件置于App安裝包中。App啟動時系統(tǒng)會加載 js bundle 文件,解析并渲染出來。所以,React Native 熱更新的根本原理就是從服務(wù)器請求新的 js bundle 文件來更換本地舊的 js bundle 文件,并重新加載,新的內(nèi)容就完美的展示出來了。
-
原生UI體驗
React Native本質(zhì)上還是調(diào)用的原生模塊進行 UI 操作,所以用戶體驗也是和原生一致的。
-
開發(fā)效率高
對于熟練的 React Native 使用者來說,使用前端框架 React 來進行復(fù)雜頁面布局的效率會大幅優(yōu)于原生開發(fā)。并且使用 RN 在開發(fā)時,UI是實時熱更新的,可以節(jié)省掉代碼修改之后的編譯用時,進一步提升效率。
2. 原生項目集成ReactNative
基于現(xiàn)有原生項目的規(guī)模,將現(xiàn)有的項目完全切換到 React Native 上對于絕大部分公司來說都是極為困難的。那么將 React Native 集成到現(xiàn)有項目中,用其來開發(fā)一些變化性較大的業(yè)務(wù)頁面,這不失為一種良好的策略。
在 RN 的官方文檔里有一節(jié) Integration with Existing Apps ,當(dāng)然也有中文版的, 只需要按照一步步做即可。不過在集成的過程中可能會遇到一些問題,下面就提提我遇到的一些問題(React Native 0.45):
-
錯誤:'jschelpers/JavaScriptCore.h' file not found
Podfile文件中,需要添加 BatchedBridge ,如下:
pod 'React', :path => '../node_modules/react-native', :subspecs => [ 'Core' ’BatchedBridge']
path是相對于Podfile文件的相對路徑。
-
warning :Native component for “RCTImageView” does not exist
同樣是需要引入 RCTImage 模塊解決
pod 'React', :path => '../node_modules/react-native', :subspecs => [ 'Core', 'RCTImage']
3. 原生和RN混合開發(fā)中的交互
3.1 原生加載RN
首先自然是來寫一個Hello World的例子吧~
經(jīng)過上一步的在原生項目中集成了RN之后,項目中創(chuàng)建了 index.ios.js 的js文件,這是RN中iOS的js端入口文件,我們可以在里邊添加代碼如下:
import React, { Component } from 'react';
import { AppRegistry, View, Text} from 'react-native';
class HelloWorldCp extends Component {
render() {
return (
<View style={{flex:1 , justifyContent: 'center', alignItems: 'center'}}>
<Text>Hello world!</Text>
</View>
);
}
}
AppRegistry.registerComponent('HelloWorldCp', () => HelloWorldCp);
在這里,我們創(chuàng)建了一個 HelloWorldCp 的React組件,并用 AppRegistry.registerComponent 注冊了該組件,這樣原生系統(tǒng)才可以使用該組件。在組件里,我們在默認的 render() 方法中輸出了默認的view,view下包含了一個 "Hello World!" 的標(biāo)簽。對于view,設(shè)置了一個style,大意是將view下的 標(biāo)簽置于View的中央。
render 是 Component 默認的輸出 UI 的方法。當(dāng)你想制造出一個組件時,你繼承 Component 并實現(xiàn)自定義的 render() 方法,在里邊返回你想展現(xiàn)的UI,這就是自定義組件的創(chuàng)造方法。
接下來的事情便是在原生UI中將這個組件顯示出來,我們需要用到React容器類RCTRootView。
NSURL *jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"HelloWorldCp"
initialProperties:nil
launchOptions:nil];
UIViewController *vc = [[UIViewController alloc] init];
vc.view = rootView;
[self.navigationController pushViewController:vc animated:YES];
看代碼可知,首先我們初始化了一個 NSURL 對象,它指向本地 JS 的調(diào)試服務(wù)地址,以供 RCTRootView 初始化時使用。RCTRootView 用來承載 JS 特定的組件,在原生下可以當(dāng)做普通的 UIView 來進行處理,如添加到 superview,設(shè)置frame等操作。初始化時第一個參數(shù)為 JS 文件的服務(wù)器地址,moduleName 是 React 中注冊好的組件, initialProperties 接收一個字典,用來傳遞參數(shù)給 JS,最后一個則是啟動項參數(shù)。在這里,我們加載了上面創(chuàng)建的 HelloWorldCp 組件。最后將初始化的 RCTRootView 設(shè)置成新頁面的根view并展示。
運行工程之前,我們需要先啟動本地 js 服務(wù)
#cd 到‘node_modules’文件所在目錄,然后
npm start
接著直接用xcode運行工程即可,假如沒其他問題的,那么運行效果如下:
3.2 初始化RCTRootView的數(shù)據(jù)傳遞
上文提到在 RCTRootView 初始化的時候可以進行參數(shù)的傳遞,那么參數(shù)是如何被接收處理的呢?下面直接看代碼:
NSDictionary *param = @{@"scores" :@[
@{@"name" : @"Alex",@"value": @"42"},
@{@"name" : @"Joel",@"value": @"10"},
@{@"name" : @"Zona",@"value": @"20"}
]
};
NSURL *jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"ParamPassCp"
initialProperties:param
launchOptions:nil];
UIViewController *vc = [[UIViewController alloc] init];
vc.view = rootView;
[self.navigationController pushViewController:vc animated:YES];
相比于上面 Hello World 的例子,這里初始化了一個字典,存儲了一些名字及對應(yīng)的分數(shù),并在 RCTRootView 初始化的時候作為 initialProperties 的參數(shù)進行傳遞。
在 JS 端是如何接收的呢?
class ParamPassCp extends React.Component {
render() {
var contents = this.props["scores"].map(
score => <Text key={score.name}>{score.name}:{score.value}{"\n"}</Text>
);
return (
<View style={styles.container}>
<Text style={styles.highScoresTitle}>
{contents}
</Text>
</View>
);
}
}
同樣是在 render() 方法中,我們直接從 props 參數(shù)中讀取字段的 key 獲取對應(yīng)的數(shù)據(jù) Array,并通過 map 方法將其每一個數(shù)據(jù)單項映射成顯示數(shù)據(jù)的標(biāo)簽,最后將標(biāo)簽列表置于View中返回。其中,對于變量 contents,我們需要用 {} 將其嵌入到 JSX 語句中。
props 即是 React 組件的屬性,是一種父級向子級傳遞數(shù)據(jù)的方式。上面讀取屬性的代碼也可以寫成:this.props.scores。顯然,通過 initialProperties 傳遞過來的字典變成了 React 組件的屬性,可直接讀取使用。但是 props 對于組件本身來說是不可變的,只能經(jīng)由父組件傳遞更新。
我們還設(shè)置了 view 的 style,這里將 style 整體定義成變量初始后傳遞給view,借以保持代碼的清晰整潔。
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#FFFFFF',
},
highScoresTitle: {
fontSize: 20,
textAlign: 'center',
margin: 10,
}
});
運行結(jié)果如下:
除了在初始化 RCTRootView 的時候可以傳遞參數(shù),OC還可以用更新的方式傳遞數(shù)據(jù)給 JS 組件,修改這個屬性,JS端會調(diào)用相應(yīng)的渲染方法。
_rootView.appProperties = @{@"scores" :@[
@{@"name" : @"Alex",@"value": @"42"},
@{@"name" : @"Joel",@"value": @"10"},
@{@"name" : @"Zona",@"value": [NSString stringWithFormat:@"%ld",(long)_score++]}
]
};
這兩種傳遞數(shù)據(jù)的方式是 OC 向 JS 傳遞數(shù)據(jù)的主要方式。
3.3 RN調(diào)用原生方法
RN向OC傳遞數(shù)據(jù)的主要形式之一便是通過在調(diào)用原生方法的時候傳遞參數(shù)。再而也為了讓React Native可以利用現(xiàn)有原生龐大的組件資源,React Native在設(shè)計之初就考慮到了讓React Native可以方便的調(diào)用Native端的方法。
3.3.1 支持調(diào)用的步驟
要想讓iOS類內(nèi)的方法能夠被RN調(diào)用,類比RN端的組件注冊,iOS端同樣需要注冊該類。首先便需要原生類實現(xiàn)協(xié)議:RCTBridgeModule,實現(xiàn)該協(xié)議的類,會自動注冊到Object-C對應(yīng)的Bridge中。所以定義可以讓RN調(diào)用的類可以這樣寫
#import "RCTBridgeModule.h"
@interface RNIOSLog : NSObject<RCTBridgeModule>
@end
所有實現(xiàn) RCTBridgeModule 的類都必須顯示的使用宏命令:
@implementation RNIOSLog
RCT_EXPORT_MODULE();
@end
該宏的作用是:自動為該類注冊為JS端的模塊,當(dāng)Object-c Bridge加載的時候。這個類注冊的模塊可以被JavaScript Bridge調(diào)用。當(dāng)然該宏可以接受一個參數(shù)作為注冊的模塊名,默認值是該類的名稱。
注冊完模塊之后,還需要注冊模塊下需要暴露給JS的方法。此外,暴露出的方法返回值必須為void。
RCT_EXPORT_METHOD(show:(NSString *)msg){
NSLog(@"msg:%@",msg);
}
原生的模塊方法注冊好之后,JS端該如何引用該類呢?
import {NativeModules} from "react-native";
var RNIOSLog = NativeModules.RNIOSLog;
引入到JS模塊下之后,便可直接調(diào)用。
class RNLogCp extends Component {
render() {
return (
<View style={styles.container}>
<TouchableHighlight onPress={()=>RNIOSLog.show('from react native')}
style={styles.btn}>
<Text>showLog</Text>
</TouchableHighlight>
</View>
);
}
}
在RN中,TouchableXXX就表示是按鈕控件。TouchableHighlight在點擊的時候,該控件會高亮顯示。此外還有TouchableOpacity,TouchableNativeFeedback 和TouchableWithoutFeedback。
到這一步之后,便是讓 RN 頁面展示出來,點擊 RN 組件上的按鈕便可看到 RN 調(diào)用 OC 的效果。同樣的,我們初始化 RCTRootView 并設(shè)置為新頁面的根view,并push出來顯示。
NSURL *jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"RNLogCp"
initialProperties:nil
launchOptions:nil];
UIViewController *vc = [[UIViewController alloc] init];
vc.view = rootView;
[self.navigationController pushViewController:vc animated:YES];
3.3.2 RN調(diào)用OC的回調(diào)
對于OC暴露給RN的方法,要求不能有返回值。但是在很多應(yīng)用場景下,我們也需要對調(diào)用之后的返回值進行相應(yīng)的處理,這樣就需要使用回調(diào)方法來對結(jié)果進行處理。在RN中專門定義了一個用于回調(diào)的參數(shù) RCTReponseSenderBlock。
typedef void (^RCTResponseSenderBlock)(NSArray *response);
它接收了一個叫做 response 的 NSArray 的參數(shù),其中 response[0] 代表著錯誤信息error,如果沒有錯誤則傳入null,即[NSNull null],后面的參數(shù)傳入自定義的內(nèi)容。
RCT_EXPORT_METHOD(showWithCallback:(RCTResponseSenderBlock)callback){
//do something you want
//callback(@"error",@"something is wrong");
callback(@[[NSNull null],@"call back from native"]);
}
在RN中,是這樣調(diào)用Native方法并處理回調(diào)的:
_logCallback() {
RNIOSLog.showWithCallback(function (err, data){
if (err) {
console.warn(err, data);
} else {
console.warn(data,'無錯回調(diào)');
}
});
}
<TouchableHighlight onPress={()=>this._logCallback()}>
<Text>showLogCallback</Text>
</TouchableHighlight>
之后便是同樣的 RN 頁面展示方法,初始化 RCTRootView 并設(shè)置為新頁面的根view,并push出來顯示。運行之后我們每次點擊 RN 頁面上的按鈕標(biāo)簽都能看到RN調(diào)用Native端的回調(diào)log,運行效果如下圖:
3.3.3 RN調(diào)用OC時的線程問題
JavaScript 代碼都是單線程運行的,而調(diào)用到Native模塊時都是默認運行在各自獨立的線程上,所以可知RN調(diào)用Native的時候都是異步的。因此若是調(diào)用的Native方法有需要操作UI的,必須指定在主線程中運行,否則會出現(xiàn)一些莫名其妙的問題。比如RN調(diào)用的Native方法里需要彈出原生的 UIAlertView ,則可以在操作 UIAlertView 的時候用 GCD 切換到主線程:
dispatch_async(dispatch_get_main_queue(), ^{
//操作UI
});
此外,如果需要對整個導(dǎo)出的類都指定到某個特定的線程中去運行,那么在每個導(dǎo)出的方法里用 GCD 的方式去切換線程會顯得很繁瑣,則可以在類中實現(xiàn) methodQueue 方法:
- (dispatch_queue_t)methodQueue{
return dispatch_get_main_queue();
}
只要實現(xiàn)了該方法并返回了特定的線程,那么該類下所有的方法在被RN調(diào)用時都會自覺的運行在該方法指定的線程下。
3.3.4 bridge資源問題
對于 RCTRootView 官方提供了兩種初始化方式
- (instancetype)initWithBridge:(RCTBridge *)bridge
moduleName:(NSString *)moduleName
initialProperties:(NSDictionary *)initialProperties;
- (instancetype)initWithBundleURL:(NSURL *)bundleURL
moduleName:(NSString *)moduleName
initialProperties:(NSDictionary *)initialProperties
launchOptions:(NSDictionary *)launchOptions;
對于第二種創(chuàng)建方式(initWithBundleURL),其會在每次調(diào)用時在方法內(nèi)部創(chuàng)建一個 RCTBridge,且多個不同 RCTRootView 并不能共享 RCTBridge,這比較耗費時間和資源。因此對于一個半RN半native的應(yīng)用的應(yīng)用來說,最好還是使用第一種方式(initWithBridge)初始化 RCTRootView。
對于 initWithBridge 的方式初始化 RCTRootView,首先需要初始化一個 RCTBridge并保存,以便在需要的時候使用。在此之前,類本身需要實現(xiàn) RCTBridgeDelegate 協(xié)議,
@interface ViewController ()<RCTBridgeDelegate>
@property (nonatomic, strong) RCTBridge *bridge;
@end
@implementation ViewController
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
return [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios"];
}
@end
在協(xié)議方法 sourceURLForBridge 中,返回 RN 模塊地址。然后便可以初始化我們的bridge,
//使用保留的 RCTBridge 初始化 RCTRootView 更節(jié)省資源,不用每次初始化bridge
_bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:nil];
最后便可以到處使用該 bridge 初始化 RCTRootView了,這樣能有效的節(jié)省每次初始化 bridge 的時間和資源耗費。
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:_bridge
moduleName:@"HelloWorldCp"
initialProperties:nil];
3.4 原生調(diào)用RN方法
現(xiàn)在,我們已經(jīng)知道了在RN中該怎么直接調(diào)用OC中的方法,那么OC該如何主動的去調(diào)用 RN方法呢?
在以前的RN版本中,可以使用 sendDeviceEventWithName:body: 的方式來將調(diào)用請求發(fā)送到JS端,JS端用 addListener 的方式監(jiān)聽對應(yīng)的關(guān)鍵字并實現(xiàn)方法即可實現(xiàn)OC調(diào)用RN方法。但是隨著RN版本的更新,當(dāng)繼續(xù)使用這種互動方式的時候,在xcode下會出現(xiàn)警告:
<font color=#DC143C>'sendDeviceEventWithName:body:' is deprecated: Subclass RCTEventEmitter instead</font>
適應(yīng)新的Api調(diào)用方式,讓我們開始用起 RCTEventEmitter 來,其基本對接步驟是一致的。我們可以定義一個專門用來調(diào)用RN方法的類,在不影響其他原生模塊的條件下方便和RN端對接。
-
1.該類需要繼承自 RCTEventEmitter ,并且需要向RN端那邊導(dǎo)出自己:
#import "RCTEventEmitter.h" @interface CallRNTest : RCTEventEmitter<RCTBridgeModule> @end -
2.然后在 .m 文件中,在子類中為父類 RCTEventEmitter 的 bridge 生成 set/get方法,并使用用于導(dǎo)出模塊的宏。
@implementation CallRNTest @synthesize bridge = _bridge; RCT_EXPORT_MODULE(); @end假如不寫第二句bridge的代碼,在使用時會報沒有設(shè)置bridge的錯誤:
<font color=#DC143C>*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'bridge is not set. </font>
-
3.導(dǎo)出所有需要傳遞的方法的名字
(NSArray<NSString *> *)supportedEvents{ return @[@"callRn"]; } -
4.你可以在Native端實現(xiàn)在 supportedEvents 中定義的方法的同名方法,便于 區(qū)分理解Native端代碼,也方便使用者調(diào)用。當(dāng)然你也可以不這么做,反正最終都是使用 sendEventWithName 來進行真正的調(diào)用的。
-(void)nativeCallRn:(NSString*)code result:(NSString*) result { [self sendEventWithName:@"callRn" body:@{ @"code": code, @"result": result, }]; } -
5.在 JS 端導(dǎo)出
import { ... NativeModules, NativeEventEmitter} from 'react-native'; var CallRNTest = NativeModules.CallRNTest; const myNativeEvt = new NativeEventEmitter(CallRNTest); -
6.在 JS 端綁定
//在組件的生命周期中綁定與解綁 componentWillMount() { //對應(yīng)原生端的名字 this.listener = myNativeEvt.addListener('callRn', this.callRn.bind(this)); } componentWillUnmount() { this.listener && this.listener.remove(); //記得remove哦 this.listener = null; } -
7.在 JS 端實現(xiàn)綁定的方法
//接受原生傳過來的數(shù)據(jù) data={code:,result:} callRn(data) { console.warn(data.code, data.result); } -
8.在 Native 端合適的時機調(diào)用,結(jié)束啦~
[self nativeCallRn:@"200" result:@"OC call Rn"];
4.0 Demo Project
寫了一個 Demo Project:
https://github.com/xzr123/LittleReactNativeDemo
如果你想試一試運行工程并且還沒有安裝好 React Native 開發(fā)環(huán)境,先看這個官方文檔配置環(huán)境是個不錯的選擇。
之后,用別忘了啟動 RN 本地調(diào)試服務(wù)器
#cd 到‘node_modules’文件所在目錄,然后
npm start
接著用Xcode打開項目工程看看運行效果吧。該Demo是基于 React Native 0.45 版本環(huán)境下的。