React Native混合开发(iOS)下的数据交互

1字数 4287阅读 5096
React Native
React Native

导语

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运行工程即可,假如没其他问题的,那么运行效果如下:

Hello World

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,
     }
 });

运行结果如下:

Param Pass

除了在初始化 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,运行效果如下图:

callback

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 版本环境下的。

参考

推荐阅读更多精彩内容