我们以 UIViewController 和 Activity 包裹 Component 的方式,迈出了百姓网主 app 接入 RN 的第一步。本期,我们将更具体地介绍 Pegasus 的设计和实现。着重回答以下问题:
- 怎么把 Pegasus 集成到已有的 native 项目?
- 怎么处理 RN 和 native 的互相通信?
一般来说,把 RN 模块集成进已有 app 有以下几个主要步骤 ( https://facebook.github.io/react-native/docs/integration-with-existing-apps.html ):
- 创建 RN 依赖和目录结构。
- 用 JavaScript 编写你的 component。
- 用 NPM 或 Yarn 安装 RN 的各种 package,用 CocoaPods(iOS)或 Gradle(Android)等包管理工具安装 RN 的 native 依赖。
- 把 RCTRootView(iOS))或者 ReactRootView(Android)添加到你的 app 里,作为 RN component 的容器来使用。
- 测试并验证代码的正确性。
- 最后把 JS 打包。
这种方案通常都要求集成者在本地配置好 RN 开发环境,比如有 Node,要装一堆 NPM package。即便在做非 RN 模块功能的开发和调试,或许也得运行 RN 相关的工具。
npm install
npm run start
这就使得原本 native 开发者的工具链一下子伸长很多。我只是想安安静静地写 native 代码啊,为什么要让我仓库里整那么多 JS 世界的东西?NPM 解决依赖好慢的,node_modules 好占地方的,后台一直开个 React Native Packager 好辛苦的,你知道吗?
于是,RN 模块的侵入一下子让原 native 项目的学习成本上了一个台阶,并且让开发体验下降了一截。那么问题来了,如何既能顺利集成 RN 模块,又能不破坏原有的开发体验呢?我们有个大胆的想法:干脆就把 Pegasus 做成一个 native 库吧,用 CocoaPods(iOS)或 Gradle(Android)无脑集成得了。以 iOS 为例,我们希望最后变成这个样子:
在 Podfile 文件里,只要写一行表示我们需要一个叫 Pegasus 的依赖,即我们的 RN 模块。
pod 'Pegasus'
在 native 代码里,通过简单的初始化代码,就能启动一个 RN 页面:
// 初始化 Pegasus
Pegasus *pegasus = [[Pegasus alloc] init];
// 创建 RN component 容器
PEGComponentViewController *viewController; // 限于版面多写一行
viewController = [PEGComponentViewController alloc] initWithPegasus:pegasus
moduleName:@"Profile"
initialProperties:@{ @"name" : "Jack" }];
[self.navigationController pushViewController:viewController animated:YES];
于是乎,我们朝着这样一个目标做了一些努力。下面祭出 Pegasus 的简易体系结构图:
根据使用方式的不同,Pegasus 对外表现成三种形态:
- JavaScript:一个 NPM package;
- iOS:一个 CocoaPod;
- Android:一个 Gradle 依赖。
Pegasus 自底向上看,有如下层级:
- Native UI Components 和 Module APIs。做过 RN 开发的同学都知道,官方提供的 Component 和 Module API 一般来说是没法完全满足 app 定制化需求的。所以如果是白手起家写 RN,开发者多多少少要写一些组件和功能接口。这层就是 Pegasus 的定制 native UI 组件和功能接口层。它和 RN 本身的 native 代码一起组成了 Pegasus 的基础,供 JS 层调用;
- JavaScript 代码层。主要的页面构建和业务逻辑都在这里。相当纯粹的 React 开发。
- Native Public API。对外暴露的接口。Pegasus 的 client 只能通过调用这些 API 来使用这个库。一般来它包括一堆 view controller 和 activity。还有一些必要的环境初始化和抽象数据接口。
- 双平台的壳工程。用于 Pegasus 的开发和调试。
对 iOS 工程来说,我们把打包过后的 JS 代码和图片等作为资源与 Objective-C 代码一起做成一个 CocoaPod 对外提供 RN 能力。如下图:
由于 RN 模块被做成了一个 native 的库,对于使用者而言就只要像往常使用普通库一样集成就行。让我们一起来看看魔法是如何生效的。下面是 package.json(NPM 包配置文件)和 Pegasus.podspec(CocodPods 库配置文件)的代码片段(省去了非关键信息)。
// package.json
{
"name": "pegasus",
"dependencies": {
"react": "16.0.0-alpha.12",
"react-native": "0.46.4",
"react-native-code-push": "^4.1.0-beta",
},
}
# Pegasus.podspec
require 'json'
npm_package = JSON.load(File.read(File.expand_path('../package.json', __FILE__)))
Pod::Spec.new do |s|
s.name = 'Pegasus'
s.version = npm_package['version']
s.summary = 'React Native Components used by Baixing.'
s.source_files = 'ios/Classes/**/*.{h,m}'
s.resource_bundles = {
'Pegasus' => ['ios/Assets/{Pegasus.js,assets,*.xcassets,*.lproj}'],
}
react_native_version = npm_package['dependencies']['react-native'].sub('^', '~>')
s.dependency 'React/BatchedBridge', react_native_version
s.dependency 'React/Core', react_native_version
s.dependency 'React/RCTText', react_native_version
s.dependency 'React/RCTImage', react_native_version
s.dependency 'React/RCTNetwork', react_native_version
s.dependency 'React/RCTAnimation', react_native_version
s.dependency 'React/RCTCameraRoll', react_native_version
code_push_version = npm_package['dependencies']['react-native-code-push'].sub('^', '~>').sub('-beta','')
s.dependency 'CodePush/Core', code_push_version
s.dependency 'SSZipArchive'
# Other UI dependencies
s.dependency 'MBProgressHUD'
end
- 首先,把 RN 的 native 代码作为 Pegasus 的依赖;
- 其次,把用到的所有含 native 代码第三方 RN 插件作为 Pegasus 的依赖;
- 最后,把添加其他 native 依赖,比如 UI 库和工具库。
由于 React Native 和相关的 RN 插件都假定开发者本地有 RN 开发环境,并且会使用 NPM 等包管理工具下载 package。因此,它们一般不会出现在公有 CocoaPods 或者 Maven 仓库里。为了让 CocoaPods 和 Gradle 一键安装生效,你很有可能需要自己去创建仓库存放你的依赖。相关教程大家可以自行通过搜索引擎找到,不再赘述。
解决依赖问题后, 下一步要做的就把打包完后的 JS 代码和图片作为资源打进库里。比如我们写了一条 NPM script,生成的 Pegasus.js 就是运行在 JavaScriptCore 上的 JS 代码。:
# NPM script
{
"scripts": {
"bundle-ios": "react-native bundle --platform ios --dev false --entry-file index.ios.js --bundle-output ios/Assets/Pegasus.js --sourcemap-output ios/Assets/Pegasus.js.map --assets-dest ios/Assets",
}
}
# Run
npm run bundle-ios
至此,我们通过把 RN 模块做成 native library 的方式,对外屏蔽 JS 技术细节,无缝接入了 RN 能力。对原 native app 的开发者来说,他们只是多使用了一个普通的 native 库。不需要额外配置,还是维持原有工具链不变,更不需要关心这个新接入的库里到底是用了 RN 还是 native 实现,反正无脑调它的 native 接口就行了。
既然外部集成 so easy,那么压力就来到了 Pegasus 这一边。怎么解决内部纷繁的 RN 技术问题成了新的挑战。
RN 模块所谓『通信问题』主要有两方面:
- JS 和 native 的通信;
- 模块和宿主 app 间的通信。
React Native 为 JS 和 native 间通信提供了几种方案。官方文档 ( https://facebook.github.io/react-native/docs/communication-ios.html ) 经过多个版本的更新后,对各种机制的说明已经相当详细。下面简单说说我们在 Pegasus 里的实际使用体验。
JS 调 native 主要是通过 NativeModules。一般做法就是写一个类实现 RCTBridgeModule 接口。把要给 JS 调用的方法暴露出来。下面是简单的例子:
@interface PEGDataProviderModule : NSObject <RCTBridgeModule>
@end
@implementation PEGDataProviderModule
RCT_EXPORT_MODULE()
RCT_EXPORT_METHOD(getUsername:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject) {
if (resolve) {
resolve(@"Jack");
}
}
@end
在 JS 端:
import { NativeModules } from 'react-native'
NativeModules.PEGDataProviderModule.getUsername().then((username) => {
console.log(username)
})
RN 在的 bridge module 处理回调可以是 callback 方式,也可以是 Promise。用 Promise 会更好一点 ( https://github.com/facebook/react-native/wiki/Breaking-Changes#remove-callback-support-from-clipboard-and-netinfo-fa5ad8---satya164 )。
Why make this breaking change: A long time ago we didn't a way to return Promises from native to JS so we used to use callbacks. Now Promises should be used everywhere.
默认情况下,RN 会帮你创建 bridge module,不过是用默认构造函数。如果希望在 module 初始化时传入一些参数,就需要做一些额外处理。给 bridge 赋一个 RCTBridgeDelegate 并实现 - extraModulesForBridge:
方法。
JS 调用 native 的另一种办法是采用 native UI component 的方式。这种做法多见于编写自定义 UI。比如你的 native app 里已经有一个定制过的地图控件了,你的 RN 模块里也要展示这个控件。从软件工程角度来看,你希望尽可能地复用。于是就用这种方式把 native UI 给 JS 用。办法也很简单,按文档写 RCTViewManager 的子类即可。
需要强调的是,这些给 JS 用的视图,其大小和位置最后都是 JS 端代码决定的。尽可能不要在 manager 里做过关于布局的假设。此外,考虑到已有 app 很多页面背后都有业务逻辑(例如一个 native 的商品列表展示页面)。我们也尝试过把 UIViewController 和 Activity 的视图做成 UI 组件给 JS 用。但是因为 RN 拿掉了 UIViewController 和 Activity 概念转而专注于 view,因此部分对象的生命周期会出现一些问题。倘若你有定制 native UI 组件的需求,建议尽量做纯 view。
从 native 端调用 JS 相比从 JS 调用 native 代码稍显繁琐。它提供了 event 机制 ( https://facebook.github.io/react-native/docs/native-components-ios.html#events ) 和 event emitter 机制 (http://facebook.github.io/react-native/releases/0.48/docs/native-modules-ios.html#sending-events-to-javascript )。
前者用于 native UI 组件从 native 向 JS 的事件回调,后者用于 bridge module(RCTEventEmitter)。两者的使用体验差别比较大。
- 对 native UI 组件,在 JS 端把响应事件的函数作为 property 传给对应 component 后,只需要在 native 调用该回调函数即可(block)。
- 对 event emitter,一方面你需要在 native 通过 RN 接口发送事件名和事件参数,另一方面还要在 JS 代码里设置观察者来响应事件。最后你还要管理这些事件和其观察者的生命周期。
倘若对 native 调 JS 的需求不是特别强烈,建议尽量少用 event emitter。代码实在是丑啊。
最后 RN 还提供了一种 native 调 JS 的方法,该方法在上文已有所提及。那就是开发者可以在 native 端创建 RCTRootView 作为 RN component 容器,通过提供组件名称和 props 初值来调起特定 component。
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
moduleName:@"ImageBrowserApp"
initialProperties:props];
Pegasus 则大量使用了上述方式从 native 端调起 JS 组件,即 native 跳转 JS。
利用 RN 提供的通信方案,在 Pegasus 中我们也能比较轻松得实现 JS 页面到 native 页面的跳转。我们在主 app 内已经有一套基于 URL 的路由机制。具体可以参考:
- 《iOS 应用架构谈组件化方案》( https://casatwy.com/iOS-Modulization.html )
- 《iOS 组件化方案探索》( http://blog.cnbang.net/tech/3080/ )
把 router 做成 bridge module 后就能在 JS 调起 native 页面了。
@implementation PEGRouterModule
RCT_EXPORT_METHOD(route:(NSURL *)url) {
[self.router routeURL:url];
}
// 其他工具方法,如关闭一个页面,跳转到某个 RN component 页面……
@end
此外由于我们把 RN component 嵌进了 UIViewController/Activity 容器,因此在 RN 页面跳转另一个 RN 页面也可以沿用该方案。至此,RN 页面就无缝接入了原有 app 体系,而且可以拥有原生转产体验。
Airbnb 写了一个 Native Navigation ( https://github.com/airbnb/native-navigation ) ,采用了类似方案。倘若你正在寻找 RN 的转场方案,不妨试一试。不过需要注意的是,据我所知他们还没有在生产环境中使用 : (
在本期文章中,我们介绍了 RN 里 JS 和 native 常用的通信方式。它们让 Pegasus 的开发成为可能。此外,我们以 native library 的形式把 Pegasus 嵌进了百姓网主 app,于此同时让使用者可以不接触一行 JS 代码,neat!在 RN 道路上,我们又迈出了一步。
下期,我们会继续讨论:
- RN 模块对外依赖的解决方案;
- RN 模块的开发和调试问题;
- RN 模块代码的动态部署问题。
敬请期待!
题图
沿用第一篇
摘要
我们以 UIViewController 和 Activity 包裹 Component 的方式,迈出了百姓网主 app 接入 RN 的第一步。本期,我们将更具体地介绍 Pegasus 的设计和实现。
作者署名
唐毅明
作者照片
沿用第一篇