React Native 和 app 动态化一直以来都是业内的热门话题。百姓网移动技术团队在过去一段时间就这两个话题展开了积极探索,并积累一点宝贵的人生经验。虽然作为分享者,我希望一下子把事情全讲清楚,但是限于移动端阅读体验,此次我们还是会分上、中、下三篇文章陆续发布。
在系列文章里,我们将注重讨论以下几个话题:
- 百姓网的 React Native 之路;
- RN 模块集成到已有 native 项目的方案;
- RN 模块中 JavaScript 和 native 代码通信问题;
- RN 模块对外依赖的解决方案;
- RN 模块的开发和调试问题;
- RN 模块代码的动态部署问题。
百姓网 app 可以让用户方便地在移动设备上搜索、浏览和发布帖子。和大多数 API 驱动的 app 一样,百姓网.app 的主要功能就是调用服务端 API 获取数据然后把他们渲染成 UI。一定程度上,我们甚至可以把它理解为一个『原生的百姓网专属浏览器』。但是和 web 应用相比,原生 app 由于各种先天(构建、运行)和后天(应用市场审核)问题很难实时地把代码部署到终端。
在过去一段时间里,移动技术团队为了快速响应需求变更,支持业务发展,在 app 动态化上做了诸多努力:
- 在 app 本地预置原生组件,获取远程数据后,根据配置渲染成各种 UI;
- 定义 URL 跳转协议,所有核心业务页面都支持 URL 跳转;
- 使用 web view 和 JavaScript bridge 实现 hybrid 方案;
- 采用 JSPatch、Tinker 等框架实现热修复。
这些措施虽然缓解了 app 动态化需求,但是仍然存在各种各样的问题:
- 预置原生组件+远程数据配的方案使得页面还是只能在线上已有组件的框架内做配置,而且除了下发简单的跳转动作以外,似乎没法部署复杂逻辑;
- Hybrid 方案在性能和用户体验上仍然存在瓶颈;
- Native 热修复方案也不适合承载业务部署的重任。
我们希望有一个框架既能承载业务开发、代码部署,又能提供良好的运行时性能。经过一段时间摸索,我们发现 React Native(以下简称 RN) 是一个不错的选择。RN 为移动端应用提供了一种全新的开发方式。从诞生之初它就具备了诸多强大特性:
- 它使得开发者可以使用目前最流行的编程语言 JavaScript 来编写原生移动应用程序;
- 它以 React 作为上层应用框架,践行其『Learn Once, Write Anywhere.』的愿景,并提供与 web 相一致的开发体验;
- 它的跨平台特性使得多平台应用程序可以共享一份核心代码,大大提升了开发效率;
- 它运行在 JavaScriptCore 上(各平台叫法不一),无需编译,可以动态部署。
罗马不是一日建成的。考虑到技术风险,我们一开始并没有直接在主 app 上使用 React Native 技术,而是新开了一个商家端 app 练手。这款 app 叫百姓生意,是一款面向百姓网 VIP 用户的管理工具。它可以帮助用户完成发帖、付费、管理帖子等任务。
百姓生意采用了 RN 官方推荐的开发范式。和绝大多数 RN 项目一样,一切都源于下面这行短短的命令:
react-native init AwesomeProject
除了私信模块需要用 native 代码做接入外,百姓生意所有模块都很『React Native』。也就是说凡是能用 JS 代码写的,我们就用 JS。我们从 React Native 社区引入了很多第三方插件来支撑我们的上层业务。但是随着开发的推进,我们发现这种纯 RN app 开发是一个深坑。
- RN 社区除了一些大型组织如 Facebook、Airbnb 等维护的组件,多数组件在 native 开发者看来都不够专业,bug 满地,性能堪忧;
react-native link
接入第三方插件依然问题多多,远不如 CocoaPods,Gradle 好用;- 由于 RN 有意屏蔽了
UIViewController
和Activity
的概念,视图层级和生命周期在复杂场景下会出现各种各样的问题; - 纯 RN 的转场一直没有特别靠谱的解决方案 ( artsy/emission#501 );
- 我们还是写了很多 native component 和 module 以供 JS 使用,这些代价甚至比纯 native 实现来得高。
总之关于这个纯 RN 试验项目的故事还有很多,有空我再说给你听。在经历了百姓生意的开发之后,我们觉得在主 app 做 RN 接入时有必要做出一些改变。
此后,我们迎来了系列文章的主角 Pegasus(天马)。名称来源很粗暴,纯粹是受动漫和希腊神话影响,感觉很厉害的样子。后来发现这家伙是在天上的,正好可以匹配动态变更的需求。
Pegasus 是百姓网主 app 内的 RN 模块。和先前试验项目百姓生意所不同的是 Pegasus 被包装成一个普通的 native 类库接入 app。以 iOS 为例,结合百姓网 API -> UI 这样的业务特点,我们可以把主 app 视为由一系列 UIViewController 对象构成的浏览器。所有业务无非就是在各种 view controller 里做一番操作然后跳转到下一个 view controller。
因此,Pegasus 对外提供了各种 UIViewController 子类,每个 view controller 嵌入一个 JS 编写的 component。当应用需要跳转 RN 页面时,只要像往常一样调起相应的 view controller 即可。下图展示了其基本原理:
创建一个内嵌 RN Component 的 view controller 相当简单。只需要提供以下几个参数:
- 全局共享的 RN bridge 实例;
- 目标 component 的名称,即在
AppRegistry
注册的组件名; - 目标 component 的
props
初值。
例如我们有一个 view controller 里嵌入了一个组件注册名叫 Profile
,其 props
需要一个叫 name
的字符串。在 iOS 端的调用可以简化为:
PEGComponentViewController *viewController; // 限于版面换行
viewController = [PEGComponentViewController alloc] initWithPegasus:[Pegasus sharedInstance]
moduleName:@"Profile"
initialProperties:@{ @"name" : "Jack" }];
[self.navigationController pushViewController:viewController animated:YES];
上述 native 端调用 RN 组件的简化源于我们对 RN 组件做了简单的封装:PEGComponentViewController。其简化版的接口声明如下:
// 一个通用的 RN 组件容器
@interface PEGComponentViewController : UIViewController
@property (nonatomic, readonly) Pegasus *pegasus;
@property (nonatomic, readonly) NSString *moduleName;
@property (nonatomic, readonly) NSDictionary *initialProperties;
- (instancetype)initWithPegasus:(nullable Pegasus *)pegasus
moduleName:(NSString *)moduleName
initialProperties:(nullable NSDictionary *)initialProperties NS_DESIGNATED_INITIALIZER;
@end
// 共享的 RN bridge 管理者
@interface Pegasus : NSObject
@property (nonatomic, readonly) RCTBridge *bridge;
@property (nonatomic, readonly) PegasusConfiguration *configuration;
+ (instancetype)sharedInstance;
+ (void)setSharedInstance:(nullable Pegasus *)instance;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfiguration:(nullable PegasusConfiguration *)configuration
router:(id<PEGRouter>)router
baixingAPI:(id<PEGBaixingAPI>)baixingAPI
dataProvider:(id<PEGDataProvider>)dataProvider NS_DESIGNATED_INITIALIZER;
@end
pegasus
是天马实例,持有 RN 的 bridge 和各种外部注入的必备模块;moduleName
是在 JS 端AppRegistry
注册的 component 的 key;initialProperties
则是 component 的 props 初值。
PEGComponentViewController 在实现上其实依托 RN 的视图类 RCTRootView(类比成 component 的大画布) 来展示 RN 侧的页面。其核心代码如下:
@implementation PEGComponentViewController
- (void)viewDidLoad {
[super viewDidLoad];
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:self.pegasus.bridge
moduleName:self.moduleName
initialProperties:self.initialProperties];
rootView.reactViewController = self;
[self.view addSubview:rootView];
// Other stuff
}
// Other methods
@end
只要你稍微看过一点 RN 在 native 端的实现,你就不难发现 RN 默认创建的工程全局只有一个 UIViewController 和一个 RCTRootView,所有 component 都在这一个 RCTRootView 上做文章,如 UI 布局、动画、过场等。我们的做法实际上只是把一个推广到多个。App 内可以有多个 RCTRootView + UIViewController,每个 RCTRootView 只承载一个 RN 页面。相比传统 RN app 它的优势很明显:
- RN 模块接入前后,调起新页面在代码层面上并没有显著区别,对接入者来说很容易接受;
- 页面间仍然可以提供 native 的转场体验,而且几乎没什么成本(已有 router 模块);
- view controller 粒度天然地帮助 RN 端代码做了模块划分。
于是,我们在已有 native app 里使用 RN 的姿势就稍稍变化了一下:
- 在适当的时候(如在 app 启动后就去预加载)创建共享的 RN bridge 实例,载入 JS bundle;
- 在需要展示 RN 页面的地方,创建对应的 view controller 或 activity,跳转。
就这样我们以 UIViewController 包 component 的姿势,把 RN 模块接入了一个有多年历史的 native 项目,迈出了百姓网主 app 基于 RN 动态化的第一步。
当然,不出意外的话,后面大概还有 N 步。我们不可避免地需要回答以下喜闻乐见的问题:
- Pegasus 到底如何嵌入已有 native 项目?
- Pegasus 怎么和宿主双向通信?
- Pegasus 对宿主的依赖怎么解决?
- Pegasus 代码如何部署?
敬请期待《Pegasus:基于 RN 的 App 动态化方案(中)》
@zpbx
改了一下: