项目接入使用React Native
有一段时间没有写东西了,因为最近项目开始尝试使用React Native(以下简称RN)来开发,所以这段时间一直在研究,目前为止开发的内容不多,所以使用过的东西也不算多,这里也只是做个简单的记录
这里我打算从以下几个方面来讲:
1.背景介绍
2.环境的配置
3.RN所需要知道的知识
4.RN与原生的交互
5.本地调试与本地打包调试
6.远程热更新
7.iOS和Android不同样式处理
8.踩坑记录
9.相关资料
0x00 背景介绍
RN是Facebook在React.js 2015大会上公布开源的,它是基于开源框架React.js来实现的,它支持了iOS和Android两大平台,解决开发者们编写重复代码的痛点,实现了所谓的跨平台开发,Learn Once , Write Anywhere,这是目前很多开发者所追求的,特别是一些独立开发者或者项目快速迭代的团队,可以尝试使用RN来开发,另外包括方便的npm管理,快速的调试等等
那么既然优点这么明显,为什么大部分的团队还是采用传统的iOS、Android开发呢,踩过坑的同学都知道,首先在支持上还做得不够完善,在使用组件时,RN原有提供的组件往往不能很好的支持,与原生组件多少存在着差异,而且在使用第三方组件时,又会因为长期不更新的原因,存在很多坑,对于新手来说,根本不知道坑在哪,完全无从下手。另外RN的性能也不能和原生的相提并论,特别是列表组件在渲染大量数据时,流畅性方面还是原生更加优越,而且并非所以代码iOS和Android都能公用,如果某个组件只支持某一个平台,那你必须分开编写代码,实际上还是存在重复代码,除此之外学习的成本以及团队RN推广等等原因都需要考量,但是我相信,跨平台开发始终是一个趋势,RN整个社区也在不断的发展,相信未来我们会实现真正意义上的跨平台开发~
0x01 环境配置
相对于Android的环境配置过程来说,iOS可以说是简单轻松…出现的问题要少很多
首先我们需要安装Homebrew
1 | $ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" |
然后安装node和watchman(用于监测文件系统的变更)
1 | brew install node |
RN的命令行工具react-native-cli
1 | npm install -g react-native-cli |
如果遇到权限问题,只要前面加个sudo即可
1 | sudo npm install -g react-native-cli |
yeah~that’s all~我在配置的过程中,基本没有报错,如果有出现配置问题的话,请自行Google一下,看看大家的解决方法
如果在原有iOS项目中集成的话,我们需要在package.json文件配置一下
1 | { |
并且Podfile里,导入需要的RN模块
1 | pod 'React', :path => '../node_modules/react-native', :subspecs => [ |
0x02 RN所需要知道的知识
RN的运行机制
在开始写代码之前,我们需要了解RN的运行机制是怎么样的,这样写起来思路会更加清晰
首先,程序需要有个入口,我们可以创建很多的组件,但是有且只有一个组件用来做为程序的入口,RN的入口则类似于iOS的main.m,在iOS里我们会在main函数里设置应用程序类的代理类
1 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([KDAppDelegate class])); |
同样,RN里我们需要注册入口的名称,并且这个名称要和原生的初始化RN界面时的入口名称保持一致
1 | // 引用navigation使用的组件 |
在iOS原生这边需要用到RN的地方,我们需要初始化它
1 | NSURL *jsCodeLocation = |
Tip:
1 | jsCodeLocation 是RN资源加载的路径,我们有两种方式去加载,一种是加载本地的js文件及其他资源文件,一种是我们将其打包成bundle文件,前者的优势在于方便调试,后者是用来打包发布上线用 |
moduleName
是对应于RN的入口名字,且这个是唯一的,那我们如果原生有多个入口需要初始化不同的RN界面,那该怎么办呢?这就用到了initialProperties
,它是字典类型,我们可以将入口作为路由,在initialProperties
里传入我们需要初始化的界面名称,入口获取到名称之后,渲染对应的界面即可
RN组件的生命周期
在RN里面,所谓的界面应该称作类或者组件更为合适
并且组件也有它的生命周期,和iOS里的viewWillAppear
、viewDidDisappear
等等很像,下面生命周期内容取自于http://www.race604.com/react-native-component-lifecycle/
我们可以把组件生命周期大致分为三个阶段:
- 第一阶段:是组件第一次绘制阶段,如图中的上面虚线框内,在这里完成了组件的加载和初始化;
- 第二阶段:是组件在运行和交互阶段,如图中左下角虚线框,这个阶段组件可以处理用户交互,或者接收事件更新界面;
- 第三阶段:是组件卸载消亡的阶段,如图中右下角的虚线框中,这里做一些组件的清理工作。
下面来详细介绍生命周期中的各回调函数。
getDefaultProps
在组件创建之前,会先调用 getDefaultProps()
,这是全局调用一次,严格地来说,这不是组件的生命周期的一部分。在组件被创建并加载候,首先调用 getInitialState()
,来初始化组件的状态。
componentWillMount
然后,准备加载组件,会调用 componentWillMount()
,其原型如下:
1 | void componentWillMount() |
这个函数调用时机是在组件创建,并初始化了状态之后,在第一次绘制 render()
之前。可以在这里做一些业务初始化操作,也可以设置组件状态。这个函数在整个生命周期中只被调用一次。
componentDidMount
在组件第一次绘制之后,会调用 componentDidMount()
,通知组件已经加载完成。函数原型如下:
1 | void componentDidMount() |
这个函数调用的时候,其虚拟 DOM 已经构建完成,你可以在这个函数开始获取其中的元素或者子组件了。需要注意的是,RN 框架是先调用子组件的 componentDidMount()
,然后调用父组件的函数。从这个函数开始,就可以和 JS 其他框架交互了,例如设置计时 setTimeout
或者 setInterval
,或者发起网络请求。这个函数也是只被调用一次。这个函数之后,就进入了稳定运行状态,等待事件触发。
componentWillReceiveProps
如果组件收到新的属性(props),就会调用 componentWillReceiveProps()
,其原型如下:
1 | void componentWillReceiveProps( |
输入参数 nextProps
是即将被设置的属性,旧的属性还是可以通过 this.props
来获取。在这个回调函数里面,你可以根据属性的变化,通过调用 this.setState()
来更新你的组件状态,这里调用更新状态是安全的,并不会触发额外的 render()
调用。如下:
1 | componentWillReceiveProps: function(nextProps) { |
shouldComponentUpdate
当组件接收到新的属性和状态改变的话,都会触发调用 shouldComponentUpdate(...)
,函数原型如下:
1 | boolean shouldComponentUpdate( |
输入参数 nextProps
和上面的 componentWillReceiveProps
函数一样,nextState
表示组件即将更新的状态值。这个函数的返回值决定是否需要更新组件,如果 true
表示需要更新,继续走后面的更新流程。否者,则不更新,直接进入等待状态。
默认情况下,这个函数永远返回 true
用来保证数据变化的时候 UI 能够同步更新。在大型项目中,你可以自己重载这个函数,通过检查变化前后属性和状态,来决定 UI 是否需要更新,能有效提高应用性能。
componentWillUpdate
如果组件状态或者属性改变,并且上面的 shouldComponentUpdate(...)
返回为 true
,就会开始准更新组件,并调用 componentWillUpdate()
,其函数原型如下:
1 | void componentWillUpdate( |
输入参数与 shouldComponentUpdate
一样,在这个回调中,可以做一些在更新界面之前要做的事情。需要特别注意的是,在这个函数里面,你就不能使用 this.setState
来修改状态。这个函数调用之后,就会把 nextProps
和 nextState
分别设置到 this.props
和 this.state
中。紧接着这个函数,就会调用 render()
来更新界面了。
componentDidUpdate
调用了 render()
更新完成界面之后,会调用 componentDidUpdate()
来得到通知,其函数原型如下:
1 | void componentDidUpdate( |
因为到这里已经完成了属性和状态的更新了,此函数的输入参数变成了 prevProps
和 prevState
。
componentWillUnmount
当组件要被从界面上移除的时候,就会调用 componentWillUnmount()
,其函数原型如下:
1 | void componentWillUnmount() |
在这个函数中,可以做一些组件相关的清理工作,例如取消计时器、网络请求等。
下表是生命周期函数的调用次数,以及能否使用 setSate():
生命周期 | 调用次数 | 能否使用 setSate() |
---|---|---|
getDefaultProps | 1(全局调用一次) | 否 |
getInitialState | 1 | 否 |
componentWillMount | 1 | 是 |
render | >=1 | 否 |
componentDidMount | 1 | 是 |
componentWillReceiveProps | >=0 | 是 |
shouldComponentUpdate | >=0 | 否 |
componentWillUpdate | >=0 | 否 |
componentDidUpdate | >=0 | 否 |
componentWillUnmount | 1 | 0 |
RN的设计模式
目前设计模式也非常多,如Flux,Reflux,Redux,Relay,Marty,不过以上都不是很了解,可以参考ReactNative的组件架构设计学习了解一下,由于做客户端的同学接触的最多的是MVC,MVVM、MVCS等等,所以我觉得选用类似MVCS的模式可能更加适合新手的学习,比如写组件时,通常我们会创建一个组件,里面会包含数据的处理,页面的渲染,样式的设置,网络请求,当这些内容过多时,组件就会显得特别臃肿,所以我们需要将其拆分开为数据模型(Model),页面渲染,样式设置,网路请求(Service),这里的页面渲染和样式设置,不能算是称作为iOS里的Controller和View,应该跟前端一样,在html文件里面写布局,css文件里面写样式,感觉像是MVCS和前端的融合
0x03 RN与原生的交互
在写RN时不免会遇到与原生交互,下面我分JS调用原生、原生调用JS来讲
JS调用原生
在调用原生时,我们需要实现RCTBridgeModule
和RCT_EXPORT_MODULE();
RCT_EXPORT_MODULE();
则是一个宏定义,返回moduleName,并且调用+ load
方法注册
1 | #define RCT_EXPORT_MODULE(js_name) \ |
例如我们增加一个bridge方法,获取版本号,getVersion
为方法名,callback
是原生回调给JS的内容
1 | RCT_EXPORT_METHOD(getVersion : (RCTResponseSenderBlock)callback) { |
然后返回方法的队列为主队列
1 | - (dispatch_queue_t)methodQueue { |
在JS文件里,我们可以定义一个全局变量
1 | var ZanIntentModule = NativeModules.ZanIntentModule; |
然后在使用的时候调用我们在原生时定义方法
1 | ZanIntentModule.getVersion( |
原生调用JS
老版本的调用方式为,但是接口被标记为deprecated:__deprecated_msg("Subclass RCTEventEmitter instead");
1 | [self.bridge.eventDispatcher sendAppEventWithName:kGiftReloadData body:nil]; |
新版本的调用方式为
1 | ZanEventEmitter *emitter = [[ZanEventEmitter alloc] init]; |
但是新版本坑的是,直接这样调用时bridge居然是nil,网上说用单例,但是也不行…所以我还是用老版本的调用方法,有哪个大神知道怎么用新版本接口调用的正确姿势,请留言交流哈
然后在实现RCTBridgeDelegate
1 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { |
在对应的组件里,需要在componentWillMount
增加监听
1 | componentWillMount() { |
对应的也需要移除掉监听
1 | componentWillUnmount() { |
然后原生发送action之后,会触发我们设定好的reloadData()
方法
0x04 本地调试与打包调试
在编写的过程中,也需要进行调试,调试有两种方法:一种是本地调试,一种是打包调试
本地调试
我们在加载bundle时,需要替换成你的ip地址,端口号不要变
1 | [NSURL URLWithString:@"http://172.17.9.94:8081/index.ios.bundle?platform=ios"] |
如果你是在真机上调试,你需要开启HTTP代理,填写你的ip地址和端口号
在终端上,先进入到你的项目目录(与node_modules目录同级),然后开启服务
1 | yzydeMacBook-Pro:shangjiaban-ios yzy$ npm start |
你修改了某处之后,在模拟器上点击Shake Gesture
或者快捷键,在真机上只要摇一摇就可以
在模拟器弹出框里选择Roload
,这样就会重新加载你本地的JS文件
如果你想查看JS里面的log日志,你可以选择Start Remote JS Debugging
,在chrome浏览器里就能看到输出的日志了
打包调试
另外一种就是打包调试,但是比较麻烦,首先我们要讲bundle加载方式改为
1 | [[NSBundle mainBundle] URLForResource:@"bundle/index.ios" withExtension:@"jsbundle"]; |
然后在终端里面,输入
1 | yzydeMacBook-Pro:shangjiaban-ios yzy$ react-native bundle --entry-file index.ios.js --platform ios --dev false --bundle-output ./xxx/bundle/index.ios.jsbundle --assets-dest ./xxx/bundle |
--bundle-output ./xxx/bundle/index.ios.jsbundle
指的是输出的bundle文件路径
1 | [20:54:43] <START> Building Dependency Graph |
当看到这样的信息的时候,说明已经打包成功了,再将生成的bundle文件夹以Create folder references
形式加到工程里,然后就可以run了
Tip:
1 | 在真机调试时,需要在Edit Scheme里在Run模式里,将Build Configuration改为Release模式 |
0x05 远程热更新
这块网上的方案大同小异,因为目前我们还是采取本地打包加载的方式,还未上热更新,所以在这不好多做说明,等上了热更新之后,我再来补充~
2016年11月23日更新:
Tips:
1 | 该热更新方法取自于有赞技术团队官方博客里《React Native有赞初探》,欢迎关注我们技术团队的博客~ |
选型
经过调研和选型,最终选择了微软出品的 CodePush
作为 React Native
热部署方案。
CodePush
是提供给 React Native
开发者直接部署移动应用更新给用户设备的云服务。CodePush
作为一个中央仓库,开发者可以推送更新 (JS, HTML, CSS and images),应用可以从客户端 SDK
里面查询更新。CodePush
可以让应用有更多的可确定性,也可以让你直接接触用户群。在修复一些小问题和添加新特性的时候,不需要经过二进制打包,可以直接推送代码进行实时更新。
CodePush
可以进行实时的推送代码更新:
- 直接对用户部署代码更新
- 管理
Alpha
,Beta
和生产环境应用 - 支持
JavaScript
文件与图片资源的更新 - 暂不支持增量更新
CodePush
开源了 react-native
版本,react-native-code-push托管在GitHub上。
具体的教程和用法微软都在 Github上
做了详细说明,接下来简单地梳理一下从配置、编码、部署等具体流程。
(1) 安装 CodePush CLI
管理 CodePush
账号需要通过 NodeJS-based CLI
。 只需要在终端输入 npm install -g code-push-cli
,就可以安装了。 安装完毕后,输入 code-push -v
查看版本,如看到版本代表成功。
(2) 创建一个 CodePush
账号 在终端输入 code-push register
,会打开如下注册页面让你选择授权账号。
授权通过之后,CodePush
会告诉你“access key”,复制此key到终端即可完成注册。
然后终端输入 code-push login
进行登陆,登陆成功后,你的session文件将会写在 /Users/你的用户名 /.code-push.config
。
(3) 在CodePush服务器注册app 为了让 CodePush
服务器知道你的app,我们需要向它注册app: 在终端输入 code-push app add
即可完成注册。
例如:
1 | code-push app add shangjiaban-android |
如果是iOS平台,命令为
code-push app add shangjiaban-ios
,Android
和iOS
必须要区分
还有很多 code-push app
相关的命令,参考如下:
1 | $: code-push app help |
iOS配置
iOS平台上关于 CodePush
的配置和 Android
平台是类似的,可以参考上文的(1)(2)(3),iOS平台集成 CodePush
比较简单,官网提供了3种集成方式,这里重点介绍如何通过 cocoapods
来集成。
(1) 引入 CodePush
首先在Podfile文件中添加 CodePush
,配置如下:
1 | pod 'CodePush', :path => './node_modules/react-native-code-push' |
然后执行 pod install
就可以了。
(2) 声明 bundle
文件来源
引入 CodePush
后还需要在代码中声明 bundle
的加载来源,之前是加载本地的bundle
文件,现在需要调用 CodePush
提供的方法指定加载 Bundle
文件,代码如下:
1 | #import "CodePush.h" |
最后还需要在 Info.plist
中添加一个 key
为 CodePushDeploymentKey
,其value
就是 CodePush
提供的唯一 token
值。具体获取方法,可以通过如下命令获得
1 | code-push deployment ls <appName> -k |
RN配置
为了达到更好的体验效果,我们决定采用静默升级的策略,让用户无感知地体验热更新,也可以是具体的升级流程图如下:
如果要达成上述热部署效果,那么还需要在 JavaScript
文件中完成更新时机和更新策略的设置。
(1)在js中导入 CodePush
模块:
1 | import codePush from 'react-native-code-push' |
(2)在 componentDidMount
中调用 sync
方法,后台请求更新
1 | codePush.sync() |
如果是非强制并允许更新, CodePush
会在后台静默地将更新下载到本地,等待APP再一次启动或者加载 React Native
页面的时候更新应用。
如果更新是强制性的,更新文件下载好之后会立即进行更新。关于如何配置是否强制更新,会在下文发布更新处重点说明。
如果你期望更及时的获得更新,可以在每次APP从后台进入前台的时候去主动的检查更新:
1 | AppState.addEventListener("change", (newState) => { |
上述流程图提及的三种更新方式,就是通过 installMode
参数控制的,取值方式分别为:
codePush.InstallMode.ON_NEXT_RESTART
即下一次启动的时候安装更新codePush.InstallMode.ON_NEXT_RESUME
即下一次切后台切换的时候安装更新codePush.InstallMode. IMMEDIATE
立即下载安装更新
如果发布更新时
mandatory
参数为true,即强制更新,则上述设置都会无效,只有mandatory
参数为fasle时,设置才会有效。
打包并发布
(1) 打包js 发布更新之前,需要先把js打包成 bundle
,以下是Android的做法:
第一步: 在 Android
工程目录里面新增 release
文件: mkdir release
,对于iOS来说,目前 bundle
文件直接放在工程根目录下,所以无需这一步。 第二步: 运行命令打包
1 | react-native bundle --platform 平台 --entry-file 启动文件 --bundle-output 打包js输出文件 --assets-dest 资源输出目录 --dev 是否调试。 |
例如:
Android
1 | react-native bundle --platform android --entry-file index.android.js --bundle-output ./release/index.android.bundle --assets-dest ./release --dev false |
iOS
1 | react-native bundle --platform ios --entry-file index.ios.js --bundle-output ./Koudaitong/main.jsbundle --assets-dest ./Koudaitong --dev false |
(2) 发布更新
打包 bundle
结束后,就可以通过 CodePush
发布更新了。在终端输入
1 | code-push release <应用名称> <Bundles所在目录> <对应的应用版本> --deploymentName: 更新环境 |
例如:
Android
1 | code-push release shangjiaban-android ./release 3.12.1 --description "update React Native" --mandatory true |
iOS
1 | code-push release shangjiaban-ios ./Koudaitong/main.jsbundle 3.12.0 --description "update React Native" --mandatory false |
注意:
CodePush
默认是更新Staging
环境的,如果是Staging
,则不需要填写deploymentName
。- 如果有
mandatory
则Code Push
会根据mandatory
是true
或false
来控制应用是否强制更新。默认情况下mandatory
为false
即不强制更新。 - 对应的应用版本
targetBinaryVersion
是指当前app的版本(对应build.gradle
中设置的versionName “3.12.1”),也就是说此次更新的js/images
对应的是app的那个版本。不要将其理解为这次js更新的版本。 如客户端版本是3.12.1,那么我们对3.12.1的客户端更新js/images
,targetBinaryVersion
填的就是3.12.1。 - 对于对某个应用版本进行多次更新的情况,
CodePush
会检查每次上传的bundle
,如果在该版本下如3.12.1已经存在与这次上传完全一样的bundle
(对应一个版本有两个bundle
的md5
完全一样),那么CodePush
会拒绝此次更新。
0x06 iOS和Android不同样式处理
最近看到FB的F8代码里面对于iOS和Android不同平台上样式的处理觉得挺不错的,由于系统原生控件样式设计风格的不一样,导致在写styles的时候会根据不同的platform来写,之前做法是定义不同的styles,然后判断platform去用,这样styles里面的代码会存在冗余,而且对styles的定义也不好
FB的做法是定义一个styles的基类,然后基类里解析平台信息
1 | export function create(styles: Object): {[name: string]: number} { |
解析完之后,在styles里面,会根据不同的platform取不同的样式
1 | button: { |
0x07 踩坑记录
踩坑最多是应该是使用上的
1.RN系统的组件并不是所有都是共用的,比如segment支持iOS,不支持Android,Alert分为iOS和Android等等,所以还是要写重复的代码
2.ListView不支持iOS原生的滑动操作,需要使用第三方库,但是第三方库不能控制只编辑一个Cell
3.由于原先iOS和Android的代码仓库是分开的,所以接入RN时,JS文件也是跟着仓库走的,这样iOS和Android会存在重复代码,并且目前两个人分别接iOS和Android,写JS时,有时并不共享,容易代码写着写着就有差异了,偏离了Write Once , Run Anywhere的初衷
0x08 相关资料
汇集了各类react-native学习资源、开源App和组件