导语
React Native是一套由 Facebook 开源的跨平台、动态更新的 Javascript 框架,其主张 “Learn once, write anywhere”,即学会一次 React,就可以编写所支持的两大移动平台(iOS,Android)的应用。通过结合 Web 端和 Native 端的开发优势,可以使用 JavaScript 来开发 iOS 和 Android 原生应用,并使用户获得和原生应用一致的顺畅 UI 体验。
1. ReactNative 概要
1.1 ReactNative 结构
ReactNative 这个单词能拆成两部分:React 和 Native,除此之外,还有一个连接 React 和 Native 的 Bridge 桥梁。
-
React
React 代表的是前端框架 React.JS,整个RN框架的JS代码部分,就是React.JS,所有这个框架的特点,完全都可以在RN中使用。我们知道RN采用了 React 和 ES6 的语法,因为要想学习RN,对于这些语法的了解是必不可少的。
关于 Javascript 的基础语法 ,推荐 W3School 里的 JS语法 ;在此之上,关于 React ,推荐阮一峰写的 React 入门实例教程;关于 ES6, 推荐这篇博客进行了解,中文的比较好理解,当然也可以翻看这里。
-
Native
顾名思义,使用RN开发出的应用具有原生的UI体验,其实质调用的也是纯原生的UI组件。React Native可以同时支持对 iOS、Android 两端的原生模块的调用。
-
Bridge
作为一个专注 View 层的一个前端框架,React.JS 会计算每个页面元素的位置大小并把数据传递给浏览器,让浏览器进行渲染。但是在RN中,这些UI数据传输的目的地不再是浏览器了,而是通过一个 JS/OC 的桥梁,去映射成原生下的 View 的初始化布局方法,以此用原生的方式渲染出了界面,相当于用 React.JS 绘制出了一个 native 的 View。
当用户在这个 View 上发生了触摸点击等事件时,也是通过一个 OC/JS 的桥梁去将事件传递回 React.JS 的事件处理方法中进行处理。
这样 React.JS 还是那个 React.JS ,他的使用方法没发生变化,但是却获得了原生 native 的体验。
1.2 ReactNative 优势
-
跨平台+代码复用
React Native 可以支持 iOS、Android 两大平台。一般而言,同一款产品下的 Android 和 iOS 两端除 UI 有些许不同外,多数业务逻辑几乎完全一致,因此在RN下,iOS、Android 两大平台下能够复用绝大部分代码。
Instagram 的官博 React Native at Instagram 一文中提到,利用RN开发的 feature 可以实现 85% - 99% 的代码复用率。这意味着利用RN进行开发的产品,我们可以用更少的人力成本来达到相同的效果。
-
动态更新
App的发布时,React等一系列资源会被打包成 js bundle 文件置于App安装包中。App启动时系统会加载 js bundle 文件,解析并渲染出来。所以,React Native 热更新的根本原理就是从服务器请求新的 js bundle 文件来更换本地旧的 js bundle 文件,并重新加载,新的内容就完美的展示出来了。
-
原生UI体验
React Native本质上还是调用的原生模块进行 UI 操作,所以用户体验也是和原生一致的。
-
开发效率高
对于熟练的 React Native 使用者来说,使用前端框架 React 来进行复杂页面布局的效率会大幅优于原生开发。并且使用 RN 在开发时,UI是实时热更新的,可以节省掉代码修改之后的编译用时,进一步提升效率。
2. 原生项目集成ReactNative
基于现有原生项目的规模,将现有的项目完全切换到 React Native 上对于绝大部分公司来说都是极为困难的。那么将 React Native 集成到现有项目中,用其来开发一些变化性较大的业务页面,这不失为一种良好的策略。
在 RN 的官方文档里有一节 Integration with Existing Apps ,当然也有中文版的, 只需要按照一步步做即可。不过在集成的过程中可能会遇到一些问题,下面就提提我遇到的一些问题(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混合开发中的交互
3.1 原生加载RN
首先自然是来写一个Hello World的例子吧~
经过上一步的在原生项目中集成了RN之后,项目中创建了 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);
在这里,我们创建了一个 HelloWorldCp 的React组件,并用 AppRegistry.registerComponent 注册了该组件,这样原生系统才可以使用该组件。在组件里,我们在默认的 render() 方法中输出了默认的view,view下包含了一个 "Hello World!" 的标签。对于view,设置了一个style,大意是将view下的 标签置于View的中央。
render 是 Component 默认的输出 UI 的方法。当你想制造出一个组件时,你继承 Component 并实现自定义的 render() 方法,在里边返回你想展现的UI,这就是自定义组件的创造方法。
接下来的事情便是在原生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 的调试服务地址,以供 RCTRootView 初始化时使用。RCTRootView 用来承载 JS 特定的组件,在原生下可以当做普通的 UIView 来进行处理,如添加到 superview,设置frame等操作。初始化时第一个参数为 JS 文件的服务器地址,moduleName 是 React 中注册好的组件, initialProperties 接收一个字典,用来传递参数给 JS,最后一个则是启动项参数。在这里,我们加载了上面创建的 HelloWorldCp 组件。最后将初始化的 RCTRootView 设置成新页面的根view并展示。
运行工程之前,我们需要先启动本地 js 服务
#cd 到‘node_modules’文件所在目录,然后
npm start
接着直接用xcode运行工程即可,假如没其他问题的,那么运行效果如下:
3.2 初始化RCTRootView的数据传递
上文提到在 RCTRootView 初始化的时候可以进行参数的传递,那么参数是如何被接收处理的呢?下面直接看代码:
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 的例子,这里初始化了一个字典,存储了一些名字及对应的分数,并在 RCTRootView 初始化的时候作为 initialProperties 的参数进行传递。
在 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 参数中读取字段的 key 获取对应的数据 Array,并通过 map 方法将其每一个数据单项映射成显示数据的标签,最后将标签列表置于View中返回。其中,对于变量 contents,我们需要用 {} 将其嵌入到 JSX 语句中。
props 即是 React 组件的属性,是一种父级向子级传递数据的方式。上面读取属性的代码也可以写成:this.props.scores。显然,通过 initialProperties 传递过来的字典变成了 React 组件的属性,可直接读取使用。但是 props 对于组件本身来说是不可变的,只能经由父组件传递更新。
我们还设置了 view 的 style,这里将 style 整体定义成变量初始后传递给view,借以保持代码的清晰整洁。
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#FFFFFF',
},
highScoresTitle: {
fontSize: 20,
textAlign: 'center',
margin: 10,
}
});
运行结果如下:
除了在初始化 RCTRootView 的时候可以传递参数,OC还可以用更新的方式传递数据给 JS 组件,修改这个属性,JS端会调用相应的渲染方法。
_rootView.appProperties = @{@"scores" :@[
@{@"name" : @"Alex",@"value": @"42"},
@{@"name" : @"Joel",@"value": @"10"},
@{@"name" : @"Zona",@"value": [NSString stringWithFormat:@"%ld",(long)_score++]}
]
};
这两种传递数据的方式是 OC 向 JS 传递数据的主要方式。
3.3 RN调用原生方法
RN向OC传递数据的主要形式之一便是通过在调用原生方法的时候传递参数。再而也为了让React Native可以利用现有原生庞大的组件资源,React Native在设计之初就考虑到了让React Native可以方便的调用Native端的方法。
3.3.1 支持调用的步骤
要想让iOS类内的方法能够被RN调用,类比RN端的组件注册,iOS端同样需要注册该类。首先便需要原生类实现协议:RCTBridgeModule,实现该协议的类,会自动注册到Object-C对应的Bridge中。所以定义可以让RN调用的类可以这样写
#import "RCTBridgeModule.h"
@interface RNIOSLog : NSObject<RCTBridgeModule>
@end
所有实现 RCTBridgeModule 的类都必须显示的使用宏命令:
@implementation RNIOSLog
RCT_EXPORT_MODULE();
@end
该宏的作用是:自动为该类注册为JS端的模块,当Object-c Bridge加载的时候。这个类注册的模块可以被JavaScript Bridge调用。当然该宏可以接受一个参数作为注册的模块名,默认值是该类的名称。
注册完模块之后,还需要注册模块下需要暴露给JS的方法。此外,暴露出的方法返回值必须为void。
RCT_EXPORT_METHOD(show:(NSString *)msg){
NSLog(@"msg:%@",msg);
}
原生的模块方法注册好之后,JS端该如何引用该类呢?
import {NativeModules} from "react-native";
var RNIOSLog = NativeModules.RNIOSLog;
引入到JS模块下之后,便可直接调用。
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 调用 OC 的效果。同样的,我们初始化 RCTRootView 并设置为新页面的根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调用OC的回调
对于OC暴露给RN的方法,要求不能有返回值。但是在很多应用场景下,我们也需要对调用之后的返回值进行相应的处理,这样就需要使用回调方法来对结果进行处理。在RN中专门定义了一个用于回调的参数 RCTReponseSenderBlock。
typedef void (^RCTResponseSenderBlock)(NSArray *response);
它接收了一个叫做 response 的 NSArray 的参数,其中 response[0] 代表着错误信息error,如果没有错误则传入null,即[NSNull null],后面的参数传入自定义的内容。
RCT_EXPORT_METHOD(showWithCallback:(RCTResponseSenderBlock)callback){
//do something you want
//callback(@"error",@"something is wrong");
callback(@[[NSNull null],@"call back from native"]);
}
在RN中,是这样调用Native方法并处理回调的:
_logCallback() {
RNIOSLog.showWithCallback(function (err, data){
if (err) {
console.warn(err, data);
} else {
console.warn(data,'无错回调');
}
});
}
<TouchableHighlight onPress={()=>this._logCallback()}>
<Text>showLogCallback</Text>
</TouchableHighlight>
之后便是同样的 RN 页面展示方法,初始化 RCTRootView 并设置为新页面的根view,并push出来显示。运行之后我们每次点击 RN 页面上的按钮标签都能看到RN调用Native端的回调log,运行效果如下图:
3.3.3 RN调用OC时的线程问题
JavaScript 代码都是单线程运行的,而调用到Native模块时都是默认运行在各自独立的线程上,所以可知RN调用Native的时候都是异步的。因此若是调用的Native方法有需要操作UI的,必须指定在主线程中运行,否则会出现一些莫名其妙的问题。比如RN调用的Native方法里需要弹出原生的 UIAlertView ,则可以在操作 UIAlertView 的时候用 GCD 切换到主线程:
dispatch_async(dispatch_get_main_queue(), ^{
//操作UI
});
此外,如果需要对整个导出的类都指定到某个特定的线程中去运行,那么在每个导出的方法里用 GCD 的方式去切换线程会显得很繁琐,则可以在类中实现 methodQueue 方法:
- (dispatch_queue_t)methodQueue{
return dispatch_get_main_queue();
}
只要实现了该方法并返回了特定的线程,那么该类下所有的方法在被RN调用时都会自觉的运行在该方法指定的线程下。
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;
对于第二种创建方式(initWithBundleURL),其会在每次调用时在方法内部创建一个 RCTBridge,且多个不同 RCTRootView 并不能共享 RCTBridge,这比较耗费时间和资源。因此对于一个半RN半native的应用的应用来说,最好还是使用第一种方式(initWithBridge)初始化 RCTRootView。
对于 initWithBridge 的方式初始化 RCTRootView,首先需要初始化一个 RCTBridge并保存,以便在需要的时候使用。在此之前,类本身需要实现 RCTBridgeDelegate 协议,
@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
在协议方法 sourceURLForBridge 中,返回 RN 模块地址。然后便可以初始化我们的bridge,
//使用保留的 RCTBridge 初始化 RCTRootView 更节省资源,不用每次初始化bridge
_bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:nil];
最后便可以到处使用该 bridge 初始化 RCTRootView了,这样能有效的节省每次初始化 bridge 的时间和资源耗费。
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:_bridge
moduleName:@"HelloWorldCp"
initialProperties:nil];
3.4 原生调用RN方法
现在,我们已经知道了在RN中该怎么直接调用OC中的方法,那么OC该如何主动的去调用 RN方法呢?
在以前的RN版本中,可以使用 sendDeviceEventWithName:body: 的方式来将调用请求发送到JS端,JS端用 addListener 的方式监听对应的关键字并实现方法即可实现OC调用RN方法。但是随着RN版本的更新,当继续使用这种互动方式的时候,在xcode下会出现警告:
<font color=#DC143C>'sendDeviceEventWithName:body:' is deprecated: Subclass RCTEventEmitter instead</font>
适应新的Api调用方式,让我们开始用起 RCTEventEmitter 来,其基本对接步骤是一致的。我们可以定义一个专门用来调用RN方法的类,在不影响其他原生模块的条件下方便和RN端对接。
-
1.该类需要继承自 RCTEventEmitter ,并且需要向RN端那边导出自己:
#import "RCTEventEmitter.h" @interface CallRNTest : RCTEventEmitter<RCTBridgeModule> @end
-
2.然后在 .m 文件中,在子类中为父类 RCTEventEmitter 的 bridge 生成 set/get方法,并使用用于导出模块的宏。
@implementation CallRNTest @synthesize bridge = _bridge; RCT_EXPORT_MODULE(); @end
假如不写第二句bridge的代码,在使用时会报没有设置bridge的错误:
<font color=#DC143C>*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'bridge is not set. </font>
-
3.导出所有需要传递的方法的名字
(NSArray<NSString *> *)supportedEvents{ return @[@"callRn"]; }
-
4.你可以在Native端实现在 supportedEvents 中定义的方法的同名方法,便于 区分理解Native端代码,也方便使用者调用。当然你也可以不这么做,反正最终都是使用 sendEventWithName 来进行真正的调用的。
-(void)nativeCallRn:(NSString*)code result:(NSString*) result { [self sendEventWithName:@"callRn" body:@{ @"code": code, @"result": result, }]; }
-
5.在 JS 端导出
import { ... NativeModules, NativeEventEmitter} from 'react-native'; var CallRNTest = NativeModules.CallRNTest; const myNativeEvt = new NativeEventEmitter(CallRNTest);
-
6.在 JS 端绑定
//在组件的生命周期中绑定与解绑 componentWillMount() { //对应原生端的名字 this.listener = myNativeEvt.addListener('callRn', this.callRn.bind(this)); } componentWillUnmount() { this.listener && this.listener.remove(); //记得remove哦 this.listener = null; }
-
7.在 JS 端实现绑定的方法
//接受原生传过来的数据 data={code:,result:} callRn(data) { console.warn(data.code, data.result); }
-
8.在 Native 端合适的时机调用,结束啦~
[self nativeCallRn:@"200" result:@"OC call Rn"];
4.0 Demo Project
写了一个 Demo Project:
https://github.com/xzr123/LittleReactNativeDemo
如果你想试一试运行工程并且还没有安装好 React Native 开发环境,先看这个官方文档配置环境是个不错的选择。
之后,用别忘了启动 RN 本地调试服务器
#cd 到‘node_modules’文件所在目录,然后
npm start
接着用Xcode打开项目工程看看运行效果吧。该Demo是基于 React Native 0.45 版本环境下的。