[关闭]
@qinyun 2018-12-29T11:46:38.000000Z 字数 13050 阅读 2328

基于React Native的跨三端应用架构实践

未分类


一次编写,到处运行”(Write once, run anywhere )是很多前端团队孜孜以求的目标。实现这个目标,不但能以最快的速度,将应用推广到各个渠道,而且还能节省大量人力物力。

React Native的推出,为跨平台的开发带来了新的曙光。 虽然Facebook官方blog的说法React Native支持“Learn once, write anywhere.”。

但经过开源社区的不断努力,React Native已经可以达到“一次编写,到处运行”的目标。可以说超过了Facebook的预期。作者在最近的几个项目中,运用React Native技术,成功实现跨越iOS,Android,Web三端的前端架构。这里将使用到的技术和过程中遇到的困难和问题揭示出来,供读者探讨。

技术选型

我们的目标是希望一套代码同时支持iOS,Android App和微信公众号内的网页(同时保留将来支持桌面浏览器的能力)。在开始重构之前,我们盘点了目前可用的一些技术:

① SPA:single page web application,就是只有一张html页面的应用。仅在该Web页面初始化时加载相应的HTML、JavaScript、CSS。一旦页面加载完成,SPA不会因为用户的操作而进行页面的重新加载或跳转,而是利用JavaScript动态的变换HTML(采用的是div切换显示和隐藏),从而实现UI与用户的交互。

② MPA: multipage web application, 相对于SPA,MPA有多个html页面。页面间跳转刷新所有资源,公共资源(js、css等)需选择性重新加载。

本人于2012年开始接触Cordova & Ionic,应该说Cordova 在React-Native出现之前确实是跨平台的主流技术。但是现在是2018年,Cordova 在性能上肯定达不到我们的要求,首先被pass掉。
 
Vue.js也是我们团队的备选前端框架,主要用于桌面浏览器展示的项目。缺乏原生移动解决方案,以及实际用下来感觉template表现力比不上JSX。另外我们用到了蚂蚁金服优秀的前端控件库ant design mobile, 暂时不支持Vue。
 
2018年7月份我们对Flutter(0.5.1) 和React-Native(0.51.0)进行了一次性能比较测试。我们在Android上用Flutter和React-Native分别实现了一个含图文的新闻客户端,比较了页面加载,图片加载,页面跳转等关键性能。实测下来Flutter在List加载,跳转到详情页时都有明显掉帧。另外代码无法移植到web上。这些原因导致我们放弃了Flutter。

最终我们选择了React-Native作为我们项目的实现技术,除了上述的一些优点之外,我们在如下一些方面收益颇多。

项目架构

我们在项目中用到的前端整体架构如下图:

以下对上图中一些技术点进行介绍:

应用支持层

作为应用和后台服务&原生App之间的桥梁,应用支持层需要处理诸如端到端通讯,数据加密解密,数据缓存,数据拦截,原生应用功能访问等基础服务。最大限度的屏蔽掉平台间差异,让位于其上的层尽量做到平台无关。

原生模块封装

React-Native 可以方便的封装原生应用模块。对于有UI的原生模块,既支持在一个新的ViewController(Activity)中展示, 也支持将其封装成一个View,嵌入到React-Native的上下文中。 这也是React-Native最接地气的特性,远超Cordova。在一些场景下需要等待原生模块中的事件,诸如用户操作等异步事件之后才能返回,这时需要用到Promise作为原生模块的参数。
 
比如通过调用手机摄像头,对银行卡进行扫描,这时会调用原生第三发控件的ScanCardViewController进行扫描,扫描结果通过代理函数回调。整个调用和回调的流程无法直接在一个函数中完成,这时可以用React native的Promise 实现对JS端Promise的无缝对接。

  1. @protocol RCTBankCardScannerDelegate <NSObject>
  2. -(void)onScanCardResult:(NSDictionary *) result;
  3. @end
  4.  
  5. @interface RCTBankCardScanner()<RCTBankCardScannerDelegate>
  6. @property(nonatomic, strong) RCTPromiseResolveBlock resolveBlock;
  7. @property(nonatomic, strong) RCTPromiseRejectBlock rejectBlock;
  8. @end
  9.  
  10. @implementation RCTBankCardScanner
  11. RCT_EXPORT_MODULE();
  12. RCT_REMAP_METHOD(scan, resolver:(RCTPromiseResolveBlock)resolve
  13.                  rejecter:(RCTPromiseRejectBlock)reject)
  14. {
  15.   //异步调用,函数本体不返回,需要保留resolve,和reject函数指针
  16.   self.resolveBlock = resolve;
  17.   self.rejectBlock = reject;
  18.   //跳转到扫描银行卡控件的ViewController
  19.   ScanCardViewController * viewController = [ScanCardViewController new];
  20.   UIViewController *rootViewController = RCTPresentedViewController();
  21.   [rootViewController presentViewController:viewController animated:YES completion:nil];
  22. }
  23.  
  24. #pragma mark RCTBankCardScannerDelegate
  25. -(void)onScanCardResult:(NSDictionary *) result
  26. {
  27.   // 在原生ViewController回调处,再返回Promise的处理结果
  28.   if(result != nil && [[result objectForKey:@"code"] isEqualToString:@"0"]){
  29.     if(self.resolveBlock != nil){
  30.       self.resolveBlock(result);
  31.     }
  32.   }else if(result != nil){
  33.     if(self.rejectBlock != nil){
  34.       self.rejectBlock([result objectForKey:@"code"], @"failed", nil);
  35.     }
  36.   }else{
  37.     if(self.rejectBlock != nil){
  38.       self.rejectBlock(@"-100", @"invaild response", nil);
  39.     }
  40.   }
  41. }

上述代码实现了银行卡扫描控件的封装。调用scan函数的时候会新启动摄像头,完成身份证扫描识别之后将结果传回JavaScript.在JavaScript中,可以通过

  1. import {NativeModules} from 'react-native'
  2. const BankCardScanner = NativeModules. BankCardScanner
  3. const { code, no } = await BankCardScanner.scan()

实现对原生层的异步调用,并等待ScanCardViewController完成并回调。

后台接口封装

到服务器的端到端访问通过继承BaseService类实现.BaseService负责处理跟服务端交互,加密,解密,错误处理等。

  1. import BaseService from '../common/base-service'
  2. import Page from './Page'
  3. export default class DemoService extends BaseService {
  4. constructor(props) {
  5. super(props)
  6. this.page = new Page(this.getDemoList.bind(this))
  7. }
  8. /**
  9. * 获取示例列表详情
  10. */
  11. async getDemoList (params) {
  12. const res = await this.postJson('getDemoList', params)
  13. return res
  14. }
  15. }

Page类实现了对分页数据的加载和存储封装,使其与页面解除耦合。通过指定支持分页的方法,可以实现分页加载。

PaginationHoc则封装了需要暴露给页面的分页相关方法,包括获取设置支持分页的Service,获取分页对象,加载下一页数据,设置搜索参数等。

一个包含分页的页面例子如下:

  1. @Pagination
  2. @Loading
  3. export default class DemoPage extends Component {
  4. constructor(props) {
  5. super(props);
  6. this.props.setService(new DemoService(this.props));
  7. }
  8. async componentDidMount() {
  9. await this.props.loadMore();
  10. }
  11. render() {
  12. return (
  13. <View>
  14. <FlatListView
  15. style={styles.list}
  16. data={this.props.getPage().list}
  17. renderItem={this.renderRow.bind(this)}
  18. hasMore={this.props.hasMore()}
  19. onEndReached={this.props.loadMore.bind(this)}
  20. />
  21. </View>
  22. );
  23. }
  24. }

全局异常捕获

在web开发中,可以使用window.onerror = function(){message, source, …} 来捕获未处理的JavaScript错误。但是对于一个遍布异步调用的复杂应用来说,window.onerror没太大用。通常需要捕获的是未处理的异步调用异常,即unhandled rejection。

在web中,unhandled rejection可以通过收听'unhandledrejection'事件来处理。

  1. window.addEventListener('unhandledrejection', function(event) {
  2. const error = event.reason
  3. handleErrors(error);
  4. })

增加了全局'unhandledrejection'事件监听之后,依然可以通过try catch实现对某个异常的自定义处理,这时全局'unhandledrejection'事件监听就不会被调用到。如:

  1. try{
  2. await this.service.getDemoList();
  3. } catch (error) {
  4. Modal.alert(‘数据获取异常’)
  5. }

Promise目前在WebKit系的浏览器支持的比较好,如果需要在非Webkit内核浏览器上使用,通常需要添加polyfill。这里需要注意的是项目不能采用promise-polyfill。因为promise-polyfill的实现没有考虑到'unhandledrejection',并且会覆盖浏览器原生的Promise实现。我们选用的是es6-promise-promise库作为Promise的polyfill方案。
 
对于react-native。异步异常捕获未见于其官方文档。但react-native的Promise模块引用的是Then Promise 。Then Promise对于'unhandledrejection',提供了处理钩子函数:

  1. require('promise/lib/rejection-tracking')
  2. .enable({
  3. allRejections: true,
  4. onUnhandled: function(id, error){
  5. ...
  6. }
  7. });

需要注意的是Then Promise对onUnhandle的默认定义是: 2秒钟内没有被处理的Promise rejection,因此错误处理时一定要考虑到这2秒钟的等待时间。

应用状态层

相信本文读者应该多少了解通过Flux、 Redux、VueX来管理前端应用状态的意义了。严格说来, 前端应用就是一个通过渲染层,将状态渲染出来,并通过响应事件来修改状态的单向数据流模型。对于状态管理库的选择和应用场景,我们在前后几个项目中经历了多次尝试。最开始我们使用Redux,尝试按照单向数据流的原教旨主义,通过Redux管理应用的全部状态,效果不理想,主要问题有以下几点:

1. 跟后台的异步交互所获得的数据,如果全部通过Redux Store管理,写法太繁琐。
 
2. 同一个页面组件在不同场景(路由)下,访问同一个Store。数据到底是清空呢,还是不清空呢?这是一个视具体情况而定的问题。
 
3. 需要多次异步请求才能完成的操作,需要用Saga之类的中间件处理,比较麻烦。

后面的项目中我们试图完全不用状态管理库,回到依赖React组件的State来管理状态,实操下来发现难以为继,特别是有主页面和承接页面的情况下,如果承接页的交互,会反映到主页面的情况下,很难通过纯粹的页面内State来实现。
 
经过摸索,我们最后在架构中采用了MobX来作为应用全局状态管理器。同时相对弱化了Store的地位,仅仅在一些需要采用Store的地方利用Store。经验看来以下场景中利用Store是比较好的设计模式:
 
1. 管理会话状态,处理用户登录,登出状态时,通过Action & Store隔绝视图层和后台服务调用,视图层不需要处理登录后跳转到具体页面,会话超时需要调转到登录页等具体而繁琐的逻辑。只需要通过Action来调用封装好的方法即可。
 
2. 主页面跳转到承接页,承接页进行交互之后,需要主页面UI进行更新的场景。比如主页面是一个待录入的产品列表,其中有一项“生产厂商”需要跳转到承接页面中选择,选择完成之后回到主页面,并把选中的厂商名字显示在主界面上。可以在承接页面中通过Action修改Store,主页面中监听Store的变更实现。

3. 不希望频繁从服务器获取的数据,比如产品列表数据,错误类型数据字典,也可以存入Store。

虚拟Dom层

以往手机浏览器中复杂页面的性能优化往往要付出巨大的代价。究其原因是因为手机浏览器DOM渲染的性能远远落后于JavaScript执行引擎的性能。而且不同层次(layer)的Dom结构和属性变化,会导致浏览器的重绘 (redraw)和重排(reflow),需要付出高昂的性能代价。这也是为什么基于Cordova的混合应用,受其性能影响,不适合做有复杂用户交互,且重视用户体验的应用的深度原因。
 
而React创造性的用虚拟Dom解决的这个问题。虚拟DOM,以及其高效的Diff算法。这让我们在大部分情况下直接让页面重绘,而不用担心性能问题,由虚拟DOM来确保只对界面上真正变化的部分进行实际的DOM操作。
 
虚拟Dom带来的另一个好处是构建了超越平台的Dom语言(JSX),使得原来浏览器界用于描述界面结构的Dom语言,能够以最小代价适用于其他各种原生应用平台。在这个领域已经涌现出了部分优秀的开源框架。

经过对比,我们选用 react-native-web作为react-native在Web上的实现。 react-native-web是一个通过将react-native的组件和APIs在Web上重新实现,使得react-native应用经过少量更改,可以在浏览器上运行的开源项目。官方宣称支持到react-native 0.55, 但是我们实测下来,兼容react-native 最新版 (截止项目结束时) 0.57.4没什么问题。

公共模块层

选择了react,我们就拥有了大量成熟的开源库,包括UI组件和工具类库。但是前端的技术迭代周期是非常快的,今年流行的库,明年说不定就out了。

架构设计时必须要考虑前端页面跟具体控件解除耦合。我们的做法是设计出一套标准的控件IDL(接口描述语言),作为媒介沟通页面跟具体组件实现。比如我们用到了某一个开源的UI组件,我们会根据实际业务抽象出一份标准接口,对开源组件进行二次封装之后再调用。这样即使后续需要更换其他组件,也不需要对页面进行改动。

所有的UI组件,不论是我们自己造轮子写的,还是开源的,都是按照:1.定义IDL -> 2.进行封装 -> 3.实现并上传cnpm服务器 -> 4.项目depencency中引用来自cnpm的组件IDL。 这样的流程来进行引用。

高阶组件层

在函数式编程的中,Hoc(高阶组件)被广泛的用于组件中公共功能的复用,以及函数式编程的方式实现组件的扩展。我觉得讲Hoc讲的比较好的一篇文章是:《React Higher Order Components in depth》(https://medium.com/@franleplant/react-higher-order-components-in-depth-cf9032ee6c3e) , 把Hoc的几种应用场景都讲的比较透,而且还有github代码直接可以拿来用。

这里结合我们项目中用到Hoc的场景,稍微展开一下。比如大家都知道React不像Vue提供了v-model的语法糖实现双向数据绑定(MVVM)。如果一定要双向绑定怎么办呢?可以利用Input-Hoc实现:

  1. export default function InputHoc(OriginalComponent) {
  2. return class ComposedComponent extends Component {
  3. constructor(props){
  4. super(props);
  5. this.state = {
  6. inputs: {
  7. }
  8. }
  9. }
  10. getInput(name, params){
  11. if(!this.state.inputs[name]){
  12. this.state.inputs[name] = {
  13. value: '',
  14. onChange: value => this.state.inputs[name].value = value
  15. }
  16. }
  17. return {
  18. value: this.state.inputs[name].value,
  19. onChange: this.state.inputs[name].onChange
  20. }
  21. }
  22. render() {
  23. const props = {...this.props, ...{
  24. getInput: this.getInput.bind(this)
  25. }};
  26. return (
  27. <OriginalComponent {...props} />
  28. );
  29. }
  30. }
  31. }

需要双向绑定的时候,对页面进行Hoc扩展。

  1. @Input
  2. export default class Login extends Component {
  3. render() {
  4. return (<TextInput {...this.props.getInput('username')} />)
  5. }
  6. }

统一路由层

在跨平台实现的过程中,最让人感到头疼就是页面路由的实现。因为iOS,Android,H5各自有其导航方式和偏好。难点在于设计一套通用的路由规则,适用于三个平台。

首先我们考查了react-router。react-router分react-router-dom 和 react-router-native,支持react.js 和 react native。实测下来react-router-native的页面导航方式看起来跟SPA一样,只是页面内容的替换,不支持原生导航的堆栈。这种导航方式的用户体验非常糟糕。比如用在App上,通常可以用拖动屏幕的手势返回到前一屏。在这个手势转场动画中,当前页面和上一页面都可以展示出来。如下图:

手势转场动画形象的揭示了在原生应用中,页面是以堆栈的方式存放的。在这个前提下,我们选用了react-navigation作为我们原生底层实现。
 
原生导航的问题解决后,另一个react-router的常见问题开始困扰我们,即H5上页面保持的问题,比如上图中:客户列表是一个支持下拉分页,加载更多的列表页。

点击一行,可以进入客户详情页。这时从客户详情返回到客户列表页,需要保持客户列表页之前的选中行位置。在原生应用中,由于页面堆栈的存在,这不是一个问题。但是在H5中使用react-router从客户详情返回到客户列表。客户列表组件是需要重新创建,重新渲染的。
 
最终我们改用react-keeper作为我们web层的底层实现,解决了上述问题。并基于react-keeper的路由风格,结合原生的特性,打造了跨平台的统一路由组件,有效解决了上述的几个问题。统一路由的设计如下图:

编译系统

为了同时支持react-native和web,项目打包分别采用了metro-bundler和webpack对代码和资源进行编译。在web项目中,为了提升页面加载速度,对生成代码进行了分chunk,实现按需加载。

webpack目前提供了一个很好用的插件webpack-bundle-analyzer,直接在编译的时候做代码分析,并生成分析报告。

可以通过分析bundle中打入了哪些模块。对应于一些非必须的模块, 可以用require.ensure 进行动态加载,或者移到common bundle里面去。这样一点点的优化主bundle的大小。最后我们成功的控制了首页加载小于1MB。

其他问题

MobX实践中遇到的问题

经过各种尝试,最终我们在项目中采用了MobX 4.3.1版。 在实际使用过程中,发现几个需要注意的地方:

1) 只有通过render函数体,或者其子组件所渲染的内容,才能响应observer的变化。如果传递一个render回调函数给子组件,其内容不会自动响应observer的变化。

比如下面的情况,this.store是observable对象。在this.store.text发生变化时,render方法不会重绘。

  1. const TabBars = ({content}) => {
  2. {this.props.content}
  3. }
  4. @inject(‘store’)
  5. @observer
  6. class Page extends Component{
  7. render() {
  8. return <TabBars content={<this.content/>} />
  9. }
  10. content = () => {
  11. return <Text>{this.store.text}</Text>
  12. }
  13. }

2) 如果采用 mobx-react 修饰页面,最好保证@observer是需要渲染observable对象的组件外的第一层Hoc,这样可以有效避免内层Hoc不是直接将原始组件在render函数,或者其子组件中渲染出来的问题。比如:

React-native对Base64图片的支持问题

react-native的Image组件允许使用 base64编码的图片作为uri。 但是实测下来发现在iOS平台上有时候base64的图片显示不出来。具体场景是使用安全键盘时,从服务器获取乱序的字母数字Base64编码后的图片显示在iOS平台上,如果直接使用 \<Image  source={{uri: base64Image}} />,在多次大小写键盘,数字和字母键盘之间切换后,部分键盘按钮图片会显示不出来:

但是通过XCode的截屏工具连手机截图,截取出来的图片又是正确的。经排查发现是react-native的Image组件绘制的问题,其github的issue list里面也承认,并推荐升级到0.57版本(https://github.com/facebook/react-native/issues/21890)。但是升级0.57.4之后,并没有解决这个问题。
 
因此我们只能想办法绕过base64,既然react-native Image对普通url的图片显示没有问题,只对base64编码的支持有问题。那么就需要将base64转换成普通图片url方式。我们采用的是修改原生层代码,直接在原生层将base64转换成二进制图片格式,react-native的js代码通过特定的url去访问这个图片。

首先需要拦截React-Native的网络请求,这就是原生AOP服务所做的事情。以iOS为例,我们找到react-native iOS源代码的RCTHTTPRequestHandler.mm. 有这么一段:

  1. - (NSURLSessionDataTask *)sendRequest:(NSURLRequest *)request
  2.                          withDelegate:(id<RCTURLRequestDelegate>)delegate
  3. {
  4.   // Lazy setup
  5.   if (!_session && [self isValid]) {
  6.     NSOperationQueue *callbackQueue = [NSOperationQueue new];
  7.     callbackQueue.maxConcurrentOperationCount = 1;
  8.     callbackQueue.underlyingQueue = [[_bridge networking] methodQueue];
  9.     NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
  10.     [configuration setHTTPShouldSetCookies:YES];

可以通过替换掉defaultSessionConfiguration,来达到对http请求进行拦截的目的。当然可以直接修改react-native的代码,不过我偏向于利用Objective-C的 method swizzling:

  1. @implementation NSURLSessionConfiguration (extend)
  2. +(void)load {
  3.   static dispatch_once_t onceToken;
  4.   dispatch_once(&onceToken, ^{
  5.     [self swizzleClassMethod:@selector(defaultSessionConfiguration)  withMethod:@selector(aopDefaultSessionConfiguration)];
  6.   });
  7. }
  8. +(NSURLSessionConfiguration *) aopDefaultSessionConfiguration{
  9.   NSURLSessionConfiguration * instance = [self aopDefaultSessionConfiguration];
  10.   Class secureKeyboardURLProtocol = NSClassFromString(@"AOPURLProtocol");
  11.   if (secureKeyboardURLProtocol){
  12.     instance.protocolClasses = @[AOPURLProtocol];
  13.   }return instance;
  14. }
  15. @end

然后我们就可以定义自己的NSURLProtocol来对特殊url的请求进行拦截了。

  1. @implementation AOPProtocol
  2. + (BOOL)canInitWithRequest:(NSURLRequest *)request {
  3.   if (request != nil) {
  4.     NSURL* url = [request URL];
  5.     if(url.scheme != nil &&  [url.scheme isEqualToString:@"demo"]){
  6.       return YES;
  7.     }
  8.   }
  9.   return NO;
  10. }
  11. - (void)startLoading{
  12.   NSURL *url = [self.request URL];
  13.   NSString * path = [url.absoluteString stringByReplacingOccurrencesOfString:@"demo://" withString: @""];
  14.   NSData * imgData = [SecureImage imageWithPath: path];
  15.   NSDictionary * headersDict = [NSDictionary dictionaryWithObjectsAndKeys:[NSString stringWithFormat:@"%ld", [imgData length]], @"Content-Length",@"image/png",@"Content-Type",nil];
  16.     NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[self.request URL] statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:headersDict];
  17.     [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
  18.     [self.client URLProtocol: self didLoadData:imgData];
  19.     [self.client URLProtocolDidFinishLoading: self];
  20.   }
  21. }

这样,在前端通过请求 demo://开头的,按一定规则索引的url,就可以返回对应的png图片,顺利绕过base64图片的问题。
 

RN对中文输入的支持问题

在react-native 0.57之前,如果像这样写:

  1. <TextInput value={this.state.value} onChange={val => this.setState({value: val})} />  

会面临中文输入时无法输入的问题,解决办法是不做value 绑定,而是通过ref来获取值。当然这样input-hoc也没法用了。

好在react-native0.57之后,Facebook修复了这个问题。

WebView 相关问题

虽然在绝大部分的常见,React-Native的性能都要超过WebView。但是由于React-Native上目前还缺乏可以媲美highbharts, e-charts的报表组件,所以需要绘制报表的时候,还是需要通过WebView内嵌html的方式实现。

在使用WebView时,遇到的问题有两个:

1.viewport:  页面指定viewport为device-width的话,会按屏幕宽度来展现页面内容。 如果希望webview内容不按整个屏幕宽度显示,则需要计算好viewport的宽度,并传入webview里面的html中。

2.Android :  android上webview不支持 require方式加载的html资源文件。比如
<WebView source={require('../../components/charts/charts.html')} />

在iOS上没问题,但是在Android上实际加载不了。解决的办法是要么把html文件放进android的assets目录,要么通过网络加载。

如:

  1. <WebView source={Platform.OS === 'android' ? 'file:///android_asset/charts/charts.html' :
  2. require('../../components/charts/charts.html')} />

总结

本文介绍了我们基于React-Native构建跨平台的前端应用架构中的一些实践经验,以及期间踩的一些坑。希望通过开放的描述我们的技术实现,抛砖引玉供大家探讨,得到有益的改进意见和建议。

作者简介:

陈子涵,7年以上前端&移动架构,跨平台应用架构设计和开发经验。曾在SAP Labs,远景能源负责移动和云产品相关设计和开发工作。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注