历时一个多月的加班加点,我的第一个 React Native 应用终于开始交付测试。这篇文章给大家分享一点 React Native 路由架构的心得,我的技术有限,希望和大家多多交流学习。
开发 React Native 的应用时,第一个要解决的问题是环境搭建,第二个要解决的问题就是路由架构,只有在搭建好路由之后,后面的工作才能依次展开。
搭建路由系统推荐使用 react-navigation
这个官方推荐的组件,该组件有三种导航(路由)系统:
- 栈导航系统
StackNavigator
- 标签导航系统
TabNavigator
- 抽屉导航系统
DrawerNavigator
在我的应用中没有使用到抽屉导航系统,因此这里就不介绍这块相关的内容了(我也没看)。首先来看一下应用的基本结构。
PS.由于我比较懒,这里就不提供截图了,采用文字描述的形式,各位要是有不明白的地方可以问我。
应用基本结构
应用的基本结构如下:
闪屏和登陆
应用运行时,首先进入 Splash 闪屏,一段时间后跳转到登陆界面,登陆之后跳转到主页。在用户登陆时会有一个本地的持久化处理,如果用户登陆成功,那么下一次运行应用时,会直接跳转到主页。
主页
主页整体上是一个标签导航系统,整个标签导航系统分为四个标签:首页、数据、消息和我的。每个标签页中还拥有一些子路由,层次最多为三层,这个就不详细说了。
路由分层
根据前面的应用基本结构,可以将使用 APP 时的路由分为两层:从闪屏到登陆到主页为第一层,主页及其内部的路由为第二层。
分层之后,就可以搭建路由系统了。整体上采用栈式导航(StackNavigator
),将闪屏页、登录页和主页作为栈式导航的子路由,主页内部采用标签式导航(TabNavigator
)。
目录结构
我们采用如下的目录结构:
├─components
├─data
├─images
├─login
├─scene
├─tabs
│ ├─data_tab
│ │ └─dataComponents
│ ├─home_tab
│ ├─message_tab
│ └─Mine_tab
└─utils
下面解释下这些目录的作用:
- components:存放公用组件
- data:对接后端的 API,针对每个 tab 页面使用一个独立文件
- images:项目中用到的图片
- login:登陆界面
- scene:闪屏(Splash)界面
- tabs:存放主页中的界面,依据不同的 tab 进行子文件夹划分
- utils:公共函数和配置等
路由配置
先来配置主页中的各个 Tab:
// 引入路由组件
import {
StackNavigator,
TabNavigator,
} from 'react-navigation'
import {
Dimensions,
...
} from 'react-native'
// 获取屏幕宽度
const { width } = Dimensions.get('window');
// 闪屏界面
import SplashScreen from './scene/Splash'
// 登陆界面
import Login from './login/Login';
// 首页的一个界面
import HomeShowTab from './tabs/home_tab/HomeShowTab';
...
// 数据页的一个界面
import DataShowTab from './tabs/data_tab/DataShowTab';
...
// 消息页的一个界面
import MessageShowTab from './tabs/message_tab/MessageShowTab';
...
// 我的页的一个界面
import MineShowTab from './tabs/Mine_tab/MineShowTab';
...
// 定义首页 Tab
const HomeTab=StackNavigator(
{
HomeShowTab: {
screen: HomeShowTab,
},
...
},
{
headerMode: "screen"
}
);
// 定义数据 Tab
const DataTab=StackNavigator(
{
DataShowTab: {
screen: DataShowTab,
},
...
},
{
headerMode: "screen"
}
);
// 定义消息 Tab
const MessageTab=StackNavigator(
{
FirstScreen: {
screen: MessageShowTab,
navigationOptions: {title: "消息"},
},
...
},
{
headerMode: "screen"
}
);
// 定义我的 Tab
const MineTab=StackNavigator(
{
MineShowTab: {
screen: MineShowTab,
},
...
},
{
headerMode: "screen"
}
);
对于每一个 Tab 来说,它们内部应该使用栈式导航系统。
接下来,定义主页的标签导航:
// 底部菜单栏设置
const MainScreenNavigator = TabNavigator({
HomeScreen: {
screen: HomeTab,
navigationOptions: {
tabBarLabel: '首页',
tabBarIcon: ({ tintColor,focused }) => {
return(
!focused?
<Image
source={require('./images/tab_home_normal.png')}
style={[styles.icon]}
/>
:
<Image
source={require('./images/tab_home_pre.png')}
style={[styles.icon]}
/>
);
},
},
},
DataScreen: {
screen: DataTab,
navigationOptions: {
tabBarLabel:'数据',
tabBarIcon: ({ tintColor,focused }) => {
return(
!focused?
<Image
source={require('./images/tab_data_normal.png')}
style={[styles.icon]}
/>
:
<Image
source={require('./images/tab_data_pre.png')}
style={[styles.icon]}
/>
);
},
}
},
MessageScreen: {
screen: MessageTab,
navigationOptions: {
tabBarLabel:'消息',
tabBarIcon: ({ tintColor,focused }) => {
return(
!focused?
<Image
source={require('./images/tab_word_normal.png')}
style={[styles.icon]}
/>
:
<Image
source={require('./images/tab_word_pre.png')}
style={[styles.icon]}
/>
);
},
}
},
MineScreen: {
screen: MineTab,
navigationOptions: {
tabBarLabel:'我的',
tabBarIcon: ({ tintColor,focused }) => {
return(
!focused?
<Image
source={require('./images/tab_center_normal.png')}
style={[styles.icon]}
/>
:
<Image
source={require('./images/tab_center_pre.png')}
style={[styles.icon]}
/>
);
},
}
}
},
{
initialRouteName:'HomeScreen',
lazy:true,
animationEnabled: false,
tabBarPosition: 'bottom',
swipeEnabled: false,
tabBarOptions: {
activeTintColor: '#42aff4',
inactiveTintColor: '#999',
showIcon: true,
indicatorStyle: {
height: 0
},
style: {
backgroundColor: '#f0f3f5',
height: 0.13066667 * width,
justifyContent:"center",
},
labelStyle: {
fontSize: 0.0293333 * width,
marginTop:-0.008 * width,
},
}
}
);
主页整体采用标签式导航,将每个标签的 screen
指向前面定义的各个 Tab。
接下来加入闪屏和登陆,构建整体的导航系统:
// 整体路由系统
const RootNavigator = StackNavigator({
IndexScreen: {
screen: MainScreenNavigator,
},
Splash:{screen: SplashScreen},
Login:{screen: Login},
}, {
// 默认显示界面为 Splash
initialRouteName: "Splash",
mode: 'card',
headerMode: 'none',
});
然后导出我们配置的路由系统就可以了:
export default class MyAPP extends Component {
render() {
return (
<View style={styles.container}>
<RootNavigator />
</View>
)
}
}
至此,我们的导航系统就搭建好了,这是一个比较通用的系统,基本可以适用于一般的应用了。构建导航系统之后,剩下的工作就是在项目目录中添加各种各样的组件,以及使用 navigate
方法进行页面间的跳转了。
如果你是开发 IOS 应用,这样的架构就已经足够了,但如果你还要同时适配 Android(一般都会),就还需要做一点工作。
Android 的返回键问题
还记得吗?我们的应用是从 Splash 闪屏开始,根据用户是否登陆跳转到登陆界面或者主界面,在 IOS 下是没有问题的,但在 Android 下,由于返回键的存在,当跳转到登陆或者主界面时,还可以按返回键返回到 Splash 界面或者登陆界面,这显然是不合常理的。因此,在 Android 下,需要我们手动的对返回键进行处理。这就需要使用到 BackHandler
组件。
我们需要在两个界面对 BackHandler
组件进行处理:一个是登陆界面(阻止返回到 Splash 界面),另一个是在首页 Tab 的第一个界面(阻止返回到登陆界面)。在这两个界面中,我们需要对 BackHandler
进行事件监听,在用户连续点击两次返回键时退出应用,阻止默认的返回事件。
要完成这个功能,需要用到两个神器:react-navigation-is-focused-hoc 组件和 react-native-exit-app组件。
两个实用的组件
react-navigation-is-focused-hoc
是用来判断某个页面是否处于 Focus 状态。为什么需要这个组件呢?在 Android 上,当我们在某个界面对物理返回键进行事件监听时,会影响到所有界面的物理返回键功能,因此我们需要在跳转到其他界面之前移除对物理返回键的事件监听,在跳转回来时重新绑定事件监听。
跳转到其他页面时移除事件监听还好说,但是怎么对跳转回当前界面进行判断呢?因为有些跳转是通过 navigation.goBack()
进行的,并不会触发组件的生命周期,所以判断是相当麻烦的。react-navigation-is-focused-hoc
这个组件就是帮助我们来解决这个问题的。
PS.后续版本的 react-navigation
组件可能会开发相应的生命周期函数,请参考 #51。
react-native-exit-app
这个组件是干嘛的呢?这是因为我们在连续两次点击返回键时需要退出应用,如果使用 BackHandler
自带的 exitApp()
方法,无法完全结束应用的进程(参见#13483),导致下一次进入应用时返回键失效,因此我们需要使用 react-native-exit-app
这个组件实现应用的完全退出。
具体应用
下面是这两个组件的使用方法:
1.对跟路由组件的 onNavigationStateChange
事件进行监听:
import { updateFocus } from '@patwoz/react-navigation-is-focused-hoc'
...
export default class MyAPP extends Component {
render() {
return (
<View style={styles.container}>
<RootNavigator
onNavigationStateChange={(prevState, currentState) => {
updateFocus(currentState)
}}
/>
</View>
)
}
}
2.对要监听物理返回键的界面进行处理:
import { withNavigationFocus } from '@patwoz/react-navigation-is-focused-hoc'
import RNExitApp from 'react-native-exit-app';
class HomeShowTab extends PureComponent {
...
// 应用更新时绑定/解绑事件
componentDidUpdate(prevProps) {
const { isFocused } = this.props;
if(isFocused){
this.preventBackEvent();
}else{
this.removeBackEvent();
}
}
preventBackEventHander(){
const time = +new Date();
this.refs.toast.show("再按一次退出应用")
if(!this.exitTimeFlag){
this.exitTimeFlag = time;
return true;
}
// 2500ms 内连续按键退出应用
if(time - this.exitTimeFlag < 2500){
this.removeBackEvent();
this.timer = setTimeout(()=>{
clearTimeout(this.timer);
RNExitApp.exitApp();
},200);
}
this.exitTimeFlag = time;
return true;
}
// 绑定事件监听
preventBackEvent(){
BackHandler.addEventListener("hardwareBackPress",this.preventBackEventHander)
}
componentWillUnmount(){
this.removeBackEvent();
}
// 移除事件监听
removeBackEvent(){
BackHandler.removeEventListener("hardwareBackPress",this.preventBackEventHander)
}
...
}
然后,使用 withNavigationFocus
高阶组件进行一次包装即可:
export default withNavigationFocus(HomeShowTab)
可见,react-navigation-is-focused-hoc
的原理是对跟路由组件的 onNavigationStateChange
事件进行监听,当发生路由跳转时,将属性传递到对应的组件,以实现对界面是否处于 Focus 的判断。
安卓返回键问题的其他解决方案
针对安卓返回键的问题,我还看了其余的两个解决方案:
- 集成
Redux
,参见这里 - 使用
getStateForAction
手动对路由栈进行管理
对于中小型的应用,没有必要使用 Redux
,而对于使用 getStateForAction
手动对路由栈进行管理太过麻烦,需要考虑很多情况,我也没有研究透。
因此,对我个人而言,使用 react-navigation-is-focused-hoc
和 react-native-exit-app
这两个组件是比较好的解决方案,这两个组件帮助我解决了安卓返回键这一大痛点,因此我将它们称为神器。
完。