如何在 React Native + React Navigation 的项目中优雅地处理 Android 回退按钮事件

3,287 阅读6分钟

在 Android 手机上回退按钮是经常会用到一个功能,可以用来返回上一个页面,或者是用来取消某项操作等等,总之十分方便,今天就给大家介绍一下如何在 React Native(以下简称 RN)项目中结合 React Navigation 进行回退按钮的处理。

为什么是React Navigation?因为它已逐渐成为 RN 项目路由管理工具的标配,RN 的官方文档推荐使用 Expo 来创建项目,而在 Expo 中则推荐使用React Navigation来做路由管理。

RN 的回退按钮事件

RN 提供了专门处理回退按钮的 API —— BackHandler,它的使用方式跟其他事件监听器十分类似,通过传入一个事件名和事件函数就可以完成事件的绑定,代码示例如下:

1
2
3
4
  BackHandler.addEventListener('hardwareBackPress', function() {
    // do something
    return true;
  })

注意:这里不同于其他事件监听器的地方是必须return 布尔值,因为回退按钮的事件是反顺序触发的,即最后一个注册的事件会被最先触发,return true表示不会再继续调用上一级的回退按钮事件,反之return false则表示会调用。

这个 API 在使用上虽然没有什么问题,但是有一点比较麻烦:如果多个组件同时需要处理回退按钮事件,那就需要在多个组件上添加事件,而且还要注意组件间的事件调用关系,比较麻烦。

另外回退按钮事件的添加和移除需要在组件的生命周期方法mountunmount中进行:

1
2
3
4
5
6
7
  componentDidMount() {
    BackHandler.addEventListener('hardwareBackPress', this.handleBackPress);
  }

  componentWillUnmount() {
    BackHandler.removeEventListener('hardwareBackPress', this.handleBackPress);
  }

但由于有些一级页面是常驻路由堆栈中的,因此页面的事件被添加之后,即使页面进行了跳转,页面中的事件也不会被销毁,这样就会造成一些性能上的损耗。

React Navigation 回退事件的处理

我们再来看React Navigation是如何处理回退按钮的,这里是官方文档,我们可以看到它的处理方式其实和 RN 的差不多,只不过将组件的生命周期方法换成了React Navigation的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  constructor(props) {
    super(props);
    this.didFocusSubscription = props.navigation.addListener('didFocus', payload =>
      BackHandler.addEventListener('hardwareBackPress', this.handleBackPress)
    );
  }

  componentDidMount() {
    this.willBlurSubscription = this.props.navigation.addListener('willBlur', payload =>
      BackHandler.removeEventListener('hardwareBackPress', this.handleBackPress)
    );
  }

  componentWillUnmount() {
    this.didFocusSubscription && this.didFocusSubscription.remove();
    this.willBlurSubscription && this.willBlurSubscription.remove();
  }

在代码中我们可以看到,React Navigation通过didFocus方法(即页面加载完成之后的回调函数)添加回退按钮事件,然后通过willBlur方法(即页面离开之前的回调函数)来移除事件。这样一来,即使是常驻路由堆栈的页面,只要进行了页面跳转,回退按钮事件就会被移除。

但这种方式还是会有一些问题,首先页面还是会加载无法移除的事件,只不过事件从回退按钮事件变成了React Navigation的监听事件。

另外一个比较严重的问题是:如果进入和离开页面耗时比较长(比如需要发送好几次网络请求),或者切换页面比较快的话,就有可能出现先添加当前页面事件(didFocus)再移除上个页面事件(willBlur)的情况,这意味着当前页面的回退按钮事件可能被添加后马上被移除,从而导致操作异常。

更好的解决方法

总结一下上面的问题:

  • 需要在多个组件(页面)中处理回退按钮事件,使得代码的编写变得麻烦
  • 事件的添加删除顺序在页面切换的过程中可能引起错乱,导致 App 行为异常

上面问题产生的主要原因是多个地方在处理回退按钮事件,如果我们的 APP 只有一个地方在处理回退按钮事件的话,那就可以避免这些问题了。

但是,如果把回退按钮事件的处理放在一个地方的话,我们需要先解决 2 个问题:

  • 在哪个地方添加回退按钮事件?
  • 我们如何判断当前页面是哪个页面?因为我们要根据不同页面处理不同的逻辑,比如 A 页面的回退按钮要退出 App,而 B 页面则是要退回到上一个页面。

在哪个地方添加回退按钮事件?

针对这个问题,我们可以在组件的最外层封装一个Layout组件,然后通过这个组件来添加回退按钮的事件,然后把原先的根组件作为这个组件的children,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Layout extends React.Component {
  componentDidMount() {
    BackHandler.addEventListener('hardwareBackPress', this.handleBackPress);
  }

  componentWillUnmount() {
    BackHandler.removeEventListener('hardwareBackPress', this.handleBackPress);
  }

  render() {
    <View>
      {this.props.children}
    </View>
  }
}

class App extends React.Component {
  render() {
    <Layout>
      <Root /> // 原先的根组件
    </Layout>
  }
}

在其他地方不需要再添加回退按钮事件,所有页面的事件统一在handleBackPress方法中进行处理。

如何判断当前页面是哪个页面?

React Navigation的官方文档中介绍了一个无需 navigation 参数进行导航的方法。这里的原理是通过设置根目录节点来获取全局的React Navigation对象,然后通过这个对象来调用React Navigation的 API。

我们可以通过扩展这个方法来获取当前页面,我们增加如下的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const getCurrentRoute = () => {
  // 获取到 route 对象
  let route = navigator.state.nav;
  // 一直循环获取到最底层的 route 对象
  while (route.routes) {
    route = route.routes[route.index];
  }
  return route;
};

// route: {
//   "key": "id-1552444588477-2",
//   "params": {
//     "disableBack": true,
//   },
//   "routeName": "Settings",
// }

通过这个函数我们可以获取到当前页面的路由对象,在这个对象中可以根据routeName来进行页面判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handleBackPress = () => {
  const { routeName } = NavigationSeveice.getCurrentRoute();
  switch(routeName) {
    case 'Home': {
      // dosomething
      return true;
    }
    case 'Settings': {
      // dosomething
      return true;
    }
    default: {
      return true;
    }
  }
}

直接使用

如果觉得自己实现比较麻烦的话,也可以直接使用这个库——React Native Android Backer,这是笔者写的一个基于以上思路的 RN 库,使用方法非常简单,感兴趣的同学可以参考里面的Readme文档。

总结

这是在 RN 开发中遇到的一个实际问题,开始参考了各种文章和方法都不太理想,后来自己通过摸索找到了令自己满意的解决方案,并将其抽取成了第三方库,也希望能帮助到遇到同样问题的朋友,欢迎大家试用和留言讨论,谢谢。