《成为大前端》进阶 - 5. ReactNative整合

1,132 阅读29分钟

概述

Hybrid开发除了H5技术还有如FaceBook的ReactNative,阿里的Weex等等,甚至后来的Flutter。

本章主要讲如何将ReactNative整合进项目中,并将利用之前写过的很多JSBridge功能给RN提供Native接口调用,一举两得。

创建 ReactNative 项目

目前的目录结构:

android/
ios/
web/

我们创建一个react-native目录

android/
ios/
web/
react-native/

在 react-native 下创建 IntegrationProject

再创建一个 package.json 文件,内容为:

{
  "name": "integration-project",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "react-native start"
  },
  "dependencies": {
    "react": "16.9.0",
    "react-native": "0.61.5"
  }
}

也就是说我们将使用 react-native@0.61.5react@19.9.0 做整合,版本声明为不可变为好

cd react-native/IntegrationProject
npm install

在 react-native/IntegrationProject 下创建 index.js

import React from "react";
import { AppRegistry, StyleSheet, Text, View } from "react-native";

class HelloWorld extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.hello}>Hello, World 1</Text>
      </View>
    );
  }
}
var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center"
  },
  hello: {
    fontSize: 20,
    textAlign: "center",
    margin: 10
  }
});

AppRegistry.registerComponent("IntegrationProject", () => HelloWorld);

注意我们这里 registerComponent 使用了 IntegrationProject 为名,这个是一个关键点,后续都会陆续提到这个点

npm run start

如果启动正常项目就创建好了

整合到 Android 中

配置添加依赖

在 build.gradle (Project: ToBeBigFE) 中,添加:

allprojects {
    repositories {
        // 指向我们的RN android源码
        maven { url "$rootDir/../react-native/IntegrationProject/node_modules/react-native/android" }
        ...
    }
}

在 build.gradle (Project: app) 中,添加依赖:

implementation "com.facebook.react:react-native:+"
def hermesPath = "$rootDir/../react-native/IntegrationProject/node_modules/hermes-engine/android/"
debugImplementation files(hermesPath + "hermes-debug.aar")
releaseImplementation files(hermesPath + "hermes-release.aar")

注意实际项目中,上面的路径不一定是这样的,根据具体你公司的 android 项目和 react 项目的相对路径来配置

然后点右上角的 Sync Now

MyApplication 初始化 SoLoader

MyApplication 中添加一行代码:

class MyApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        SoLoader.init(this, false) // 这行,代表加载react的so库
        WebManager.init(this)
    }
}

ReactNativeActivity

增加一个包react,并在里面增加ReactNativeActivity.kt文件:

package com.example.tobebigfe.react

import android.app.Activity
import com.facebook.react.common.LifecycleState
import com.facebook.react.shell.MainReactPackage
import com.facebook.react.ReactInstanceManager
import com.facebook.react.ReactRootView
import android.os.Bundle
import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler
import android.view.KeyEvent
import com.example.tobebigfe.BuildConfig
import android.view.KeyEvent.KEYCODE_MENU
import android.content.Intent


class ReactNativeActivity : Activity(), DefaultHardwareBackBtnHandler {

    private var mReactRootView: ReactRootView? = null
    private var mReactInstanceManager: ReactInstanceManager? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        mReactRootView = ReactRootView(this)
        mReactInstanceManager = ReactInstanceManager.builder()
            .setApplication(application)
            .setCurrentActivity(this)
            .setBundleAssetName("index.android.bundle")
            .setJSMainModulePath("index")
            .addPackage(MainReactPackage())
            .setUseDeveloperSupport(BuildConfig.DEBUG)
            .setInitialLifecycleState(LifecycleState.RESUMED)
            .build()
        // The string here (e.g. "MyReactNativeApp") has to match
        // the string in AppRegistry.registerComponent() in index.js
        mReactRootView!!.startReactApplication(mReactInstanceManager, "IntegrationProject", null)

        setContentView(mReactRootView)
    }

    override fun invokeDefaultOnBackPressed() {
        super.onBackPressed()
    }

    override fun onPause() {
        super.onPause()
        mReactInstanceManager?.onHostPause(this)
    }

    override fun onResume() {
        super.onResume()
        mReactInstanceManager?.onHostResume(this, this)
    }

    override fun onDestroy() {
        super.onDestroy()
        mReactInstanceManager?.onHostDestroy(this)
        mReactRootView?.unmountReactApplication()
    }

    override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
        if (keyCode == KEYCODE_MENU && mReactInstanceManager != null) {
            mReactInstanceManager!!.showDevOptionsDialog()
            return true
        }
        return super.onKeyUp(keyCode, event)
    }

    override fun onBackPressed() {
        if (mReactInstanceManager != null) {
            mReactInstanceManager!!.onBackPressed()
        } else {
            super.onBackPressed()
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
        mReactInstanceManager?.onActivityResult(this, requestCode, resultCode, data)
    }
}

注意这行代码:

mReactRootView!!.startReactApplication(mReactInstanceManager, "IntegrationProject", null)

我们使用到了 IntegrationProject,与前面对应

配置 AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.tobebigfe">

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

    ...
    <application ...>
        <!-- 添加 react 调试 activity -->
        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
        <!-- 添加 ReactNativeActivity -->
        <activity
            android:name=".react.ReactNativeActivity"
            android:label="@string/app_name"
            android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>

    </application>
</manifest>

MainActivity

为了方便测试,我们在 MainActivity 的 onCreate 最后一行添加代码

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    startActivity(Intent(this, ReactNativeActivity::class.java))
}

这样 App 一打开就会调转到 RN 的界面

运行 App

不出意外,应该是如下的界面,如果是模拟器,不会出现这个问题:

这是因为开发环境下,ReactNativeActivity 打开的 url 是 http://localhost:8081

我们需要 adb 来配置一下,做一个 IP 映射,没关系,不用理解

adb 配置和 IP 映射

如果在命令行下运行这个:

adb

是能使用这个命令的,可以跳过下面这步,配置 adb:

vim ~/.lbash_profile

增加一行:

export PATH="$PATH:/Users/mingo/Library/Android/sdk/platform-tools"

接前面,退出后:

source ~/.bash_profile

注意,/Users/mingo/Library/Android/sdk/platform-tools 这个路径和你安装的 Android SDK 路径有关, 如果你的 AndroidStudio 没什么bug,可以从这里找:

前面搞定后:

$ adb devices
List of devices attached
2KE0219B23007696	device

$ adb reverse tcp:8081 tcp:8081
8081
mingodeiMac:~ mingo$

再次运行:

到这里搞定了 Android 的整合

整合到 iOS 中

使用 CocoaPods 管理依赖

在这章之后,我们需要是 CocoaPods 管理我们的依赖,当然之前用 Swift Package Manager 管理的不需要改动,我们 可以混用两个依赖管理。

安装 CocoaPods, 安装方法有很多,我们这里使用 gem

sudo gem install cocoapods

ios下创建podfile, 内容如下:

source 'https://github.com/CocoaPods/Specs.git'

# Required for Swift apps
platform :ios, '9.0'
use_frameworks!

# The target name is most likely the name of your project.
target 'ToBeBigFE' do

    react_native_path = "../react-native/IntegrationProject/node_modules/react-native"
    pod 'FBLazyVector', :path => "#{react_native_path}/Libraries/FBLazyVector"
    pod 'FBReactNativeSpec', :path => "#{react_native_path}/Libraries/FBReactNativeSpec"
    pod 'RCTRequired', :path => "#{react_native_path}/Libraries/RCTRequired"
    pod 'RCTTypeSafety', :path => "#{react_native_path}/Libraries/TypeSafety"
    pod 'React', :path => "#{react_native_path}"
    pod 'React-Core', :path => "#{react_native_path}"
    pod 'React-Core/DevSupport', :path => "#{react_native_path}"
    pod 'React-CoreModules', :path => "#{react_native_path}/React/CoreModules"
    pod 'React-RCTActionSheet', :path => "#{react_native_path}/Libraries/ActionSheetIOS"
    pod 'React-RCTAnimation', :path => "#{react_native_path}/Libraries/NativeAnimation"
    pod 'React-RCTBlob', :path => "#{react_native_path}/Libraries/Blob"
    pod 'React-RCTImage', :path => "#{react_native_path}/Libraries/Image"
    pod 'React-RCTLinking', :path => "#{react_native_path}/Libraries/LinkingIOS"
    pod 'React-RCTNetwork', :path => "#{react_native_path}/Libraries/Network"
    pod 'React-RCTSettings', :path => "#{react_native_path}/Libraries/Settings"
    pod 'React-RCTText', :path => "#{react_native_path}/Libraries/Text"
    pod 'React-RCTVibration', :path => "#{react_native_path}/Libraries/Vibration"
    pod 'React-Core/RCTWebSocket', :path => "#{react_native_path}"

    pod 'React-cxxreact', :path => "#{react_native_path}/ReactCommon/cxxreact"
    pod 'React-jsi', :path => "#{react_native_path}/ReactCommon/jsi"
    pod 'React-jsiexecutor', :path => "#{react_native_path}/ReactCommon/jsiexecutor"
    pod 'React-jsinspector', :path => "#{react_native_path}/ReactCommon/jsinspector"
    pod 'ReactCommon/jscallinvoker', :path => "#{react_native_path}/ReactCommon"
    pod 'ReactCommon/turbomodule/core', :path => "#{react_native_path}/ReactCommon"

    pod 'Yoga', :path => "#{react_native_path}/ReactCommon/yoga"

    pod 'DoubleConversion', :podspec => "#{react_native_path}/third-party-podspecs/DoubleConversion.podspec"
    pod 'glog', :podspec => "#{react_native_path}/third-party-podspecs/glog.podspec"
    pod 'Folly', :podspec => "#{react_native_path}/third-party-podspecs/Folly.podspec"

end

上面内容声明了我们如何引入 ReactNative 的

 安装依赖

cd ios
pod install

安装成功后,结构是这样的:

这时候,我们关掉 XCode,双击 ToBeBigFE.xcworkspace,来打开项目,打开后的 XCode:

ReactNativeController

增加一个 Group React,并在里面增加 ReactNativeController.swift 文件,内容如下:

import Foundation
import React

class ReactNativeController : UIViewController {

    override func loadView() {
        let jsCodeURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")!
        let rootView = RCTRootView(
            bundleURL: jsCodeURL,
            moduleName: "IntegrationProject",
            initialProperties: [:],
            launchOptions: nil
        )
        self.view = rootView
    }

}

你可以通过跳整 jsCodeURL 的路径,用于真机是可以设置localhost为你电脑的ip

为了方便查看整合结果,我们在 ViewController 的 viewDidLoad 加一行代码:

func viewDidLoad() {
    ...
    self.present(ReactNativeController(), animated: true, completion: nil)
}

这样,一打开App就会跳转到 ReactNative 的界面

运行结果


到这里就整合完毕

与开发环境整合(Android)

前面一节我们通过 setUseDeveloperSupport 方法开启了 ReactNative

mReactInstanceManager = ReactInstanceManager.builder()
    ...
    .setUseDeveloperSupport(BuildConfig.DEBUG)
    ...
    .build()

但是这个方式对host``port不可控

这节我们主要整合我们之前的开发环境设置页,提供可配置的开会环境设置

开发环境设置支持 ReactNative

res/xml/dev_preferences.xml

增加一个Preference项:

<ListPreference
    app:key="webDev.type"
    app:title="项目类型"
    app:entries="@array/web_dev_types"
    app:entryValues="@array/web_dev_types"
    app:useSimpleSummaryProvider="true"
    />

@array/web_dev_types 报红,需要定义一下

res/values/arrays.xml

增加文件res/values/arrays.xml,内容:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="web_dev_types">
        <item>WebView</item>
        <item>ReactNative</item>
    </string-array>
</resources>

MainActivity.kt 改动

在顶部ActionBar增加了一个menu项,点击后可以去 React 界面

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    if (BuildConfig.DEBUG) {
        shakeSensor = ShakeSensor(this)
        shakeSensor.shakeListener = this
        shakeSensor.register()
    }
}

override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menu.add("打开React")
    return super.onCreateOptionsMenu(menu)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    if (item.title == "打开React") {
        val intent = Intent(this, ReactNativeActivity::class.java)
        intent.putExtra("project", "IntegrationProject")
        startActivity(intent)
    }
    return super.onOptionsItemSelected(item)
}

运行看看开发环境

去首页 摇一摇 进入开发环境设置

接下来是如何使用开发环境的设置项到 ReactNative 中

WebManager 改动

增加2个方法用于 ReactNativeActivity

object WebManager {

    ...


    fun isDebugReactProject(id: String): Boolean {
        return isDebugProject(id) && preferences.isReactNativeProject()
    }

    fun getWebDevServer(): String {
        return preferences.webDevServer() ?: "localhost"
    }

    ...

}

....

// 这里增加了判断开发环境是不是ReactNative类型
private fun SharedPreferences.isReactNativeProject(): Boolean {
    return getString("webDev.type", "WebView") == "ReactNative"
}

ReactNativeActivity 改动

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    mReactRootView = ReactRootView(this)
    val builder = ReactInstanceManager.builder()
        .setApplication(application)
        .setCurrentActivity(this)
        .setBundleAssetName("index.android.bundle")
        .setJSMainModulePath("index")
        .addPackage(MainReactPackage())
        .setInitialLifecycleState(LifecycleState.RESUMED)

    val project = getProject()
    // 如果是ReactNative开发环境
    if (WebManager.isDebugReactProject(project)) {
        // 改变 debug_http_host
        val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
        preferences.edit().putString("debug_http_host", WebManager.getWebDevServer()).apply()

        // 开启dev support
        builder.setUseDeveloperSupport(true)
        mReactInstanceManager = builder.build()
    } else {

        // 关闭dev support
        builder.setUseDeveloperSupport(false)
        mReactInstanceManager = builder.build()
    }

    // The string here (e.g. "MyReactNativeApp") has to match
    // the string in AppRegistry.registerComponent() in index.js
    mReactRootView!!.startReactApplication(mReactInstanceManager, getProject(), null)

    setContentView(mReactRootView)
}

// 增加一个方法,获取intent参数 project
fun getProject(): String {
    return intent.getStringExtra("project") ?: "IntegrationProject"
}

debug_http_host 是 ReactNative 的 DevSupport 模块会通过 DefaultSharedPreferences 获取的 key,这是看源码知道的,官方没有文档。

运行效果

这样完成了开发环境整合

与开发环境整合(iOS)

前面一节我们写死了ReactNative的URL

let jsCodeURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")!

这节我们主要整合我们之前的开发环境设置页,提供可配置的开会环境设置

Web/DevSettingsController 改动

添加一个可以选择项目类型的方式,并且使用 type 作为key存起来

class DevSettingsController ... {

    lazy var typePickerField: OptionPickerFormItem = {
        let instance = OptionPickerFormItem()
        instance.title("项目类型").placeholder("required")
        instance.append([
            "WebView",
            "ReactNative"
        ])
        instance.selectOptionWithTitle(self.settings.string(forKey: "type") ?? "WebView")
        instance.valueDidChange = { (selected: OptionRowModel?) in
            let projectType = selected?.title ?? "WebView"
            self.settings.setValue(projectType, forKey: "type")
        }
        return instance
    }()
    
    override func populate(_ builder: FormBuilder) {
        ...
        builder += typePickerField // 
    }

}

WebManager 改动

func getWebUrl(id: String, page: String) -> String {
    if isDebugProject(id),
        let server = settings.string(forKey: "server"),
        let project = settings.string(forKey: "project")
    {
        // 如果是 React, 返回 React 的开发链接
        if settings.string(forKey: "type") == "ReactNative" {
            return "http://\(server)/\(page)?platform=ios"
        }
        return "http://\(server)/\(project)/dev/\(page)"
    }
    ...
}

ViewController 改动

改成右上角的按钮打开React界面,这样可以在首页摇一摇弹出开发环境页

class ViewController : WebViewController {
    
    override func viewDidLoad() {
        project = "home"
        super.viewDidLoad()
        
        // self.present(ReactNativeController(), animated: true, completion: nil)
        let reactBtn = UIBarButtonItem(title: "打开React", style: .plain, target: self, action: #selector(onClickReact))
        navigationItem.rightBarButtonItem = reactBtn
    }
    
    @objc private func onClickReact() {
        self.present(ReactNativeController(project: "IntegrationProject"), animated: true, completion: nil)
    }

}

摇一摇配置好

模拟器:Hardware -> Shake Gesture

点击项目类型,选择ReactNative

回到首页,点击右上角的打开React

这样完成了开发环境整合

项目 "todolist" 开发

这次我们使用 ReactNative 开发一个简单的项目,和 WebView 项目进行一些小配合

项目 home 提供 todolist 入口

我们加入入口,并提供到版本2,发布一下home项目

package.json

  "deploy": {
    "version": "2"
  }

App.vue

添加一个入口:

<div class="item todolist" @click="onClickTodoList">TodoList</div>

跳转到 TodoList

onClickTodoList() {
    JSBridge.Navigation.open({
        id: 'todolist',
        type: 'ReactNative',
        page: 'index.bundle'
    })
}

这里我们为 Navigation.open 添加 type 参数,不填是 WebView,这里我们填写 ReactNative, 那么 Native 需要支持跳转到 ReactNative 项目,这个稍后会讲

发布

这里我改了书的源码目录结构,使用我在build_and_copy.shgenerateManifest.js中调整了一下,减少了一个../

target="../../../book-to-be-big-fe-deploy/"
let manifestDeployPath = path.join(__dirname, "../../../book-to-be-big-fe-deploy/manifest.json")

运行,构建并 copy 到 book-to-be-big-fe-deploy

cd web/deploy
sh build_and_copy.sh

!!! 注意:提交 git 后,一定要去 gitee 发布

运行

运行打开2到N次首页之后,会更新到 zip 包,页面会变成:

图中是 iOS 的效果,Android也一样。并且点击会崩溃,因为我们还没有 todolist 的业务项目

创建 TodoList 项目

复制 IntegrationProject 一份,改名为 todolist,以及 packages.json 中的 name 为 todolist

index.js 改动

改为 TodoList 的功能

import React, { Component } from "react";
import {
  AppRegistry,
  StyleSheet,
  Text,
  View,
  FlatList,
  AsyncStorage,
  Button,
  TextInput,
  Keyboard,
  Platform
} from "react-native";

const isAndroid = Platform.OS == "android";
const viewPadding = 10;

export default class TodoList extends Component {
  state = {
    tasks: [],
    text: ""
  };

  changeTextHandler = text => {
    this.setState({ text: text });
  };

  addTask = () => {
    let notEmpty = this.state.text.trim().length > 0;

    if (notEmpty) {
      this.setState(
        prevState => {
          let { tasks, text } = prevState;
          return {
            tasks: tasks.concat({ key: tasks.length, text: text }),
            text: ""
          };
        },
        () => Tasks.save(this.state.tasks)
      );
    }
  };

  deleteTask = i => {
    this.setState(
      prevState => {
        let tasks = prevState.tasks.slice();

        tasks.splice(i, 1);

        return { tasks: tasks };
      },
      () => Tasks.save(this.state.tasks)
    );
  };

  componentDidMount() {
    Keyboard.addListener(
      isAndroid ? "keyboardDidShow" : "keyboardWillShow",
      e => this.setState({ viewPadding: e.endCoordinates.height + viewPadding })
    );

    Keyboard.addListener(
      isAndroid ? "keyboardDidHide" : "keyboardWillHide",
      () => this.setState({ viewPadding: viewPadding })
    );

    Tasks.all(tasks => this.setState({ tasks: tasks || [] }));
  }

  render() {
    return (
      <View
        style={[styles.container, { paddingBottom: this.state.viewPadding }]}
      >
        <TextInput
          style={styles.textInput}
          onChangeText={this.changeTextHandler}
          onSubmitEditing={this.addTask}
          value={this.state.text}
          placeholder="Add Tasks"
          returnKeyType="done"
          returnKeyLabel="done"
        />
        <FlatList
          style={styles.list}
          data={this.state.tasks}
          renderItem={({ item, index }) =>
            <View>
              <View style={styles.listItemCont}>
                <Text style={styles.listItem}>
                  {item.text}
                </Text>
                <Button title="X" onPress={() => this.deleteTask(index)} />
              </View>
              <View style={styles.hr} />
            </View>}
        />
      </View>
    );
  }
}

let Tasks = {
  convertToArrayOfObject(tasks, callback) {
    return callback(
      tasks ? tasks.split("||").map((task, i) => ({ key: i, text: task })) : []
    );
  },
  convertToStringWithSeparators(tasks) {
    return tasks.map(task => task.text).join("||");
  },
  all(callback) {
    return AsyncStorage.getItem("TASKS", (err, tasks) =>
      this.convertToArrayOfObject(tasks, callback)
    );
  },
  save(tasks) {
    AsyncStorage.setItem("TASKS", this.convertToStringWithSeparators(tasks));
  }
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "#F5FCFF",
    padding: viewPadding,
    paddingTop: 20
  },
  list: {
    width: "100%"
  },
  listItem: {
    paddingTop: 2,
    paddingBottom: 2,
    fontSize: 18
  },
  hr: {
    height: 1,
    backgroundColor: "gray"
  },
  listItemCont: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between"
  },
  textInput: {
    height: 40,
    paddingRight: 10,
    paddingLeft: 10,
    borderColor: "gray",
    borderWidth: isAndroid ? 0 : 1,
    width: "100%"
  }
});

AppRegistry.registerComponent("todolist", () => TodoList);

运行开发服务器

npm run start

iOS

JSBridgeNavigation 改动

支持打开 type=ReactNative 的项目

private func open(callbackId: String, arg: [String : Any?]) {
    guard let vc = self.viewController else { return }
    let type = arg["type"] as? String ?? "WebView"
    if type == "WebView" {
        guard let url = parseWebUrl(arg) else { return }
        let newVC = WebViewController()
        newVC.url = url
        newVC.project = arg["id"] as? String
        if let params = arg["params"] as? [String:Any?] {
            newVC.params = params
        }
        vc.present(newVC, animated: true, completion: nil)
    } else if (type == "ReactNative") {
        let project = arg["id"] as! String
        let rectVC = ReactNativeController(project: project)
        vc.present(rectVC, animated: true, completion: nil)
    } else {
        vc.view.makeToast("Invalid type: \(type)")
    }
}

这是运行还是会奔溃,奔溃在 WebManager.getWebUrl 中,因为 TodoList 在我们的 manifest.json 还没有,业务还没有正式上线

配置为开发中的项目


最终效果


Android

JSBridgeNavigation 改动

private fun open(callbackId: String, arg: JSONObject) {
    val type = if (arg.has("type")) {
        arg.getString("type")
    } else {
        "WebView"
    }

    if (type == "WebView") {
        val intent = Intent(activity, WebActivity::class.java)
        // 如果前端传递了id参数,说明使用项目名跳转方式
        if (arg.has("id")) {
            val id = arg.getString("id")
            val page = arg.getString("page")
            val url = WebManager.getWebUrl(id, page)
            intent.putExtra("url", url)
            intent.putExtra("project", id)
        }
        // 否则有url参数的情况
        else if (arg.has("url")) {
            intent.putExtra("url", arg.getString("url"))
        }

        if (arg.has("params")) {
            val params = arg.get("params") as JSONObject?
            params?.let {
                intent.putExtra("params", it.toString())
            }
        }

        activity.startActivity(intent)
    }

    else if (type == "ReactNative") {
        val intent = Intent(activity, ReactNativeActivity::class.java)
        val id = arg.getString("id")
        intent.putExtra("project", id)
        activity.startActivity(intent)
    }
}

这是运行还是会奔溃,奔溃在 WebManager.getWebUrl 中,因为 todolist 在我们的 manifest.json 还没有,业务还没有正式上线

配置为开发中的项目


最终效果


结语

我们开发了 todolist 项目,并利用 Navigation 模块跳转到 ReactNative 项目。后面我们还会再解决2个重要问题:

  • ReactNative 使用现有的 JSBridge
  • ReactNative 项目离线包

离线包发布

接下来讲如何将 ReactNative 发布成离线包。

由于有了我们之前的基础,我们这里只需要将 ReactNative 项目发布成规范的 zip 包即可。

以下以 todolist 为例

构建 ReactNative 项目

来看看官方提供的构建命令,以 android 举例

react-native bundle --platform android --dev false --entry-file index.js --bundle-output dist/index.android.js

可以看出,由于系统不一样,生成的最终产物也不一样,那么离线包的差异也会不同,因此我们需要解决这种情况,我们需要生成不同的zip包,部署到不同的路径

先说目标,我们希望 build 和 pack 完之后的结构:

dist/todolist/[version]/
  android/
    index.js
    todolist.zip
  ios/
    index.js
    todolist.zp

同样也按这样的结构部署,因此我们需要修改构建和部署流程,兼容 ReactNative 的情况

todolist/package.json

...
"scripts": {
    "start": "react-native start",
    "build": "npm run build-android && npm run build-ios",
    "build-android": "react-native bundle --platform android --dev false --entry-file index.js --bundle-output dist/android/index.js --assets-dest dist/android",
    "build-ios": "react-native bundle --platform ios --dev false --entry-file index.js --bundle-output dist/ios/index.js --assets-dest dist/ios"
},
...

npm run build 构建出来的目录是:

dist/
  android/
    index.js
  ios/
    index.js

完成构建,下一步的以希望的目录方式打包

deploy

在完成打包前,我们调整一下deploy的目录位置,因为我 deploy 将为 webreact-native 共同服务,而不是单单web, 所以我们把deployweb中提出来,放到和webreact-native并列的地方:

android/
ios/
web/
react-native/
deploy/
    ...

然后一步步写兼容react-native打包的代码

build_and_copy.sh

#!/bin/sh

target="../../../book-to-be-big-fe-deploy/"

## web 项目
for name in "home" "news" "weather"
do
    if [ "$1" == "$name" ] || [ "$1" == "" ] ; then
        echo building project $name
        cd ../web/$name
        rm -R dist
        npm run build
        node ../../deploy/pack.js
        cp -r -f ./dist/ $target
        cd ../../deploy
    fi
done

## react-native 项目
for name in 'todolist'
do
    if [ "$1" == "$name" ] || [ "$1" == "" ] ; then
        echo building project $name
        ## 进入
        cd ../react-native/$name
        ## clean
        rm -R dist
        ## 构建dist目录结构,两个系统
        mkdir dist
        mkdir dist/android
        mkdir dist/ios
        ## build
        npm run build
        ## pack 传递了 ReactNative 参数,看 pack.js 了解
        node ../../deploy/pack.js ReactNative
        ## copy
        cp -r -f ./dist/ $target
        ## 推出目录
        cd ../../deploy
    fi
done

node generateManifest.js

pack.js

const path = require("path");
const { execSync } = require("child_process");

// 获取 type 参数
const type = process.argv[2] || "WebView"
const prjPath = process.cwd();
const packageJSON = require(path.join(prjPath, "package.json"));
const name = packageJSON.name;
const version = packageJSON.deploy.version;

// web 项目
if (type === "WebView") {
  execSync('zip -q -r ' + name + '.zip *', {
      cwd: path.join(prjPath, "dist", name, version),
  })
} 
// react-native 项目
else if (type === "ReactNative") {
  const distCwd = path.join(prjPath, 'dist')

  // 构建部署的目录结构
  execSync('mkdir ' + name, { cwd: distCwd })
  execSync('mkdir ' + name + '/' + version, { cwd: distCwd })

  // 移动 build 好的到部署目录
  execSync('mv android ' + name + '/' + version, { cwd: distCwd })
  execSync('mv ios ' + name + '/' + version, { cwd: distCwd })

  // 打zip包
  execSync('zip -q -r ' + name + '.zip *', { 
      cwd: path.join(prjPath, 'dist/' + name + '/' + version + '/android')
  })
  execSync('zip -q -r ' + name + '.zip *', {
    cwd: path.join(prjPath, 'dist/' + name + '/' + version + '/ios')
  })
}

console.log('pack ok: ' + name)

generateManifest.js

支持了 react-native 的项目,同时为每个项目添加 type 参数,值是 WebView 或者 ReactNative,给 Native 代码判断项目类型使用

const path = require("path")
const fs = require('fs')

const webPrjList = ['home', 'news', 'weather']
const reactPrjList = ['todolist']<i>
const manifest = {
    projects: []
}

webPrjList.forEach(prj => {
    let version = require(path.join(__dirname, '../web', prj, 'package.json')).deploy.version
    manifest.projects.push({
        name: prj,
        type: 'WebView',
        version: version
    })
})

reactPrjList.forEach(prj => {
    let version = require(path.join(__dirname, '../react-native', prj, 'package.json')).deploy.version
    manifest.projects.push({
        name: prj,
        type: 'ReactNative',
        version: version
    })
})

let manifestDeployPath = path.join(__dirname, "../../book-to-be-big-fe-deploy/manifest.json")
fs.writeFileSync(manifestDeployPath, JSON.stringify(manifest, null, 2))

运行部署脚本

sh build_and_copy.sh

部署到 book-to-big-fe-deploy 的内容:

manifest.json 的内容:

{
  "projects": [
    {
      "name": "home",
      "type": "WebView",
      "version": "2"
    },
    {
      "name": "news",
      "type": "WebView",
      "version": "1"
    },
    {
      "name": "weather",
      "type": "WebView",
      "version": "1"
    },
    {
      "name": "todolist",
      "type": "ReactNative",
      "version": "1"
    }
  ]
}

到这里就完成了构建打包部署,当然还是手动提交git,部署gitee pages

支持 ReactNative 离线包 (Android)

这篇即上篇部署完 ReactNative 离线包之后,我们 Native 也要支持一下

重新将最新的离线包复制的 Assets 中

删除 Assets 中的内容,将上面的新内容拖到 Assets 中,注意不用拷贝 ios 的 todolist

VersionManager

首先我们需要支持 manifest.json 的 type

class VersionManager {
    ....
    // 增加一个 map
    private val typeMap = mutableMapOf<String, String>()

    ...

    // 增加 getType 方法
    fun getType(id: String): String {
        return typeMap[id] ?: "WebView"
    }

    ...

    private fun parseManifest(content: String, output: MutableMap<String, String>) {
        val manifest = JSONObject(content)
        val projects = manifest.getJSONArray("projects")
        for (i in 0 until projects.length()) {
            val prj = projects.getJSONObject(i)

            // 以下增加 type 到 map 
            val name = prj.getString("name")
            output[name] = prj.getString("version")
            // 兼容以下过去 preferences 中没有 type 的情况
            typeMap[name] = if (prj.has("type")) {
                prj.getString("type")
            } else {
                "WebView"
            }
        }
    }
}

ZipManager

首先是增加构造函数,需要传入 versionManager

class ZipManager(..., val versionManager: VersionManager) {
    ...
}

然后是 updateOfflineZip 支持下载 android 的 ReactNative 离线包,下载的链接和 web 的有差异

// 原代码
val zipUrl = "${WebConst.WEB_BASE_URL}$id/$version/$id.zip"

// 现代码
val type = versionManager.getType(id)
val zipUrl = if (type == "ReactNative") {
    "${WebConst.WEB_BASE_URL}$id/$version/android/$id.zip" // 指向 android 的位置
} else {
    "${WebConst.WEB_BASE_URL}$id/$version/$id.zip"
}

WebManager

创建 ZipManager 时传递 versionManager

fun init(context: Context) {
    ...
    zipManager = ZipManager(context, httpClient, versionManager)
}

然后是 getWebUrl 支持 ReactNative 的,获取本地的 js 文件,因为无需返回 http 的链接

// 最后一行,原代码
return "${WebConst.WEB_BASE_URL}$id/$version/$page"

// 现代码
val type = versionManager.getType(id)
return if (type == "ReactNative") {
    val jsFileName = page.replace(".bundle", ".js")
    val jsFile = getOfflineFile(id, version, jsFileName)
    jsFile.absolutePath
} else {
    "${WebConst.WEB_BASE_URL}$id/$version/$page"
}

到这里就完成了 android 支持离线包了。

可以关闭开发环境,运行测试下,运行会发现 ReactNative 相对 web 来说加载要快一些。

支持 ReactNative 离线包 (iOS)

这篇即上篇部署完 ReactNative 离线包之后,我们 Native 也要支持一下

重新将最新的离线包复制的 Assets 中

删除 Assets 中的内容,将上面的新内容拖到 Assets 中,注意不用拷贝 android 的 todolist

VersionManager

首先我们需要支持 manifest.json 的 type

class VersionManager {
    ....
    // 增加一个 dict
    private var typeDict = [String:String]()

    ...

    // 增加 getType
    func getType(id: String) -> String {
        return typeDict[id] ?? "WebView"
    }

    ...

    private func parseManifest(_ data: Data, output: inout [String:String]) throws {
        let json = try JSONSerialization.jsonObject(with: data, options: []) as! [String:Any?]
        let projects = json["projects"] as! [[String:Any?]]
        projects.forEach { item in
            let name = item["name"] as! String
            output[name] = item["version"] as? String
            typeDict[name] = item["type"] as? String ?? "WebView"  // 这里增加这行
        }
    }
}

ZipManager

首先是增加构造函数,需要传入 versionManager

class ZipManager {
    
    private let versionManager: VersionManager

    ...
    
    init(versionManager: VersionManager) {
        self.versionManager = versionManager
        ...
    }

}

然后是 updateOfflineZip 支持下载 ios 的 ReactNative 离线包,下载的链接和 web 的有差异

// 原代码
let zipUrl = "\(WebConst.WEB_BASE_URL)\(id)/\(version)/\(id).zip"

// 现代码
var zipUrl = ""
let type = versionManager.getType(id: id)
if type == "ReactNative" {
    zipUrl = "\(WebConst.WEB_BASE_URL)\(id)/\(version)/ios/\(id).zip"  // 指向 ios 的位置
} else {
    zipUrl = "\(WebConst.WEB_BASE_URL)\(id)/\(version)/\(id).zip"
}

WebManager

创建 ZipManager 时传递 versionManager

class WebManager {
    
    ...

    private let zipManager: ZipManager
    
    init() {
        ...
        self.zipManager = ZipManager(versionManager: self.versionManager)
    }

    ...
}

然后是 getWebUrl 支持 ReactNative 的,获取本地的 js 文件,因为无需返回 http 的链接

// 最后一行,原代码
return "\(WebConst.WEB_BASE_URL)\(id)/\(version!)/\(page)"

// 现代码
let type = versionManager.getType(id: id)
if type == "ReactNative" {
    // index.bundle -> index.js
    // index.bundle: 开发环境的
    // index.js: 发布环境的
    // 实际这里可以统一,个人失误,但问题不大
    let jsFile = page.replacingOccurrences(of: ".bundle", with: ".js")
    return "file://\(getOfflineFile(id: id, version: version!, filePath: page))"
} else {
    return "\(WebConst.WEB_BASE_URL)\(id)/\(version!)/\(page)"
}

到这里就完成了 ios 支持离线包了。

可以关闭开发环境,运行测试下,运行会发现 ReactNative 相对 web 来说加载要快一些。

支持调用 JSBridge 的模块

我们之前写了很多 JSBridge 模块,我们希望将这些模块提供给 ReactNative 那边调用,实际公司的项目也是这样的,本身就积累了很多 Native 代码,所以,把 ReactNative 整合后,最大的一个问题是提供已有功能复用。

巧的是,我们之前的架构非常良好,很容易办到这件事情

JS 端修改

调整 common 目录

首先,我们要修改 jsbridge.js 中的部分代码,兼容在 ReactNative 下调用

修改前,我希望我们整理一下 web/common 的位置,调整到和web react-native 并列的位置,因为,jsbridge.js 已经不单为 web 提供功能了

整理后的目录结构,非常清晰:

android/
ios/
web/
react-native/
deploy/
common/
  jsbridge.js
  package.json

jsbridge.js 修改

我们将 jsbridge.js 分成三个文件:

common/
    jsbridge.js              // 模块定义
    jsbridge-webview.js      // web 项目引用入口,WebView相关的代码
    jsbridge-react-native.js // react-native 项目引用入口,ReactNative相关代码

jsbridge.js:

/* eslint-disable */

// 传入JSBridge,在里面挂载模块
module.exports = function(JSBridge) {
  JSBridge.UI = {};
  JSBridge.UI.toast = function(message) {
    callNative("UI.toast", { message: message });
  };
  JSBridge.UI.alert = function(params) {
    callNative("UI.alert", params);
  };
  JSBridge.UI.confirm = function(params, callback) {
    callNative("UI.confirm", params, callback);
  };

  JSBridge.KVDB = {};
  JSBridge.KVDB.getInt = function(key, callback) {
    callNative("KVDB.getInt", { key: key }, callback);
  };
  JSBridge.KVDB.setInt = function(key, value) {
    callNative("KVDB.setInt", { key: key, value: value });
  };
  JSBridge.KVDB.getBool = function(key, callback) {
    callNative("KVDB.getBool", { key: key }, callback);
  };
  JSBridge.KVDB.setBool = function(key, value) {
    callNative("KVDB.setBool", { key: key, value: value });
  };
  JSBridge.KVDB.getString = function(key, callback) {
    callNative("KVDB.getString", { key: key }, callback);
  };
  JSBridge.KVDB.setString = function(key, value) {
    callNative("KVDB.setString", { key: key, value: value });
  };
  JSBridge.KVDB.getJSON = function(key, callback) {
    callNative("KVDB.getString", { key: key }, function(result) {
      callback(JSON.parse(result));
    });
  };
  JSBridge.KVDB.setJSON = function(key, value) {
    callNative("KVDB.setString", { key: key, value: JSON.stringify(value) });
  };

  JSBridge.Camera = {};
  JSBridge.Camera.takePicture = function(callback) {
    callNative("Camera.takePicture", {}, callback);
  };
  JSBridge.Camera.takeVideo = function(callback) {
    callNative("Camera.takeVideo", {}, callback);
  };
  JSBridge.Image = {};
  JSBridge.Image.pickPhotos = function(callback) {
    callNative("Image.pickPhotos", {}, callback);
  };

  function resolveNavParams(params) {
    params = JSON.parse(JSON.stringify(params));
    if (!params.url) {
      let path = location.pathname;
      if (!path) {
        params.url =
          location.protocol + "//" + location.host + "/" + params.page;
      } else {
        params.url =
          location.protocol +
          "//" +
          location.host +
          path.substr(0, path.lastIndexOf("/") + 1) +
          params.page;
      }
    }
    return params;
  }

  JSBridge.Navigation = {};
  JSBridge.Navigation.open = function(params, callback) {
    callNative("Navigation.open", resolveNavParams(params), callback);
  };
  JSBridge.Navigation.close = function(callback) {
    callNative("Navigation.close", {}, callback);
  };
  JSBridge.Navigation.push = function(params, callback) {
    if (window.androidBridge) {
      JSBridge.Navigation.open(params, callback);
    } else {
      callNative("Navigation.push", resolveNavParams(params), callback);
    }
  };
  JSBridge.Navigation.getParams = function(callback) {
    callNative("Navigation.getParams", {}, callback);
  };
};

jsbridge-webview.js

const initJSBridgeModules = require("./jsbridge");

module.exports = (function(JSBridge) {
  var currentCallbackId = 0;

  function callNative(method, data, callback) {
    // 生成一个唯一callbackId
    var callbackId = "nativeCallback_" + currentCallbackId++;

    if (callback) {
      // 给window添加callback
      window[callbackId] = function(result) {
        delete window[callbackId];
        callback(result);
      };
    }

    var stringData = JSON.stringify(data);
    if (window.androidBridge) {
      // android端传递三个参数
      window.androidBridge.callNative(callbackId, method, stringData);
    } else {
      // iOS不支持多参数,我们传递json对象
      window.webkit.messageHandlers.iOSBridge.postMessage({
        callbackId: callbackId,
        method: method,
        data: stringData
      });
    }
  }

  // 将方法挂到window.JSBridge下
  var JSBridge = (window.JSBridge = {});

  JSBridge.callNative = callNative;
  initJSBridgeModules(JSBridge);

  return JSBridge;
})();

jsbridge-react-native.js, 非常简单,而且 ReactNative 支持 Promise

// 引入 NativeModules
const { NativeModules } = require('react-native')
const initJSBridgeModules = require("./jsbridge");

module.exports = (function() {
  const JSBridge = {}
  JSBridge.callNative = (method, data, callback) => {
    var stringData = JSON.stringify(data);
    // Native定义的 JSBridge.callNative,稍后我们会讲到
    NativeModules.JSBridge.callNative(method, stringData).then(callback)
  }
  initJSBridgeModules(JSBridge);
  return JSBridge;
})();

修复 web 项目中的引用

修复 web 中的三个项目引入问题,在三个项目引用了 jsbridge 的代码中修正引用和路径

-   import JSBridge from '../../common/jsbridge'
+   import JSBridge from '../../../common/jsbridge-webview'

到这里 JS 端完成修改,接下来是 Native 端

支持调用 JSBridge 的模块 (Android)

这篇分析如何支撑 ReactNative 调用 JSBridge 的模块

理解 ReactNative 调用 Native

首先,我们先理解 ReactNative JS 那边如何调用 Native 的,上一节我们讲过:

const { NativeModules } = require("react-native");

NativeModules.JSBridge.callNative(method, stringData).then(callback);

上面是部分jsbridge-react-native.js中的代码,其中:

  • NativeModuels 是 ReactNative 提供我们挂载本地对象的地方
  • JSBridge 是我们 Native 注入到 NativeModuels 中的对象
  • callNative 是 JSBridge 提供的 Native 方法

那么 Native 如何添加JSBridge这个对象呢

::: tip 注意 以下代码不是最终效果,只是理解过程即可,最终代码有一定差异,后面会讲 :::

// 声明一个类,继承 ReactContextBaseJavaModule
class ReactNativeBridge(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
    // 覆盖 getName,这就是我们挂载的名称 JSBridge,
    // 可以随意改,我们这里使用 JSBridge
    override fun getName(): String {
        return "JSBridge"
    }

    // 使用 @ReactMethod 声明 JSBridge.callNative,参数与JS对应,
    // 但最后多了一个promise,表示这是一个 promise 方法
    @ReactMethod
    fun callNative(method: String, arg: String, promise: Promise) {
        Log.e("JSBridge", "%s %s".format(method, arg))
    }
}

// 声明一个类,继承 ReactPackage
class JSBridgePackage() : ReactPackage {

    // 在 createNativeModules 中,把上面要挂载的对象创建出来,
    // 以数组返回,说明可以挂载多个,我们这里只挂载 JSBridge
    override fun createNativeModules(reactContext: ReactApplicationContext): MutableList<NativeModule> {
        val bridge = ReactNativeBridge(reactContext)
        return mutableListOf(bridge)
    }
    override fun createViewManagers(reactContext: ReactApplicationContext): MutableList<ViewManager<View, ReactShadowNode<*>>> {
        return mutableListOf()
    }
}

// 在之前的 ReactNativeActivity 中
class ReactNativeActivity ... {
     override fun onCreate(savedInstanceState: Bundle?) {
         ...
         val builder = ReactInstanceManager.builder()
            .setApplication(application)
            .setCurrentActivity(this)
            .addPackage(MainReactPackage())
            // 添加这行代码
            .addPackage(JSBridgePackage())
            .setInitialLifecycleState(LifecycleState.RESUMED)
     }
}

callNative 的问题分析

之前我们在 WebViewBridge 的 BridgeObject 里,注册了这些模块

bridgeModuleMap["UI"] = JSBridgeUI(activity, webView)
bridgeModuleMap["KVDB"] = JSBridgeKVDB(activity, webView)
bridgeModuleMap["Camera"] = JSBridgeCamera(activity, webView)
bridgeModuleMap["Image"] = JSBridgeImage(activity, webView)
bridgeModuleMap["Navigation"] = JSBridgeNavigation(activity, webView)

我们同理也可以在 ReactNativeBridge 里注册这些模块,但是有一个问题,这些模块 的参数是 WebActivityWebView

对于 ReactNative 来说,没有这两个对象,说明这些模块依赖的具体的通信环境,这些通信 环境就是 WebView 相关的类

因此,我们需要重构这部分

jsbridge 包分化成 jsbridge 和 webview

我们将 jsbridge 分成 2 个部分:

com.example.tobebigfe
    jsbridge
        JSBridgeCamera
        JSBridgeImage
        JSBridgeKVDB
        JSBridgeNavigation
        JSBridgeUI
    webivew
        CustomWebViewClient
        WebActivity
        WebViewBridge

这样 jsbridge 是与具体通信环境无关的代码, webview 是与 WebView 通信相关的代码

::: tip 直接在 Android Studio 新建 webview 包,将文件拖入 webview 下即可,会自动重构引用 :::

解决 WebActivity 依赖

创建一个 BaseActivity 从 WebActivity 提出 JSBridgeXXX 这些模块需要用的:

BaseActivity.kt

BaseActivity 放到和 MainActivity 并列就好

将 WebActivity 的部分代码提取到 BaseActivity,如下:

package com.example.tobebigfe

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity

typealias ActivityResultCallback = (requestCode: Int, resultCode: Int, result: Intent?) -> Boolean

abstract class BaseActivity : AppCompatActivity() {

    private var activityResultCallback: ActivityResultCallback? = null

    fun startActivityForCallback(intent: Intent, requestCode: Int, callback: ActivityResultCallback) {
        activityResultCallback = callback
        startActivityForResult(intent, requestCode)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        activityResultCallback?.let {
            if (it.invoke(requestCode, resultCode, data)) {
                activityResultCallback = null
            }
        }

    }
}

::: tip WebActivity 被移走的代码这里就不说了 :::

重构原来的代码

我们先从上层的接口开始重构

原代码如下:

interface BridgeModule {
    fun callFunc(func: String, callbackId: String, arg: JSONObject)
}

abstract class BridgeModuleBase(val webView: WebView) : BridgeModule {
    ...
}

新代码:

interface BridgeCall {
    val moduleName: String
    val funcName: String
    val arg: JSONObject
    fun callback(value: Int)
    fun callback(value: Boolean)
    fun callback(value: String?)
    fun callback(json: JSONObject)
}

interface BridgeModule {
    fun callFunc(call: BridgeCall)
}

open class BridgeRegistry(activity: BaseActivity) {

    protected val bridgeModuleMap = mutableMapOf<String, BridgeModule>()

    init {
        bridgeModuleMap["UI"] = JSBridgeUI(activity)
        bridgeModuleMap["KVDB"] = JSBridgeKVDB(activity)
        bridgeModuleMap["Camera"] = JSBridgeCamera(activity)
        bridgeModuleMap["Image"] = JSBridgeImage(activity)
        bridgeModuleMap["Navigation"] = JSBridgeNavigation(activity)
    }

    fun getModule(id: String): BridgeModule? {
        return bridgeModuleMap[id]
    }
}

我们去掉了 BridgeModuleBase 类,定义了 BridgeCall,并修改 BridgeModule 的 callFunc 方法的参数

这里的设计原理是:

WebView      callNative - \
                          |  -> new BridgeCall -> BridgeModule.callFunc(call)
ReactNative  callNative - /
  • BridgeCall 代表了一次调用参数和返回接口,传递到具体的 BrigdeModule
  • BridgeRegistry 是注册模块的管理类

并且我们把这部分代码,放到了 JSBridge.kt 中,如图:

WebViewBridge.kt 里,变成这样:

// WebViewJSBridgeCall 继承了 BridgeCall
// WebViewJSBridgeCall 定义了 WebView 调用是的通信环境
// * callbackId: WebView独有的通信方式
// * webView参数
// * callback 方式使用 evaluateJavascript
class WebViewJSBridgeCall(
    override val moduleName: String,
    override val funcName: String,
    override val arg: JSONObject,
    val webView: WebView,
    private val callbackId: String) : BridgeCall {

    override fun callback(value: Int) {
        execJS("window.$callbackId($value)")
    }

    override fun callback(value: Boolean) {
        execJS("window.$callbackId($value)")
    }

    override fun callback(value: String?) {
        if (value == null) {
            execJS("window.$callbackId(null)")
        } else {
            execJS("window.$callbackId('$value')")
        }
    }

    override fun callback(json: JSONObject) {
        execJS("window.$callbackId($json)")
    }

    private fun execJS(script: String) {
        Log.e("WebView", "exec $script")
        webView.post {
            webView.evaluateJavascript(script, null)
        }
    }

}

// BridgeObject 继承 BridgeRegistry
class BridgeObject(val activity: WebActivity, val webView: WebView) : BridgeRegistry(activity) {

    @JavascriptInterface
    fun callNative(callbackId: String, method: String, arg: String) {
        Log.e("WebView", "callNative $method args is $arg")
        val jsonArg = JSONObject(arg)
        val split = method.split(".")
        val moduleName = split[0]
        val funcName = split[1]

        val module = bridgeModuleMap[moduleName]
        // 创建 WebViewJSBridgeCall
        val call = WebViewJSBridgeCall(moduleName, funcName, jsonArg, webView, callbackId)
        // callFunc 传入
        module?.callFunc(call)
    }
}

修改 JSBridgeXXX 几个模块

这里以 JSBridgeCamera 为例,其他的按照这个方式即可

首先,继承 BridgeBaseModule 改为直接继承 BridgeModule,参数改为只有一个,并且类型是 BaseActivity

class JSBridgeCamera(val activity: BaseActivity) : BridgeModule { ... }

callFunc 改为一个参数 call,类型为 BridgeCall

override fun callFunc(call: BridgeCall) {
    when (call.funcName) {
        "takePicture" -> takePicture(call)
        "takeVideo" -> takeVideo(call)
    }
}

takePicture 和 takeVideo 改为接受 call 参数

private fun takePicture(call: BridgeCall) { ... }
private fun takeVideo(call: BridgeCall) { ... }

方法里面如果遇到要使用 arg 的地方改为 call.arg 即可,比如 JSBridgeUI.alert

private fun alert(call: BridgeCall) {
    val arg = call.arg
    ...
}

到这里,我们完成了重构,可以运行以下看看效果,检查下是否有错误

ReactNativeBridge

接下来我们需要为 ReactNative 提供调用 JSBridge 的功能了

在 react 包下,创建 ReactNativeBridge.kt

内容如下:

class JSBridgePackage(val activity: ReactNativeActivity) : ReactPackage {

    override fun createNativeModules(reactContext: ReactApplicationContext): MutableList<NativeModule> {
        return mutableListOf(ReactNativeBridge(activity, reactContext))
    }

    override fun createViewManagers(reactContext: ReactApplicationContext): MutableList<ViewManager<View, ReactShadowNode<*>>> {
        return mutableListOf()
    }

}

class ReactNativeBridge(activity: ReactNativeActivity, val context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {

    private val bridgeRegistry = BridgeRegistry(activity)

    override fun getName(): String {
        return "JSBridge"
    }

    @ReactMethod
    fun callNative(method: String, arg: String, promise: Promise) {
        Log.e("JSBridge", "%s %s".format(method, arg))

        val jsonArg = JSONObject(arg)
        val split = method.split(".")
        val moduleName = split[0]
        val funcName = split[1]

        val module = bridgeRegistry.getModule(moduleName)
        val call = ReactNativeJSBridgeCall(moduleName, funcName, jsonArg, context, promise)
        module?.callFunc(call)
    }

}

class ReactNativeJSBridgeCall(
    override val moduleName: String,
    override val funcName: String,
    override val arg: JSONObject,
    val context: ReactApplicationContext,
    val promise: Promise
) : BridgeCall {

    override fun callback(value: Int) {
        promise.resolve(value)
    }

    override fun callback(value: Boolean) {
        promise.resolve(value)
    }

    override fun callback(value: String?) {
        promise.resolve(value)
    }

    override fun callback(json: JSONObject) {
        promise.resolve(json)
    }

}

前面两个类不解析了,最后一个 ReactNativeJSBridgeCall 定义的是 ReactNative 的 BridgeCall, 它使用 promise.resolve 的方式做 callback

到这里就完成了 ReactNative 的调用 JSBridge

试用问题

我们在 todolist 试用一下 JSBridge 的效果,通过引入:

import JSBridge from "../../common/jsbridge-react-native.js";

添加试用代码,在 addTask 成功是 toast 一下:

addTask = () => {
  let notEmpty = this.state.text.trim().length > 0;

  if (notEmpty) {
    this.setState(
      prevState => {
        let { tasks, text } = prevState;
        return {
          tasks: tasks.concat({ key: tasks.length, text: text }),
          text: ""
        };
      },
      () => Tasks.save(this.state.tasks)
    );

    JSBridge.UI.toast("Add task success");
  }
};

运行问题出现,会找不到 jsbridge-react-native.js 文件,因为,ReactNative 使用 Metro 作为 打包工具,这个工具不支持引用项目外部的代码

因此,我们将 jsbridge.jsjsbridge-react-native.js 复制到项目下:

import JSBridge from "./jsbridge-react-native.js";

运行效果


支持调用 JSBridge 的模块 (iOS)

这篇分析如何支撑 ReactNative 调用 JSBridge 的模块

理解 ReactNative 调用 Native

首先,我们先理解 ReactNative JS 那边如何调用 Native 的,上一节我们讲过:

const { NativeModules } = require("react-native");

NativeModules.JSBridge.callNative(method, stringData).then(callback);

上面是部分jsbridge-react-native.js中的代码,其中:

  • NativeModuels 是 ReactNative 提供我们挂载本地对象的地方
  • JSBridge 是我们 Native 注入到 NativeModuels 中的对象
  • callNative 是 JSBridge 提供的 Native 方法

那么 Native 如何添加JSBridge这个对象呢

::: tip 注意 以下代码不是最终效果,只是理解过程即可,最终代码有一定差异,后面会讲 :::

首先,需要使用 objc 导出我们的模块:

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(JSBridge, NSObject)

RCT_EXTERN_METHOD(callNative:(NSString *)method argString:(NSString *)argString resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)

@end

然后,声明 swift 的实现

@objc(JSBridge)
class ReactNativeBridge: NSObject, RCTBridgeModule {

    @objc(callNative:argString:resolve:reject:)
    func callNative(method: String, argString: String, resolve: @escaping RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void {
        print("JSBridge \(method) \(argString)")
    }
}

这样就实现了,NativeModules.JSBridge.callNative 是可以调用通过的,底层原理这里不讲

但是,后面的实现会稍微不一样,因为我们需要访问具体的 ViewController 环境,后面会讲

分析 callNative 的问题

之前我们在 WebViewBridge 的 BridgeObject 里,注册了这些模块

moduleDict["UI"] = JSBridgeUI(viewController: viewController)
moduleDict["KVDB"] = JSBridgeKVDB(viewController: viewController)
moduleDict["Camera"] = JSBridgeCamera(viewController: viewController)
moduleDict["Image"] = JSBridgeImage(viewController: viewController)
moduleDict["Navigation"] = JSBridgeNavigation(viewController: viewController)

我们同理也可以在 ReactNativeBridge 里注册这些模块,但是有一个问题,这些模块 的参数是 WebViewController

对于 ReactNative 来说,没有这两个对象,说明这些模块依赖的具体的通信环境,这些通信 环境就是 WebView 相关的类

因此,我们需要重构这部分

JSBridge 分化成 JSBridge 和 WebView

我们将 JSBridge 分成 2 个部分:

ToBeBigFE
    JSBridge
        JSBridgeCamera.swift
        JSBridgeImage.swift
        JSBridgeKVDB.swift
        JSBridgeNavigation.swift
        JSBridgeUI.swift
    WebView
        WebViewController.swift
        WebViewBridge.swift

这样 JSBridge 是与具体通信环境无关的代码, WebView 下的代码是与 WebView 通信相关的代码

解决 WebViewController 依赖

创建一个 BaseViewController 从 WebViewController 提出 JSBridgeXXX 这些模块需要用的:

BaseViewController.swift

BaseViewController 放到和 ViewController 并列就好

将 WebViewController 的部分代码提取到 BaseViewController

class BaseViewController : UIViewController {
    
    var project: String? = nil
    var params: [String:Any?] = [:]
    
}

::: tip WebViewController 被移走的代码这里就不说了 :::

重构原来的代码

我们先从上层的接口开始重构

原代码如下:

protocol BridgeModule : class {
    func callFunc(_ funcName: String, callbackId: String, arg: [String: Any?])
}

class BridgeModuelBase : BridgeModule {
    ... // 一些 callback 方法
}

新代码:

protocol BridgeCall {
    var moduleName: String { get }
    var funcName: String { get }
    var arg: [String : Any?] { get }
    func callback(value: Int)
    func callback(value: Bool)
    func callback(value: String?)
    func callback(json: [String:Any?])
}

protocol BridgeModule : class {
    func callFunc(_ call: BridgeCall)
}

class BridgeRegistry {
    
    var moduleDict = [String:BridgeModule]()
    
    func initModules(viewController: BaseViewController) {
        moduleDict["UI"] = JSBridgeUI(viewController: viewController)
        moduleDict["KVDB"] = JSBridgeKVDB(viewController: viewController)
        moduleDict["Camera"] = JSBridgeCamera(viewController: viewController)
        moduleDict["Image"] = JSBridgeImage(viewController: viewController)
        moduleDict["Navigation"] = JSBridgeNavigation(viewController: viewController)
    }
    
    func getModule(id: String) -> BridgeModule? {
        return moduleDict[id]
    }
}

我们去掉了 BridgeModuleBase 类,定义了 BridgeCall,并修改 BridgeModule 的 callFunc 方法的参数

这里的设计原理是:

WebView      callNative - \
                          |  -> new BridgeCall -> BridgeModule.callFunc(call)
ReactNative  callNative - /
  • BridgeCall 代表了一次调用参数和返回接口,传递到具体的 BrigdeModule
  • BridgeRegistry 是注册模块的管理类

并且我们把这部分代码,放到了 JSBridge.swift 中,如图:

WebViewBridge.swift 里,变成这样:

// WebViewJSBridgeCall 继承了 BridgeCall
// WebViewJSBridgeCall 定义了 WebView 调用是的通信环境
// * callbackId: WebView独有的通信方式
// * webView参数
// * callback 方式使用 evaluateJavascript
class WebViewBridgeCall : BridgeCall {
    
    let moduleName: String
    let funcName: String
    let arg: [String:Any?]
    let callbackId: String
    weak var webView: WKWebView?
    
    init(moduleName: String, funcName: String, arg: [String:Any?], callbackId: String, webView: WKWebView?) {
        self.moduleName = moduleName
        self.funcName = funcName
        self.arg = arg
        self.callbackId = callbackId
        self.webView = webView
    }
    
    func callback(value: Int) {
        execJS("window.\(callbackId)(\(value))")
    }
    
    func callback(value: Bool) {
        execJS("window.\(callbackId)(\(value))")
    }
    
    func callback(value: String?) {
        if value == nil {
            execJS("window.\(callbackId)(null)")
        } else {
            execJS("window.\(callbackId)('\(value!)')")
        }
    }
    
    func callback(json: [String:Any?]) {
        guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else {
            return
        }
        guard let jsonString = String(data: jsonData, encoding: .utf8) else {
            return
        }
        execJS("window.\(callbackId)(\(jsonString))")
    }
    
    func execJS(_ script: String) {
        print("WebView execJS: \(script)")
        webView?.evaluateJavaScript(script)
    }
    
}

// BridgeHandler 继承 BridgeRegistry
class BridgeHandler : NSObject, WKScriptMessageHandler {
    
    weak var webView: WKWebView?
    weak var viewController: BaseViewController?
    
    let bridgeRegistry = BridgeRegistry()
    
    override init() {
        super.init()
    }
    
    func initModules() {
        bridgeRegistry.initModules(viewController: viewController!)
    }
    
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage)
    {
        // 3. guard防止不合法调用和deinit后调用
        guard
            let body = message.body as? [String: Any],
            let webView = self.webView,
            let viewController = self.viewController,
            let callbackId = body["callbackId"] as? String,
            let method = body["method"] as? String,
            let data = body["data"] as? String,
            let utf8Data = data.data(using: .utf8)
        else {
            return
        }
        print("WebView callNative ok. body is \(body)")
        
        var arg: [String:Any?]?
        do {
            arg = try JSONSerialization.jsonObject(with: utf8Data, options: []) as? [String:Any?]
        } catch (let error) {
            print(error)
            return
        }
        
        let split = method.split(separator: ".")
        let moduleName = String(split[0])
        let funcName = String(split[1])
        
        guard let module = bridgeRegistry.getModule(id: moduleName) else {
            return
        }
        // 创建 WebViewJSBridgeCall
        let call = WebViewBridgeCall(
            moduleName: moduleName,
            funcName: funcName,
            arg: arg ?? [String:Any?](),
            callbackId: callbackId,
            webView: webView)
        // callFunc 时传入
        module.callFunc(call)
    }
}

修改 JSBridgeXXX 几个模块

这里以 JSBridgeCamera 为例,其他的按照这个方式即可

首先,继承 BridgeBaseModule 改为直接继承 BridgeModule,参数改为只有一个,并且类型是 BaseViewController

class JSBridgeCamera : BridgeModule {

    weak var viewController: BaseViewController?

    ...

    init(viewController: BaseViewController) {
        self.viewController = viewController
    }

    ...
}

callFunc 改为一个参数 call,类型为 BridgeCall

func callFunc(_ call: BridgeCall) {
    switch call.funcName {
    case "takePicture": takePicture(call)
    case "takeVideo": takeVideo(call)
    default: break
    }
}

takePicture 和 takeVideo 改为接受 call 参数

private func takePicture(_ call: BridgeCall) { { ... }
private func takeVideo(call: BridgeCall) { ... }

方法里面如果遇到要使用 arg 的地方改为 call.arg 即可,比如 JSBridgeUI.alert

func alert(_ call: BridgeCall) {
    let arg = call.arg
    ...
}

同时,有些方法会遇到线程问题,比如 toast

::: warning Main Thread Checker: UI API called on a background thread: -[UIView init] PID: 31536, TID: 7502538, Thread name: (none), Queue name: com.facebook.react.JSBridgeQueue, QoS: 0 :::

使用 DispatchQueu 进入主线程:

func toast(_ call: BridgeCall) {
    let arg = call.arg
    guard let message = arg["message"] as? String else {
        return
    }
    // 进入UI主线程
    DispatchQueue.main.async {
        self.viewController?.view.makeToast(message)
    }
}

还有很多地方可能遇到主线程问题,这里不全部解析

到这里,我们完成了重构,可以运行以下看看效果,检查下是否有错误

ReactNativeBridge

接下来我们需要为 ReactNative 提供调用 JSBridge 的功能了

在 React 下,创建几个文件:

ReactNativeBridge.m

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(JSBridge, NSObject)

RCT_EXTERN_METHOD(callNative:(NSString *)method argString:(NSString *)argString resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)

@end

ReactNativeBridge.swift

ReactNativeBridge.swift 我们声明的 ReactNativeBridgeCall,和 WebView 不同的是,我们这里 使用 RCTPromiseResolveBlock

ReactNativeBridge 类我们使用 @objc(JSBridge) 以及 static func moduleName() 两个方式保证 名称为 JSBridge,因为我们要使用自定义导出模块

自定义导出模块需要修改 ReactViewController 的代码,请看后面的源码

class ReactNativeBridgeCall : BridgeCall {
    let moduleName: String
    let funcName: String
    let arg: [String:Any?]
    let resolve: RCTPromiseResolveBlock
    
    init(moduleName: String, funcName: String, arg: [String:Any?], resolve: @escaping RCTPromiseResolveBlock) {
        self.moduleName = moduleName
        self.funcName = funcName
        self.arg = arg
        self.resolve = resolve
    }
    
    func callback(value: Int) {
        resolve(value)
    }
    
    func callback(value: Bool) {
        resolve(value)
    }
    
    func callback(value: String?) {
        resolve(value)
    }
    
    func callback(json: [String:Any?]) {
        resolve(json)
    }
}

@objc(JSBridge)
class ReactNativeBridge: NSObject, RCTBridgeModule {
    
    static func moduleName() -> String! {
        return "JSBridge"
    }
    
    weak var viewController: ReactNativeController?
    
    let bridgeRegistry: BridgeRegistry
    
    init(viewController: ReactNativeController) {
        self.bridgeRegistry = BridgeRegistry()
        super.init()
        self.viewController = viewController
        self.bridgeRegistry.initModules(viewController: viewController)
    }

    @objc(callNative:argString:resolve:reject:)
    func callNative(method: String, argString: String, resolve: @escaping RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void {
        print("JSBridge \(method) \(argString)")
        
        guard
            let viewController = self.viewController,
            let utf8Data = argString.data(using: .utf8)
        else {
            return
        }
        
        var arg: [String:Any?]?
        do {
            arg = try JSONSerialization.jsonObject(with: utf8Data, options: []) as? [String:Any?]
        } catch (let error) {
            print(error)
            return
        }
        
        let split = method.split(separator: ".")
        let moduleName = String(split[0])
        let funcName = String(split[1])
        
        guard let module = bridgeRegistry.getModule(id: moduleName) else {
            return
        }
        let call = ReactNativeBridgeCall(
            moduleName: moduleName,
            funcName: funcName,
            arg: arg ?? [String:Any?](),
            resolve: resolve)
        module.callFunc(call)
    }

}

ReactNativeController

使用自定义导出模块,要求我们手动创建 RCTBridge, 并实现一个RCTBridgeDelegate,这里直接 使用 ReactNativeController 实现

实现两个方法:

  • sourceURL:提供指向 js 代码文件的路径
  • extraModules:提供自定义导出的模块,我们自己创建 ReactNativeBridge
@objc
class ReactNativeController : BaseViewController {
    
    var jsCodeURL: URL!
    
    init(project: String) {
        super.init(nibName: nil, bundle: nil)
        self.project = project
        let urlStr = WebManager.shared.getWebUrl(id: project, page: "index.bundle")
        self.jsCodeURL = URL(string: urlStr)!
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        let bridge = RCTBridge(delegate: self, launchOptions: nil)!
        let rootView = RCTRootView(bridge: bridge, moduleName: project!, initialProperties: [:])
        self.view = rootView
    }
    
}

extension ReactNativeController : RCTBridgeDelegate {
    func sourceURL(for bridge: RCTBridge!) -> URL! {
        return jsCodeURL
    }
    
    func extraModules(for bridge: RCTBridge!) -> [RCTBridgeModule]! {
        return [
            ReactNativeBridge(viewController: self)
        ]
    }
}

到这里就完成了 ReactNative 的调用 JSBridge

试用问题

我们在 todolist 试用一下 JSBridge 的效果,通过引入:

import JSBridge from "../../common/jsbridge-react-native.js";

添加试用代码,在 addTask 成功是 toast 一下:

addTask = () => {
  let notEmpty = this.state.text.trim().length > 0;

  if (notEmpty) {
    this.setState(
      prevState => {
        let { tasks, text } = prevState;
        return {
          tasks: tasks.concat({ key: tasks.length, text: text }),
          text: ""
        };
      },
      () => Tasks.save(this.state.tasks)
    );

    JSBridge.UI.toast("Add task success");
  }
};

运行问题出现,会找不到 jsbridge-react-native.js 文件,因为,ReactNative 使用 Metro 作为 打包工具,这个工具不支持引用项目外部的代码

因此,我们将 jsbridge.jsjsbridge-react-native.js 复制到项目下:

import JSBridge from "./jsbridge-react-native.js";

运行效果