上期,我们介绍了 RN 中 JS 和 native 的通信方案,并以 native library 形式在我们的 app 内顺利集成了 RN 模块。今天我们继续讨论:
- RN 模块对外依赖的解决方案;
- RN 模块的开发和调试问题;
- RN 模块代码的动态部署问题。
内嵌的 RN 模块尽管很独立,但是或多或少要依赖一些宿主 app 的功能。举个例子,你在原有 native app 里写了一堆与服务端 API 加密访问的代码。这个功能的一些参数还依赖一些运行时数据。在 RN 模块你也需要用到该功能。此时,有两种选择摆在你面前。要么在 RN 模块里再把该功能实现一遍,要么复用已有代码。重写是不现实的,今天要在 RN 重新实现这个功能,明天可能又要重写那个功能。一方面大大增加工作量,另一方面同一功能你既要在 RN 模块维护,又要在 native 模块维护。画面太感人了!
于是乎我们想尽可能地通过复用解决 RN 模块对宿主的依赖。Pegasus 给出的方案是:依赖注入。
Pegasus 有不少业务功能需要依赖宿主 app。例如我们在 Pegasus 内实现百姓网帖子发布功能时要用到主 app 内当前登录的用户信息、类目结构信息、地理位置信息、已有网络 API、已有埋点功能……是直接把这些实现拿来直接给 Pegasus 用吗? 思考再三,我们忍了忍,加了点料。
Pegasus 定义了大量接口来表示外部依赖,在代码里我们只调用这些抽象接口,而不是去直接调用具体实现的类。
上图展示了 Pegasus 对网络 API 的依赖处理。我们定义了一个 BaixingAPI 的抽象接口。RN 模块内的代码直接依赖于这个接口。宿主 app 内已有的 BXAPI 再去实现上述抽象接口。宿主再把 BXAPI 作为依赖注入到 Pegasus 后,Pegasus 就能真正使用 BXAPI 的实现了,尽管在 RN 模块里压根就没有 BXAPI。
在这个例子里,我们给 BaixingAPI 这个抽象接口定义了两个方法:一个是 HTTP GET 方法,另一个是 POST 方法。
// 抽象接口定义
@protocol PEGBaixingAPI <NSObject>
@required
- (void)GET:(NSString *)URLString parameters:(nullable NSDictionary<NSString *, id> *)parameters success:(nullable void (^)(id _Nullable responseObject))success failure:(nullable void (^)(NSError *error))failure;
- (void)POST:(NSString *)URLString parameters:(nullable NSDictionary<NSString *, id> *)parameters success:(nullable void (^)(id _Nullable responseObject))success failure:(nullable void (^)(NSError *error))failure;
@end
然后我们在要用到 GET 和 POST 请求的地方,去调用这个抽象接口。例如我们想 JS -> PEGBaixingAPIModule -> BaixingAPI (BXAPI)。让 JS 代码看上去这样:
const options = {
method: 'get',
api: '/v2/users',
params: {
id: 1234567890
}
}
NativeModules.PEGBaixingAPIModule.request(options).then((user) => {
console.log(user)
}).catch((error) => {
console.log(error)
})
在 bridge module 里,我们的代码是依赖 BaixingAPI 这个接口,而非 BXAPI 的实现。
@interface PEGBaixingAPIModule : NSObject <RCTBridgeModule>
@property (nonatomic, readonly) id<PEGBaixingAPI> baixingAPI;
+ (instancetype)moduleWithBaixingAPI:(id<PEGBaixingAPI>)baixingAPI;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithBaixingAPI:(id<PEGBaixingAPI>)baixingAPI NS_DESIGNATED_INITIALIZER;
@end
@implementation PEGBaixingAPIModule
// 让 JS 端调用
RCT_EXPORT_METHOD(request:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
NSString *method = options[@"method"];
NSString *path = options[@"api"];
NSDictionary *parameters = options[@"params"];
NSAssert(method, @"API request options must contain a method. Neither 'get' nor 'post' exists.");
NSAssert(path, @"API request options must contain a path.");
void (^failureBlock)(NSError * _Nonnull error) = ^(NSError * _Nonnull error) {
if (reject) {
reject([@(error.code) stringValue], error.localizedDescription, error);
}
};
void (^successBlock)(id _Nullable responseObject) = ^(id _Nullable responseObject) {
if (resolve) {
BOOL isValidJSONObject = [NSJSONSerialization isValidJSONObject:responseObject];
if (!isValidJSONObject) {
resolve(nil);
return;
}
resolve(responseObject);
}
};
if ([method isEqualToString:@"get"]) {
[self.baixingAPI GET:path parameters:parameters success:successBlock failure:failureBlock];
} else if ([method isEqualToString:@"post"]) {
[self.baixingAPI POST:path parameters:parameters success:successBlock failure:failureBlock];
}
}
@end
在宿主 app 里让已有的 BXAPI 类去实现 BaixingAPI 这个接口。
@interface BXAPI (Pegasus) <PEGBaixingAPI>
@end
@implementation BXAPI (Pegasus)
- (void)GET:(NSString *)URLString
parameters:(NSDictionary<NSString *,id> *)parameters
success:(void (^)(id _Nullable))success
failure:(void (^)(NSError * _Nonnull))failure
{
// 调用已有 BXAPI 实现
}
- (void)POST:(NSString *)URLString
parameters:(NSDictionary<NSString *,id> *)parameters
success:(void (^)(id _Nullable))success
failure:(void (^)(NSError * _Nonnull))failure
{
// 调用已有 BXAPI 实现
}
@end
最后,我们把这些依赖注入进 Pegasus,大功告成!
// 宿主 app 初始化 Pegasus
id<PEGBaixingAPI> baixingAPI = [BXAPI sharedInstance];
id<PEGRouter> router = [BXRouter sharedRouter];
id<PEGDataProvider> dataProvider = [BXDataProvider sharedProvider];
Pegasus *pegasus = [[Pegasus alloc] initWithConfiguration:configuration
baixingAPI:baixingAPI
router:router
dataProvider:dataProvider];
通过这样的设计,我们顺利地解决了 RN 模块对宿主 app 的依赖问题。这样做有几个好处:
- 做到依赖倒置,Pegasus 和宿主代码都依赖抽象接口,而非具体实现,达到解耦目的。
- 提升了 Pegasus 的可复用性。试想一下,今天我在百姓网主 app 使用了 Pegasus,明天我想在一个百姓网商家端 app 复用 Pegasus 该怎么做?很简单,只要在这个商家端 app 内实现 Pegasus 定义的抽象接口,把实现注入到 Pegasus 即可。
最后,RN 模块可能会和宿主 app 共享一些数据。比如用户信息,图片缓存等。这又怎么解决呢?Pegasus 的原则是只管自己的数据:
- Pegasus 对外不可见的数据,自己持有并管理。
- Pegasus 需要对外暴露的数据通过 public 的 native API 供 app 使用。
- App 持有的数据则通过前文所述的依赖注入方案给 Pegasus 使用。项目中我们抽象了一个叫 DataProvider 的接口。
在解决了集成和依赖问题后,我们就要用 RN 做业务开发了。
Pegasus 有 native 和 JS 两部分代码。在 native 端,我们一边构建定制化的 native UI 组件,一边做 bridge module 接入。在 JS 端,我们一边写业务代码,一边接入 native 组件和 module API。这对开发者的技术栈是有一定要求的,团队内最好有人既熟悉 native 开发,又熟练 React。对纯 native app 团队是一大挑战。
我们的应对策略是『学习』!走出自己的舒适区,扩大自己的能力范围。最后让团队内所有开发者都会写 RN。
我们的经验是虽然 RN 在初期投入比较大,有学习成本,要花精力做基础架构搭建,但是考虑到后续功能迭代效率的提升,这些都是值得的。现在我们只要花很少的人力就能完成双端功能的开发。
Pegasus 在技术上选用了 TypeScript。一方面作为一群 native 开发者,我们更偏爱有相对完善类型系统的语言。从 native 到 TypeScript 过渡很舒服。另一方面,TypeScript 工具链做得很棒。用微软的 Visual Studio Code 写 TS 体验非常棒。我们的前同事羊羊羊也将在 10 月份的 QCon ( http://2017.qconshanghai.com/presentation/1146 ) 分享关于 TS 的相关内容。
唯一要花点功夫的地方就是怎么用 TypeScript 写 RN。GitHub 上有一些例子可以参考作为起步:
- ReactNativeTS ( https://github.com/mrpatiwi/ReactNativeTS )
- TypeScript React Native Starter ( https://github.com/Microsoft/TypeScript-React-Native-Starter )
经过一些配置之后,基本上就可以用 TS 写 RN 了。不过只要你在运行时设置断点,就发现调试器里的代码是编译过后的 JS 代码,而非原始 TS 代码。我们花了一些时间自定义了 RN 的 transformer,在 source map 上做了点文章,最后可以在调试器里直接定位到 TS 代码行。
早些时候本文提到 Pegasus 项目里有双端的壳工程。具体到开发阶段,因为做了各种接口抽象。在壳工程内营造一个『百姓网.app』环境就水到渠成了。 我们 mock 了各种宿主 app 依赖,将它们注入进 Pegasus。于是 RN 开发者就能在壳工程内开发和调试业务代码了。
这种壳工程的开发方式对于一次编译就要花很长时间的大型 app,可以大大提升开发体验。对好几个团队并行开发的 app,这种做法也很有意义。
哼,道理我都懂,但是我们的 app 没那么大,也没那么多团队。实际上大家还是更愿意直接在目标 app 内调试。: )
一般 app 会用类似 Crashlytics、Bugly 这样的崩溃收集工具。RN 层面的崩溃最后会以 native 的方式抛出来。但是 JS 调用栈信息基本是人不可读的。此时要用 source map 对其做转换。
百姓网用 Bugly 收集崩溃,我们写了一个 Chrome 插件一键解析 JS 调用栈内容。方便地定位到 TS 代码。这里分享一个教训:
- 打包完 JS 一定要保存好 source map 啊!!!
- 打包完 JS 一定要保存好 source map 啊!!!
- 打包完 JS 一定要保存好 source map 啊!!!
我们利用依赖注入,解决了 RN 模块的依赖问题。我们也利用 RN 提供的通信机制很好地处理了 JS 和 native 代码互通问题。经过一段时间的开发,RN 模块就要发布了。那么 Pegasus 怎么部署 RN 代码呢?
就是通常情况下怎么打包发版啦。实际上,在『如何集成』这一小节,我们已经谈过 Pegasus 代码的部署办法。就是把整个项目做成 native library 被 app 消费掉。利用包管理工具,把打包好的 JS 代码、图片资源、native 代码作为整体供 app 使用。这里不再赘述。
终于谈到动态部署了。其实这个词多少有点『噱头』的意味。动态部署代码这种事在国内大厂已经烂大街了。RN 代码能动态部署得益于其 JS 代码跑在 JavaScriptCore 上,不像 native 代码需要编译成二进制。所谓动态部署就是把新的 JS 代码下发到 app,让 app 去跑这些 JS 代码。这使得移动 app 能像 web 那样快速地修复线上 bug。
RN 代码部署工具其原理都是差不多的。公开的、比较出名的有微软的 CodePush,还有 AppHub。像资源比较丰富的大厂则会选择自建平台。考虑成本,百姓网移动技术团队选择使用 CodePush。
需要注意的是,苹果在年初下架了一些集成热更新方案的 app。尽管 RN 不是直接原因 ( facebook/react-native#12778 (comment) ),但在生产环境滥用 RN 特性会导致 app 挨刀。用 CodePush 要慎重啊 ( https://github.com/Microsoft/react-native-code-push#store-guideline-compliance )。鉴于应用市场审核速度越来越快(大概两三天就能上线),我们目前还是在内部和 beta 测试阶段使用 CodePush。
使用方式也很简单照着 CodePush 的官方文档集成就行。
#pragma mark - RCTBridgeDelegate
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
NSURL *bundleURL = nil;
if (self.configuration.developmentMode) {
bundleURL = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index.ios"
fallbackResource:nil];
} else {
bundleURL = [CodePush bundleURLForResource:@"Pegasus"
withExtension:@"js"
subdirectory:nil
bundle:[NSBundle pegasusBundle]];
}
if (!bundleURL) {
bundleURL = [[NSBundle pegasusBundle] URLForResource:@"Pegasus" withExtension:@"js"];
}
return bundleURL;
}
如果你的团队没有使用微软的 Visual Studio Mobile Center 全家桶,那么 CodePush 貌似只能用命令行来操作。只要熟读文档,操作起来也没什么难度。
说说我们用 CodePush 的体验吧。有开发者可能觉得当前严酷的互联网环境会对 CodePush 有影响,怕它部署慢,怕它被和谐了。我们用下来感觉还行:打包好 JS 和图片资源体积不大,就我们的业务而言可能就几兆。和动不动就要几十上百兆的巨型 app 不能比。整个包在 Wi-Fi 和 4G 下几秒就能下载完毕,大部分用户网络环境都还不错: )。
百姓网.app 选择的更新策略比较保守。在 app 启动时检查更新并在后台下载,下次启动时再生效。虽然降低了动态部署的实时性,但是保证了用户单次使用时功能的一致性。
RN 热更新除了部署 JS 代码,还要考虑 native 代码版本问题。一份 JS 代码到底能不能动态部署到某个版本的 app 内,完全取决于这份 JS 代码能不能在对方 native 代码上顺利执行。我们团队内部做了一张相当复杂的流程图来判定某份 JS 代码可以动态部署到哪些版本的 app 上。甚至定义了『可兼容变更』和『不可兼容变更』等晦涩的名词。其实简单来说,主要你 RN 模块所依赖的 native 代码没什么大的变更,基本上就能走热更新。
其他要做的就是小心维护 RN 模块的变更记录,仔细测试 app 和你 RN 库的兼容性。百姓网移动技术团队基本上 native,RN 和后端 API 开发都做,因此没有什么技术栈壁垒和沟通成本。倘若是多个团队维护一个 app,做 RN 模块的团队一定要和用你类库的团队多多沟通,提供详实、准确的文档。
至此,百姓网移动技术团队实现了基于 RN 的 app 动态化方案。让我们回顾一下:
- 百姓生意的经验让我们确定以 UIViewController 和 Activity 容器的方式来使用 RN。
- 我们把 RN 模块以 native library 的形式接入已有 app。
- 充分利用 RN 提供的通信机制,让一切成为可能。
- 通过依赖注入解决了 RN 模块对外依赖的问题。
- 使用 TypeScript 和相关工具,获取良好的开发体验。
- 利用 CodePush 完成动态部署。
百姓网目前的业务模式很适合 React Native 的发挥。我们『API -> UI』和『浏览器』特性是我们决定使用 RN 的关键。 React Native 开发这个话题很大,每一块都有很多东西可以说(比如怎么在 RN 直接调试 TS 呢 : ) )。限于篇幅我们只能覆盖一些关键设计决策。 软件开发从来都没有银弹,希望读者朋友能根据自身情况进行判断。真心希望本文能给大家带来些许启发。当然,Pegasus 还有许多可以改进的地方。如果你有什么好的意见或建议,请不吝告知,谢谢!
题图
沿用第一篇
摘要
这是系列文章的终篇,我们将继续讨论 RN 模块对外依赖的解决方案、开发、调试和动态部署问题。
作者署名
唐毅明
作者照片
沿用第一篇