我们以 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 模块代码的动态部署问题。
敬请期待!


请问一下 Pegasus是不是没有开源啊