React Native 有赞初探

对于移动客户端开发一般需要同时考虑 iOSAndroid 两个平台,而这两个平台需要两种完全不同的编程语言来开发,React Native 的出现就可以在很大程度上满足一次开发双平台适用,本文重点介绍 iOSAndroid 两个平台下如何配置 React Native 环境,原生和 React Native 的通信以及 React Native 热部署,而 JavaScript 编写的 UI 基本上可以双平台共用,UI 部分在 React Native 官网也有详细的介绍,本文就不再做重点介绍。

以下讨论的所有问题都是基于 0.33.0 版本,因为 React Native 现在版本更新很快,临近的版本之间也有很大差异,所以其他版本的就不再单独指出其差异。

1. 接入原生工程

1.1 Android 平台

1.1.1 接入前的配置

先在工程目录下新建一个名为 package.json 文件,文件基本内容为:

{
  "name": "react-native-module",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start"
  },
  "dependencies": {
    "react": "^15.3.1",
    "react-native": "^0.33.0",
  },
  "main": "index.android.js",
  "devDependencies": {},
  "author": "",
  "license": "ISC",
  "description": "Your app name",
  "repository": {
    "type": "git",
    "url": "git@gitlab.qima-inc.com:app/your_app.git"
  }
}

在原生工程的根目录下,依次执行下面的命令:

$ npm init
$ npm install --save react react-native
$ curl -o .flowconfig https://raw.githubusercontent.com/facebook/react-native/master/.flowconfig

在 app 目录下的 build.gradle 文件中添加 React Native 的依赖

dependencies {
    ...
    compile "com.facebook.react:react-native:+" 
}

然后在工程目录下的 build.gradle 文件添加 maven 目录:

allprojects {
    repositories {
        mavenLocal()	// 这行配置绝对不能少,并且要在第一行,不然下载的版本就会是0.30以下
        jcenter()
        maven {
            url "$projectDir/../node_modules/react-native/android"
        }
    }
}

这里的 url 路径,将取决于你放置 node_modules 的位置(你可以根据需要选择放置在其他地方)

1.1.2 常见的错误和异常

到此需要完成的配置基本上算结束了,但是这过程中有可能会出现一些异常,下面列举几种常见的问题。

1. 问:构建工程失败,错误信息:

SDK location not found. Define location with sdk.dir in the local.properties file or with an ANDROID_HOME environment variable.

答:这个问题应该不用我说怎么解决了吧,如果真不会的话,请 Google

2. 问:

android/app/src/main/assets/index.android.bundle: No such file or Warning: directory或者手机上提示The development server returned response error code: 500

答:在 android/app/src/main/ 下建立 assets 文件夹,然后执行下面一行命令:

"http://localhost:8081/index.android.bundle?platform=android" -o "android/app/src/main/assets/index.android.bundle" 

3. 问:

java.lang.IllegalAccessError: Method 'void android.support.v4.net.ConnectivityManagerCompat.()' is inaccessible to class 'com.facebook.react.modules.netinfo.NetInfoModule' (declaration of 'com.facebook.react.modules.netinfo.NetInfoModule' appears in /data/app/package.name-2/base.apk)
或者 Could not find com.facebook.react:react-native:0.xx.x

答:记得在工程目录下的 build.gradle 文件中添加如下配置

mavenLocal()			
jcenter()
maven {
  url "$projectDir/../node_modules/react-native/android"
}

4. 问:

Can't find variable: __fbBatchedBridge in android release

答:在工程根目录下输入命令:

react-native bundle --platform android --dev true --entry-file index.android.js --bundle-output app/src/main/assets/index.android.bundle --assets-dest app/src/main/res/

1.1.3 接入 React Native

(1) Application 实现 ReactApplication 接口

public class YourApplication extends BaseApplication implements ReactApplication{
	private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        @Override
        protected boolean getUseDeveloperSupport() {
            return BuildConfig.DEBUG;
        }
        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                    new MainReactPackage()
            );
        }
    };
    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }
}

(2) 创建 Activity 并继承 ReactActivity

public class GiftReactActivity extends ReactActivity {

    /**
     * Returns the name of the main component registered from JavaScript.
     * This is used to schedule rendering of the component.
     */
    @Override
    protected String getMainComponentName() {
        return "ReactNativeApp";
    }
}

getMainComponentName 方法返回的字符串很重要,它决定了需要显示加载的 js 文件,如何对应,接下来在介绍 js 文件的时候还会提到。

(3) 在工程目录下建立 index.android.js 文件

我们以经典的 Hello world 开始,代码如下:

import React, { Component } from 'react';
import { AppRegistry, Text } from 'react-native';

class HelloWorldApp extends Component {
  render() {
    return (
      <Text style={styles.bigblue}>Hello world!</Text>
      <Text style={styles.bigblue, styles.red}>Hello world!</Text>
    );
  }
}
const styles = StyleSheet.create({
  bigblue: {
    color: 'blue',
    fontWeight: 'bold',
    fontSize: 30,
  },
  red: {
    color: 'red',
  },
});
AppRegistry.registerComponent('ReactNativeApp', () => HelloWorldApp);

此处的 ReactNativeApp 必须和步骤 (2) 中的提到的字符串保持一致,否则 Activity 无法找到对应的 js 文件

这段代码比较简单,只有一个 Text 控件,和 Android 原生控件 TextView 类似,显示内容直接添加到 Text 标签中间即可。更改 Text 属性是通过 style 方式实现的,在 style 中定义各种类 css 属性,比如上面代码中就设置了文字的颜色、大小和加粗,调用方式为 style={styles.bigblue}

(4) 清单文件添加声明

Android 清单文件 AndroidManifest.xml 添加以下内容(省略了无关部分):

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools"
          package="com.qima.kdt">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

    <application ... >
         ... 
        <activity android:name=".business.react.GiftReactActivity"/>
        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
    </application>

</manifest>

其中 GiftReactActivity 为承载 React Native 页面的业务 ActivityDevSettingsActivity 则是以下这个 Dev Settings 的界面:

DevSetting

在真机环境下,打开 app 到 React Native 页面,摇动手机会出现如下页面:

数字 1 菜单表示重新加载 js 文件,这个功能在开发过程中非常实用,如果只是更改了 js 文件,那么只需要点击 Reload 菜单即可立马看到更改后的效果。

点击数字 2 菜单即可进入上文提到的 DevSettingActivity 界面。

(5) 到 package.json 的位置,打开命令行,运行 packager server:

npm start 或者 react-native start

至此就可以在手机上看到 React Native 页面了。

(6) debug模式需要电脑端时刻运行 package server ,方便调试,如果想要打 release 包,可以运行如下命令,这样 React Native 页面加载就不需要电脑,直接从本地加载显示。但是 release 包如果需要更改原生或者 js 文件,都需要重新安装 apk 文件。

react-native bundle --platform android --dev true --entry-file index.android.js --bundle-output app/src/main/assets/index.android.bundle --assets-dest app/src/main/res/

1.2 iOS 平台

1.2.1 接入前的配置

iOS 接入 React Native 相对要简单一些,需要借助 Cocoapods ,因此必须首先安装 Cocoapods

$ sudo gem install cocoapods

(1) 添加 Package 依赖

在项目根目录下建立 package.json ,添加相关的 package 依赖,示例如下:

{
  "name": "Koudaitong",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start"
  },
  "dependencies": {
    "react": "15.3.1",
    "react-native": "0.33.0",
    "react-native-swipeout": "^2.0.12"
  }
}

(2) Packages 安装

通过 Node 包管理器来安装 React NativeNode 模块将被安装到根目录的 node_modules/ 目录下。

$ npm install

(3) 安装 React Native Framework

Podfile 末尾添加如下配置:

pod 'React', :path => '../node_modules/react-native', :subspecs => [
    'Core',
    'RCTText',
    'RCTImage',
    'RCTNetwork',
    'RCTWebSocket', # needed for debugging
    # Add any other subspecs you want to use in your project
]

(4) 安装 Pod

$ pod install

1.2.2 代码集成

(1) 创建 index.ios.js 文件

在根目录下创建 index.ios.js 文件,我们还以经典的 Hello World 为例,代码如下:

import React, { Component } from 'react';
import { AppRegistry, Text } from 'react-native';

class HelloWorldApp extends Component {
  render() {
    return (
      <View>
         <Text style={styles.bigblue}>Hello world!</Text>
         <Text style={styles.bigblue, styles.red}>Hello world!</Text>
      </View>
    );
  }
}
const styles = StyleSheet.create({
  bigblue: {
    color: 'blue',
    fontWeight: 'bold',
    fontSize: 30,
  },
  red: {
    color: 'red',
  },
});
AppRegistry.registerComponent('ReactNativeApp', () => HelloWorldApp);

(2) Objective-C 代码接入

我们需要用 React Native packager 来创建 index.ios.bundle ,并通过 React Native server 来托管,因此需要借助 RCTRootView 来判断 index.ios.bundle 来源。

首先需要在要接入 React Native 页面的 ViewController 中导入 RCTRootView.h ,然后在 viewDidLoad 中添加如下代码:

NSURL *jsCodeLocation =
[NSURL URLWithString:@"http://172.17.8.76:8081/index.ios.bundle?platform=ios"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                    moduleName:@"navigation"
                                             initialProperties:@{@"name": @"gift"}
                                                 launchOptions:nil];
[rootView setFrame:CGRectMake(0, 0, CGRectGetWidth(self.view.frame), CGRectGetHeight(self.view.frame))];
rootView.backgroundColor = UIColorFromRGB(tableBg);
self.view = rootView;

(3) 到 package.json 的位置,打开命令行,运行 packager server

npm start 或者 react-native start

至此就可以在模拟器上看到 React Native 页面了。

2. React Native 与原生通信

有时候 App 需要访问平台 API ,但 React Native 可能还没有相应的模块包装;或者你需要复用一些 Java 代码,而不是用 JavaScript 重新实现一遍;又或者你需要实现某些高性能的、多线程的代码,譬如图片处理、数据库、或者各种高级扩展等等。

不管是在 React Native 层面封装 Java 原生控件,还是实现两种编程语言的信息互通,都有助于 React Native 实现一些复杂的功能,并且还可以达到原生的体验效果。

2.1 Android 平台

2.1.1 React Native 主动发信息

Android 平台下 React Native 发信息模型如下:

接下来以 AlertDialog 为例说明如何在 React Native 层面实现原生的 Dialog 效果,首先需要创建一个原生模块。一个原生模块是一个继承了 ReactContextBaseJavaModule 的 Java 类,它可以实现一些 JavaScript 所需的功能。最终调用的效果如下:

var options = {
  'title': '',
  'message': '确认删除吗?',
  'positive': '确定',
  'negative': '取消'
}
var positiveCallback = () => {
  var segmentSelect = that.state.segmentSelect
  var select = segmentSelect? 0:1
  console.log(id);
  GiftRequest.deleteGift(segmentSelect, id,
      () => {
          ToastAndroid.show('删除成功', ToastAndroid.SHORT)
          this._getData(select)
      },
      () => {
          ToastAndroid.show('删除失败', ToastAndroid.SHORT)
      }
  )
}
intentModule.showDialog(options, positiveCallback)

针对上述代码需要几点说明:

  1. options 包含了对话框的所有文案信息,当然这些 key 值都是提前约定好的,下文会提及 Java 原生如何保证这些约定。
  2. positiveCallbackDialog 确定按钮点击事件的回调方法,本例中点击确定按钮执行删除操作。
  3. intentModule.showDialog 是 Java 中预先写好的方法,下文中会提及该方法的定义
  4. 需要补充一点,在 js 文件中还需要声明 NativeModule ,该模块负责与原生建立连接,代码如下:
import {
  AppRegistry,
  StyleSheet,
  NativeModules,
  View
} from 'react-native';
var intentModule = NativeModules.ZanIntentModule;

以上就是 React Native 需要做的事情,接下来就是原生 Java 代码需要支持的操作,再说原生操作之前,还需要明确一点就是 React NativeJava 通信的数据结构,比如说上述例子中的 optionspositiveCallback 分别在 Java 中对应什么数据结构。官方给出的数据结构对照关系如下:

Boolean -> Bool
Integer -> Number
Double -> Number
Float -> Number
String -> String
Callback -> function
ReadableMap -> Object
ReadableArray -> Array

左侧对应的是 Java 的数据结构,右侧对应的是 JavaScript 的数据结构,其中 CallbackReadableMapReadableArrayReact Native 框架提供的数据类型,具体用法下文会重点提及。

在上文 1.3(1) 中提及了 Application 需要实现 ReactApplication ,并且需要实现其中几个方法,其中 getPackages() 还需要添加自定义的 IntentReactPackage ,代码如下:

@Override
protected List<ReactPackage> getPackages() {
    return Arrays.<ReactPackage>asList(
            new MainReactPackage(),
            new IntentReactPackage()
    );
}

IntentReactPackage 类需要实现 ReactPackage 接口,然后在其接口方法中添加自定义的通信模块,代码如下:

public class IntentReactPackage implements ReactPackage {
    @Override
    public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
        List<NativeModule> modules = new ArrayList<>();
        modules.add(new ZanIntentModule(reactContext));
        return modules;
    }

    @Override
    public List<Class<? extends JavaScriptModule>> createJSModules() {
        return Collections.emptyList();
    }

    @Override
    public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
        return Collections.emptyList();
    }
}

重点来看 createNativeModules 方法,其他两个方法暂时不需要关心,默认就好了,在 createNativeModules 方法中添加了自定义的 ZanIntentModule 类,这个类就是我们需要暴露给 React Native 的方法,接下来重点来看这个类。

public class ZanIntentModule extends ReactContextBaseJavaModule {

    public static final String TAG = "ZanIntentModule";

    @Override
    public String getName() {
        return "ZanIntentModule";
    }
    
     /**
     * 弹出对话框
     */
    @ReactMethod
    public void showDialog(final ReadableMap map, final Callback positiveCallback) {
        Activity currentActivity = getCurrentActivity();
        if (currentActivity == null) return;
        new AlertDialog.Builder(currentActivity)
            .setCancelable(true)
            .setTitle(map.getString("title"))
            .setMessage(map.getString("message"))
            .setPositiveButton(map.getString("positive"), new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    positiveCallback.invoke();
                }
            })
            .setNegativeButton(map.getString("negative"), null)
            .show();
    }
}

关于这段代码需要几点说明:

  1. getName() 方法需要返回一个标识字符串,该字符串必须要和 js 文件调用处保持一致,上文中关于 React Native 提到的声明中 var intentModule = NativeModules.ZanIntentModule; 这里的 ZanIntentModule 就必须要和getName() 方法的返回值保持一致。
  2. showDialog 方法就是上文中调用显示对话框的方法,该方法必须要用 @ReactMethod 注解标识,负责系统无法识别该方法是为 JavaScript 提供的接口方法。
  3. showDialog 方法的形参对应了上文 js 文件中的调用参数, options 对应此处的 ReadableMappositiveCallback 对应此处的 Callback ,上文提到 options 中 key 值的约定就体现在 ReadableMapkey 值,两者必须要严格对应。
  4. 关于 ReadableMap 我们可以看一下官方都提供了那些方法:
package com.facebook.react.bridge;

/**
 * Interface for a map that allows typed access to its members. Used to pass parameters from JS to
 * Java.
 */
public interface ReadableMap {
  boolean hasKey(String name);
  boolean isNull(String name);
  boolean getBoolean(String name);
  double getDouble(String name);
  int getInt(String name);
  String getString(String name);
  ReadableArray getArray(String name);
  ReadableMap getMap(String name);
  ReadableType getType(String name);
  ReadableMapKeySetIterator keySetIterator();
}

看完 ReadableMap 提供的方法应该会对上文提到的数据结构对应表有更直观的理解了吧。

利用上面的数据结构对应类型,可以定义一个通用的参数传递机制,先来看代码:

/**
 * 从js页面跳转到原生Activity, 同时也可以从js传递相关数据到原生页面
 * @param map
 */
@ReactMethod
public void startActivityFromJS(String name, ReadableMap map){
    try{
        Activity currentActivity = getCurrentActivity();
        if(null != currentActivity) {
            Class toActivity = Class.forName(name);
            Intent intent = new Intent(currentActivity, toActivity);
            // 封装传递参数
            ReadableMapKeySetIterator iterator = map.keySetIterator();
            while (iterator.hasNextKey()){
                String key = iterator.nextKey();
                if (ReadableType.Number.equals(map.getType(key))){
                    intent.putExtra(key, map.getInt(key));
                } else if (ReadableType.String.equals(map.getType(key))){
                    intent.putExtra(key,map.getString(key));
                } else if (ReadableType.Boolean.equals(map.getType(key))){
                    intent.putExtra(key,map.getBoolean(key));
                }
            }
            currentActivity.startActivity(intent);
        }
    }catch(Exception e){
        throw new JSApplicationIllegalArgumentException(
                "can not open Activity : " + e.getMessage());
    }
}

这是一个根据 js 传递的参数来决定跳转方向以及跳转参数的方法,为了尽量做到通用性,首先判断要跳转的 Activity 名字,然后遍历 ReadableMap,将 keyvalue 分别填充到 Intentbundle 中,由于 ReadableMap 数字类型只有 NumberJavaIntent 中对应的有 intlongfloat 三种,这里暂时没有做区分。

以上是 Android 平台下 React Native 与原生的通信,但是如果原生需要主动给 js 文件发信息,上面的方法就没法实现了,那 js 主动发信息该如何实现呢?

2.1.2 原生主动发信息

React Native 提供了 RCTDeviceEventEmitter 来实现原生主动发信息,本质上来说就是利用事件总线进行分发。

React Native原生发消息示意图

具体实现方法也很简单,参考下面的代码:

WritableMap params = Arguments.createMap();
params.putString(CONFIG_CONTENT, data.getStringExtra(CONFIG_CONTENT));
params.putInt(CONFIG_CONTENT_ID, data.getIntExtra(CONFIG_CONTENT_ID, 0));
params.putInt(CONFIG_CONTENT_LIST_ID, data.getIntExtra(CONFIG_CONTENT_LIST_ID, 0));
params.putString(CONFIG_CONTENT_NICK_NAME, data.getStringExtra(CONFIG_CONTENT_NICK_NAME));

if (mReactContext.hasActiveCatalystInstance()){
    mReactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
            .emit("updateData", params);
}

以上代码完成了原生端发送的操作,发送主要是调用 ReactContextgetJSModuleemit 方法,其中 "updateData" 表示发送事件名称,params 表示发送的数据封装,查看 emit 源码可以看到数据类型为 Object ,因此可以封装任何数据类型。

@SupportsWebWorkers
  public interface RCTDeviceEventEmitter extends JavaScriptModule {
    void emit(String eventName, @Nullable Object data);
  }

2.2 iOS平台

2.2.1 React Native 主动发信息

React Native 控制原生 ViewController 跳转为例,说明 React Native 如何主动发信息。在 React Native 中,一个“原生模块”就是一个实现了“RCTBridgeModule”协议的 Objective-C 类,代码如下:

#import "RCTBridgeModule.h"

@interface ZanIntentModule : NSObject <RCTBridgeModule>
@end

为了实现 RCTBridgeModule 协议,类需要包含 RCT_EXPORT_MODULE() 宏。这个宏也可以添加一个参数用来指定在 JavaScript 中访问这个模块的名字。如果你不指定,默认就会使用这个 Objective-C 类的名字。

NSString *KDMarketGiftControllerHanlder = @"KDMarketGiftControllerHanlder";
...

RCT_EXPORT_METHOD(KDMarketGiftControllerHanlder : (NSDictionary *)object) {
    [[NSNotificationCenter defaultCenter]
     postNotificationName:KDMarketGiftControllerHanlder object:object];
}

该宏用来给 React Native 的 js 文件暴露公开接口,这样就可以在 js 文件中调用该方法,实现页面跳转的功能。上面的 KDMarketGiftControllerHanlder 方法通过消息中心发送了一个名称为 "KDMarketGiftControllerHanlder" 的消息,并且传递了一个字典类型的参数,该参数是 js 封装并传递进来的,下文中还会提到,这里暂时不表。

这个名叫 "KDMarketGiftControllerHanlder" 的消息在 KDMarketGiftController 中注册并处理,主要原理是 KDMarketGiftController 接收到该消息后,解析 object 参数,根据定义好的跳转协议进行原生页面的跳转,从而实现 js 代码控制原生页面跳转。

// 解析object参数,定义不同的跳转情况,注意需要注册消息通知
- (void)KDMarketGiftControllerHanlder:(NSNotification *)notification {
    NSDictionary *router = notification.object;
    NSString *path = router[@"path"];
    if ([path isEqualToString:@"openMarketingGiftWebViewFromJS"]) {
        KDWebViewController *web = [KDWebViewController webViewWithUrl:[KDURLHelper marketingGiftInfoURL]
                                                        usingUIWebView:NO
                                                     configWebProgress:NO];
        [self.navigationController pushViewController:web animated:YES];
    } else if ([path isEqualToString:@"addWord"]) {
        [self presentDetailController:nil];
    } else if ([path isEqualToString:@"editWord"]) {
        NSDictionary *json = router[@"model"];
        NSString *type = router[@"type"];
        NSError *error = nil;
        KDMarketWordModel *giftMarkertModel =
        [[KDMarketWordModel alloc] initWithDictionary:json error:&error];
        [self presentDetailController:giftMarkertModel];
    }
}

以上是原生 Objective-C 需要处理的逻辑,那么 React Native 中 js 文件如何使用原生 Objective-C 暴露的接口方法呢?其实也很简单,在需要控制原生页面跳转的 js 文件中,引入 ZanIntentModule ,然后调用 KDMarketGiftControllerHanlder 方法即可。

import {
  AppRegistry,
  StyleSheet,
  NativeModules,
} from 'react-native';
var ZanIntentModule = NativeModules.ZanIntentModule;
...
// 添加寄语
_addWord() {
	var type = this.state.FooterText;
	ZanIntentModule.KDMarketGiftControllerHanlder({
	  "path": "addWord",
	  "type": type,
	  "model": giftDataSource,
	});
}

KDMarketGiftControllerHanlder 方法中传递的 object 对象就对应了上文Objective-C 中的字典参数,和 Android 平台类似,iOS 原生与 js 通信也可以通过回调获得原生的返回值或者异步动作,而所不同的是在 iOS 平台下,原生处理回调是RCTResponseSenderBlock 实现的,具体使用方法如下:

// Objective-C代码
// js获取原生的token参数,RCT_EXPORT_METHOD不支持return返回值,可以通过回调将token传递给js
RCT_EXPORT_METHOD(getAccessToken : (RCTResponseSenderBlock)callback) {
    NSString *token = [KDKeyChainController sharedInstance].token;
    callback(@[token]);
}

// js代码,getAccessToken通过回调将token参数传递给js,这是一个异步的过程
async createTokenRequest(url){
     return new Promise((resolve, reject) => {
        intentMoudle.getAccessToken(
          (token) => {
              resolve(this.URL_CARMEN + url + "?access_token=" + token);
          })
     });
 },

最后还需要说明 Objective-C 和 js 代码之间的数据类型对应关系

前面是js中的数据类型,圆括号是Objective-C的数据类型
string     (NSString)
number     (NSInteger, float, double, CGFloat, NSNumber)
boolean    (BOOL, NSNumber)
array      (NSArray) 
object     (NSDictionary) 
function   (RCTResponseSenderBlock)

2.2.2 iOS 原生主动发消息

类似的,iOS 也是通过事件总线完成原生主动发消息的动作,比如原生执行完某个动作需要通知 js 刷新数据,那么就可以通过以下方式实现。

// Objective-C代码  发送刷新数据的事件,参数为nil
- (void)reloadData {
    [self.bridge.eventDispatcher sendAppEventWithName:kReloadData body:nil];
}

// js代码,在React Native的生命周期componentWillMount方法中执行刷新数据的动作
componentWillMount() {
    this.eventEmitter = NativeAppEventEmitter.addListener(
      'ReloadData',
      () => this._reloadData()
    );
  }

3. 热部署

3.1 通用配置

经过调研和选型,最终选择了微软出品的 CodePush 作为 React Native 热部署方案。

CodePush 是提供给 React Native 开发者直接部署移动应用更新给用户设备的云服务。CodePush 作为一个中央仓库,开发者可以推送更新 (JS, HTML, CSS and images),应用可以从客户端 SDK 里面查询更新。CodePush 可以让应用有更多的可确定性,也可以让你直接接触用户群。在修复一些小问题和添加新特性的时候,不需要经过二进制打包,可以直接推送代码进行实时更新。

CodePush 可以进行实时的推送代码更新:

  • 直接对用户部署代码更新
  • 管理 AlphaBeta 和生产环境应用
  • 支持 JavaScript 文件与图片资源的更新
  • 暂不支持增量更新

CodePush 开源了 react-native 版本,react-native-code-push托管在 GitHub 上。

具体的教程和用法微软都在 Github上 做了详细说明,接下来简单地梳理一下从配置、编码、部署等具体流程。

(1) 安装 CodePush CLI

管理 CodePush 账号需要通过 NodeJS-based CLI
只需要在终端输入 npm install -g code-push-cli ,就可以安装了。
安装完毕后,输入 code-push -v 查看版本,如看到版本代表成功。

(2) 创建一个 CodePush 账号
在终端输入 code-push register ,会打开如下注册页面让你选择授权账号。

CodePush 注册

授权通过之后,CodePush 会告诉你“access key”,复制此 key 到终端即可完成注册。

然后终端输入 code-push login 进行登陆,登陆成功后,你的 session 文件将会写在 /Users/ 你的用户名 /.code-push.config

(3) 在 CodePush 服务器注册 app
为了让 CodePush 服务器知道你的 app,我们需要向它注册 app: 在终端输入 code-push app add <appName> 即可完成注册。

例如:

code-push app add shangjiaban-android

如果是 iOS 平台,命令为 code-push app add shangjiaban-iosAndroidiOS 必须要区分

还有很多 code-push app 相关的命令,参考如下:

$: code-push app help
Usage: code-push app <command>

命令:
  add       Add a new app to your account
  remove    Remove an app from your account
  rm        Remove an app from your account
  rename    Rename an existing app
  list      Lists the apps associated with your account
  ls        Lists the apps associated with your account
  transfer  Transfer the ownership of an app to another account

3.2 Android 配置

(1) 集成 CodePush SDK
第一步:在项目中安装 react-native 插件,终端进入你的项目根目录然后运行。

npm install --save react-native-code-push

CodePush提供了两种方式:RNPMManual ,接下来重点讲述 Manual 这种方式。
android/settings.gradle 中添加如下配置

include ':app', ':react-native-code-push'
project(':react-native-code-push').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-code-push/android/app')

android/app/build.gradle 文件中添加如下的配置:

...
apply from: "../node_modules/react-native-code-push/android/codepush.gradle"

...
dependencies {
    ...
    compile project(':react-native-code-push')
}

(2) 配置 Application 文件

import com.microsoft.codepush.react.CodePush;

public class MainApplication extends Application implements ReactApplication {

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        ...
        // 1. 覆写getJSBundleFile 方法
        @Override
        protected String getJSBundleFile() {
            return CodePush.getJSBundleFile();
        }

        @Override
        protected List<ReactPackage> getPackages() {
            // 2. 添加CodePush 相关的Package,初始化参数需要包含deploymentKey,
            // 可以通过运行“code-push deployment ls <appName> -k”获得
            return Arrays.<ReactPackage>asList(
                new MainReactPackage(),
                new CodePush("deployment-key", MainApplication.this, BuildConfig.DEBUG)
            );
        }
    };
}

其中 deployment-key 可以通过如下命令获得。

3.3 iOS 配置

iOS 平台上关于 CodePush 的配置和 Android 平台是类似的,可以参考上文的 (1)(2)(3),iOS 平台集成 CodePush 比较简单,官网提供了 3 种集成方式,这里重点介绍如何通过 cocoapods 来集成。

(1) 引入 CodePush

首先在 Podfile 文件中添加 CodePush,配置如下:

pod 'CodePush', :path => './node_modules/react-native-code-push'

然后执行 pod install 就可以了。

(2) 声明 bundle 文件来源

引入 CodePush 后还需要在代码中声明 bundle 的加载来源,之前是加载本地的bundle 文件,现在需要调用 CodePush 提供的方法指定加载 Bundle 文件,代码如下:

#import "CodePush.h"
...
// 原来的bundle加载方法
jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];

// CodePush的bundle加载方法,这里的bundleURL默认加载的是main.jsbundle,
// 如果你的名称不一样,需要调用CodePush提供的其他方法来自定义。
jsCodeLocation = [CodePush bundleURL];

最后还需要在 Info.plist 中添加一个 keyCodePushDeploymentKey , 其value 就是 CodePush 提供的唯一 token 值。具体获取方法,可以通过如下命令获得

code-push deployment ls <appName> -k

3.4 React Native 配置

为了达到更好的体验效果,我们决定采用静默升级的策略,让用户无感知地体验热更新,也可以是具体的升级流程图如下:

如果要达成上述热部署效果,那么还需要在 JavaScript 文件中完成更新时机和更新策略的设置。

(1) 在 js 中导入 CodePush 模块:

import codePush from 'react-native-code-push'

(2) 在 componentDidMount 中调用 sync 方法,后台请求更新

codePush.sync()

如果是非强制并允许更新, CodePush 会在后台静默地将更新下载到本地,等待 APP 再一次启动或者加载 React Native 页面的时候更新应用。

如果更新是强制性的,更新文件下载好之后会立即进行更新。关于如何配置是否强制更新,会在下文发布更新处重点说明。

如果你期望更及时的获得更新,可以在每次 APP 从后台进入前台的时候去主动的检查更新:

AppState.addEventListener("change", (newState) => {
        newState === "active" && codePush.sync({
            installMode:codePush.InstallMode.ON_NEXT_RESUME,
            deploymentKey: DEPLOYMENT_KEY,
          });
    });

上述流程图提及的三种更新方式,就是通过 installMode 参数控制的,取值方式分别为:

  1. codePush.InstallMode.ON_NEXT_RESTART即下一次启动的时候安装更新
  2. codePush.InstallMode.ON_NEXT_RESUME即下一次切后台切换的时候安装更新
  3. codePush.InstallMode. IMMEDIATE立即下载安装更新

如果发布更新时 mandatory 参数为 true,即强制更新,则上述设置都会无效,只有mandatory 参数为 fasle 时,设置才会有效。

3.5 打包并发布

(1) 打包 js
发布更新之前,需要先把 js 打包成 bundle ,以下是 Android 的做法:

第一步: 在 Android 工程目录里面新增 release 文件: mkdir release ,对于 iOS 来说,目前 bundle 文件直接放在工程根目录下,所以无需这一步。
第二步: 运行命令打包

react-native bundle --platform 平台 --entry-file 启动文件 --bundle-output 打包js输出文件 --assets-dest 资源输出目录 --dev 是否调试。

例如:

Android

react-native bundle --platform android --entry-file index.android.js --bundle-output ./release/index.android.bundle --assets-dest ./release --dev false

iOS

react-native bundle --platform ios --entry-file index.ios.js --bundle-output ./Koudaitong/main.jsbundle --assets-dest ./Koudaitong --dev false

(2) 发布更新

打包 bundle 结束后,就可以通过 CodePush 发布更新了。在终端输入

code-push release <应用名称> <Bundles所在目录> <对应的应用版本> --deploymentName: 更新环境 
--description: 更新描述 --mandatory: 是否强制更新 

例如:

Android

code-push release shangjiaban-android ./release 3.12.1 --description "update React Native" --mandatory true

iOS

code-push release shangjiaban-ios ./Koudaitong/main.jsbundle 3.12.0 --description "update React Native" --mandatory false

注意:

  1. CodePush 默认是更新 Staging 环境的,如果是 Staging ,则不需要填写 deploymentName

  2. 如果有 mandatoryCode Push 会根据 mandatorytruefalse 来控制应用是否强制更新。默认情况下 mandatoryfalse 即不强制更新。

  3. 对应的应用版本 targetBinaryVersion 是指当前 app 的版本 ( 对应 build.gradle 中设置的 versionName “3.12.1”),也就是说此次更新的 js/images 对应的是 app 的那个版本。不要将其理解为这次 js 更新的版本。 如客户端版本是 3.12.1,那么我们对 3.12.1 的客户端更新 js/imagestargetBinaryVersion 填的就是 3.12.1。

  4. 对于对某个应用版本进行多次更新的情况, CodePush 会检查每次上传的 bundle ,如果在该版本下如 3.12.1 已经存在与这次上传完全一样的 bundle ( 对应一个版本有两个 bundlemd5 完全一样 ),那么 CodePush 会拒绝此次更新。