ReactNative-蓝图-全-

ReactNative 蓝图(全)

原文:zh.annas-archive.org/md5/70729A755431D37E9DA3E2FBADC90F35

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

React Native 帮助 Web 和移动开发人员构建与任何其他本地开发的应用性能相同的 iOS 和 Android 应用程序。使用这个库可以构建的应用范围非常广泛。从电子商务到游戏,React Native 都是任何移动项目的良好选择,因为它具有灵活性和可扩展性。它具有良好的性能,可以重用 React 知识,具有导入 npm 包的能力,并且在 iOS 和 Android 上使用相同的代码库。毫无疑问,React Native 不仅是本地开发的一个很好的替代方案,而且也是将 Web 开发人员引入移动项目的一个很好的方式。本书旨在让 JavaScript 和 React 开发人员了解如何使用 React Native 从头开始构建市场上一些最流行的应用。我们将在 iOS 和 Android 上构建所有应用,除非这些应用只在其中一个平台上有意义。

本书所需的内容

本书中构建的大多数应用程序将在 Android 和 iOS 上运行,因此需要运行 Linux、Windows 或 OSX 的计算机,尽管我们建议使用任何一台苹果电脑(运行 OSX 10 或更高版本)同时运行两个移动平台,因为一些示例将需要在 XCode 上工作,而 XCode 只能安装在 OSX 上。

我们在示例中将使用的其他软件包括:

  • XCode

  • Android Studio

  • 一个 React-ready 的 IDE(如 Atom,VS Code 和 SublimeText)

当然,我们还需要安装 React Native 和 React Native CLI(facebook.github.io/react-native/docs/getting-started.html)。

本书适合的读者是谁?

本书的目标读者是试图了解如何使用 React Native 构建不同类型应用的 JavaScript 开发人员。他们将找到一套可以应用于构建任何类型应用的最佳实践和经过验证的架构策略。

尽管本书不会解释 React 的一些基本概念,但并不需要特定的 React 技能来跟随,因为我们不会深入研究复杂的 React 模式。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“我们必须创建一个src文件夹,我们将在其中存储所有的 React 代码。”

此外,在大的代码块中,当一些代码片段不相关或在不同的地方进行了审查时,它们将被省略号(...)替换。

代码块设置如下:

/*** index.js ***/

import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('GroceriesList', () => App);

任何命令行输入或输出都以以下方式书写:

react-native run-ios

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“在添加产品屏幕上的返回按钮。”

提示和重要说明会出现在这样的框中。技巧和窍门会以这种方式出现。

第一章:购物清单

大多数现代语言和框架用于展示待办事项清单作为它们的示例应用程序。这是了解框架基础知识的绝佳方式,如用户交互、基本导航或代码结构。我们将以更加务实的方式开始:构建一个购物清单应用程序。

您将能够使用 React Native 代码开发此应用程序,为 iOS 和 Android 构建它,并最终安装在您的手机上。这样,您不仅可以向朋友展示您所构建的内容,还可以了解您可以自己构建的缺失功能,思考用户界面改进,最重要的是,激励自己继续学习 React Native,感受其真正的潜力。

在本章结束时,您将已经构建了一个完全功能的购物清单,可以在手机上使用,并且拥有创建和维护简单有状态应用程序所需的所有工具。

概述

React Native 的最强大功能之一是其跨平台能力;我们将为 iOS 和 Android 构建我们的购物清单应用程序,重用我们代码的 99%。让我们来看看这个应用在两个平台上的样子:

iOS:

添加更多产品后,它将如下所示:

Android:

添加更多产品后,它将如下所示:

该应用程序在两个平台上的用户界面非常相似,但我们不需要过多关注差异(例如,“添加产品”屏幕上的返回按钮),因为它们将由 React Native 自动处理。

重要的是要理解每个平台都有自己的用户界面模式,并且遵循它们是一个好的做法。例如,iOS 通常通过选项卡来处理导航,而 Android 更喜欢抽屉菜单,因此如果我们希望在两个平台上都有满意的用户,我们应该构建这两种导航模式。无论如何,这只是一个建议,任何用户界面模式都可以在每个平台上构建。在后面的章节中,我们将看到如何在同一代码库中以最有效的方式处理两种不同的模式。

该应用包括两个屏幕:您的购物清单和可以添加到您的购物清单的产品列表。用户可以通过圆形蓝色按钮从购物清单屏幕导航到“添加产品”屏幕,然后通过<返回按钮返回。我们还将在购物清单屏幕上构建一个清除按钮(圆形红色按钮),以及在“添加产品”屏幕上添加和删除产品的功能。

在本章中,我们将涵盖以下主题:

  • 基本 React Native 项目的文件夹结构

  • React Native 的基本 CLI 命令

  • 基本导航

  • JS 调试

  • 实时重新加载

  • 使用 NativeBase 进行样式设置

  • 列表

  • 基本状态管理

  • 处理事件

  • AsyncStorage

  • 提示弹出

  • 分发应用

设置我们的项目

React Native 具有非常强大的 CLI,我们需要安装它才能开始我们的项目。要安装,只需在命令行中运行以下命令(如果权限不够,可能需要使用sudo):

npm install -g react-native-cli

安装完成后,我们可以通过输入react-native来开始使用 React Native CLI。要启动我们的项目,我们将运行以下命令:

react-native init --version="0.49.3" GroceriesList

此命令将创建一个名为GroceriesList的基本项目,其中包含构建 iOS 和 Android 应用所需的所有依赖项和库。一旦 CLI 完成安装所有软件包,您应该有一个类似于此的文件夹结构:

我们项目的入口文件是index.js。如果您想在模拟器上看到您的初始应用程序运行,可以再次使用 React Native 的 CLI:

react-native run-ios

或者

react-native run-android

假设您已经安装了 XCode 或 Android Studio 和 Android 模拟器,编译后您应该能够在模拟器上看到一个示例屏幕:

我们已经准备好设置开始实现我们的应用程序,但为了轻松调试并在模拟器中看到我们的更改,我们需要启用另外两个功能:远程 JS 调试和实时重新加载。

为了调试,我们将使用React Native Debugger,这是一个独立的应用程序,基于 React Native 的官方调试器,其中包括 React Inspector 和 Redux DevTools。它可以通过按照其 GitHub 存储库上的说明进行下载(github.com/jhen0409/react-native-debugger)。为了使这个调试器正常工作,我们需要在应用程序内部启用远程 JS 调试,方法是在模拟器中通过按下 iOS 上的command + ctrl + Z或 Android 上的command + M来打开 React Native 开发菜单。

如果一切顺利,我们应该看到以下菜单出现:

现在,我们将按下两个按钮:Debug Remote JS 和 Enable Live Reload。完成后,我们的开发环境已经准备好开始编写 React 代码。

设置文件夹结构

我们的应用程序只包括两个屏幕:购物清单和添加产品。由于这样一个简单应用的状态应该很容易管理,我们不会添加任何状态管理库(例如 Redux),因为我们将通过导航组件发送共享状态。这应该使我们的文件夹结构相当简单:

我们必须创建一个src文件夹,我们将在其中存储所有我们的 React 代码。自创建的文件index.js将包含以下代码:

/*** index.js ***/

import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('GroceriesList', () => App);

简而言之,这些文件将导入我们应用程序的通用根代码,将其存储在名为App的变量中,然后通过registerComponent方法将这个变量传递给AppRegistryAppRegistry是我们应该注册我们的根组件的组件。一旦我们这样做,React Native 将为我们的应用程序生成一个 JS 捆绑包,然后通过调用AppRegistry.runApplication在准备就绪时运行应用程序。

我们将写的大部分代码都将放在src文件夹中。对于这个应用程序,我们将在这个文件夹中创建我们的根组件(main.js),以及一个screens子文件夹,我们将在其中存储我们的两个屏幕(ShoppingListAddProduct)。

现在让我们在继续编码之前安装应用程序的所有初始依赖项。在我们项目的根文件夹中,我们需要运行以下命令:

npm install

运行该命令将为每个 React Native 项目安装所有基本依赖项。现在让我们安装这个特定应用程序将使用的三个软件包:

npm install **native-base --save**
**npm install react-native-prompt-android --save**
**npm install react-navigation --save** 

在本章的后面,我们将解释每个包将被用于什么。

添加导航组件

大多数移动应用程序由多个屏幕组成,因此我们需要能够在这些屏幕之间“切换”。为了实现这一点,我们需要一个Navigation组件。React Native 自带了NavigatorNavigatorIOS组件,尽管 React 的维护者建议使用社区构建的外部导航解决方案react-navigationgithub.com/react-community/react-navigation),这个解决方案非常高效,维护良好,并且功能丰富,所以我们将在我们的应用程序中使用它。

因为我们已经安装了导航模块(react-navigation),我们可以在main.js文件中设置和初始化我们的Navigation组件:

/*** src/main.js ***/

import React from 'react';
import { StackNavigator } from 'react-navigation';
import ShoppingList from './screens/ShoppingList.js';
import AddProduct from './screens/AddProduct.js';

const Navigator = StackNavigator({
  ShoppingList: { screen: ShoppingList },
  AddProduct: { screen: AddProduct }
});

export default class App extends React.Component {
  constructor() {
    super();
  }

  render() {
    return <Navigator />;
  }
}

我们的根组件导入了应用程序中的两个屏幕(ShoppingListAddProduct)并将它们传递给StackNavigator函数,该函数生成了Navigator组件。让我们深入了解一下StackNavigator的工作原理。

StackNavigator提供了一种让任何应用程序在屏幕之间进行过渡的方式,其中每个新屏幕都放置在堆栈的顶部。当我们请求导航到一个新屏幕时,StackNavigator将从右侧滑动新屏幕,并在 iOS 中的右上角放置一个< Back按钮,以返回到上一个屏幕,或者在 Android 中,新屏幕将从底部淡入,同时放置一个<-箭头以返回。使用相同的代码库,我们将在 iOS 和 Android 中触发熟悉的导航模式。StackNavigator也非常简单易用,因为我们只需要将我们应用程序中的屏幕作为哈希映射传递,其中键是我们想要为我们的屏幕设置的名称,值是导入的屏幕作为 React 组件。结果是一个<Navigator/>组件,我们可以渲染来初始化我们的应用程序。

使用 NativeBase 为我们的应用程序设置样式

React Native 包括一种强大的方式来使用 Flexbox 和类似 CSS 的 API 来为我们的组件和屏幕设置样式,但是对于这个应用程序,我们想要专注于功能方面,所以我们将使用一个包括基本样式组件的库,如按钮、列表、图标、菜单、表单等。它可以被视为 React Native 的 Twitter Bootstrap。

有几个流行的 UI 库,NativeBase 和 React Native 元素是最受欢迎和最受支持的两个。在这两者中,我们将选择 NativeBase,因为它对初学者来说稍微更清晰一些。

您可以在他们的网站上找到有关 NativeBase 如何工作的详细文档(docs.nativebase.io/),但是在本章中,我们将介绍安装和使用其中一些组件的基础知识。我们之前通过npm installnative-base安装为项目的依赖项,但 NativeBase 包括一些对等依赖项,需要链接并包含在我们的 iOS 和 Android 本机文件夹中。幸运的是,React Native 已经有一个工具来查找这些依赖项并将它们链接起来;我们只需要运行:

react-native link

在这一点上,我们的应用程序中已经完全可用来自 NativeBase 的所有 UI 组件。因此,我们可以开始构建我们的第一个屏幕。

构建 ShoppingList 屏幕

我们的第一个屏幕将包含我们需要购买的物品清单,因此它将包含每个我们需要购买的物品的一个列表项,包括一个按钮来标记该物品已购买。此外,我们需要一个按钮来导航到AddProduct屏幕,这将允许我们向我们的列表中添加产品。最后,我们将添加一个按钮来清除产品列表,以防我们想要开始一个新的购物清单:

让我们从在screens文件夹内创建ShoppingList.js开始,并从native-basereact-native导入我们将需要的所有 UI 组件(我们将使用警告弹出窗口在清除所有项目之前警告用户)。我们将使用的主要 UI 组件是Fab(蓝色和红色的圆形按钮),ListListItemCheckBoxTextIcon。为了支持我们的布局,我们将使用BodyContainerContentRight,这些是我们其余组件的布局容器。

拥有所有这些组件,我们可以创建一个简单版本的ShoppingList组件:

/*** ShoppingList.js ***/

import React from 'react';
import { Alert } from 'react-native';
import {
  Body,
  Container,
  Content,
  Right,
  Text,
  CheckBox,
  List,
  ListItem,
  Fab,
  Icon
} from 'native-base';

export default class ShoppingList extends React.Component {
  static navigationOptions = {
    title: 'My Groceries List'
  };
  /*** Render ***/
  render() {
    return (
      <Container>
        <Content>
          <List>
            <ListItem>
              <Body>
                <Text>'Name of the product'</Text>
              </Body>
              <Right>
                <CheckBox
                  checked={false}
                />
              </Right>
            </ListItem>
          </List>
        </Content>
        <Fab
          style={{ backgroundColor: '#5067FF' }}
          position="bottomRight"
        >
          <Icon name="add" />
        </Fab>
        <Fab
          style={{ backgroundColor: 'red' }}
          position="bottomLeft"
        >
          <Icon ios="ios-remove" android="md-remove" />
        </Fab>
      </Container>
    );
  }
}

这只是一个愚蠢的组件,静态显示我们将在此屏幕上使用的组件。需要注意的一些事情:

  • navigationOptions是一个静态属性,将被<Navigator>用来配置导航的行为。在我们的情况下,我们希望将“我的杂货清单”显示为此屏幕的标题。

  • 为了使native-base发挥其作用,我们需要使用<Container><Content>来正确地形成布局。

  • Fab按钮放置在<Content>之外,因此它们可以浮动在左下角和右下角。

  • 每个ListItem包含一个<Body>(主要文本)和一个<Right>(右对齐的图标)。

由于我们在最初的步骤中启用了实时重新加载,所以在保存新创建的文件后,我们应该看到应用程序重新加载。现在所有的 UI 元素都已经就位,但它们还没有功能,因为我们还没有添加任何状态。这应该是我们下一步要做的事情。

在我们的屏幕上添加状态

让我们在ShoppingList屏幕上添加一些初始状态,以用实际动态数据填充列表。我们将首先创建一个构造函数,并在那里设置初始状态:

/*** ShoppingList.js ***/

...
constructor(props) {
  super(props);
  this.state = {
    products: [{ id: 1, name: 'bread' }, { id: 2, name: 'eggs' }]
  };
}
...

现在,我们可以在<List>(在render方法内部)中呈现该状态:

/*** ShoppingList.js ***/

...
<List>
 {
   this.state.products.map(p => {
     return (
       <ListItem
         key={p.id}
       >
         <Body>
           <Text style={{ color: p.gotten ? '#bbb' : '#000' }}>
             {p.name}
           </Text>
         </Body>
         <Right>
           <CheckBox
             checked={p.gotten}
            />
         </Right>
       </ListItem>
     );
   }
  )}
</List>
...

我们现在依赖于组件状态中的产品列表,每个产品存储一个id、一个namegotten属性。在修改此状态时,我们将自动重新呈现列表。

现在,是时候添加一些事件处理程序,这样我们就可以根据用户的命令修改状态或导航到AddProduct屏幕。

添加事件处理程序

所有与用户的交互都将通过 React Native 中的事件处理程序进行。根据控制器的不同,我们将有不同的可以触发的事件。最常见的事件是onPress,因为每次我们按下按钮、复选框或一般视图时都会触发它。让我们为屏幕中可以被按下的所有组件添加一些onPress处理程序:

/*** ShoppingList.js ***/

...
render() {
 return (
   <Container>
     <Content>
       <List>
        {this.state.products.map(p => {
          return (
            <ListItem
              key={p.id}
              onPress={this._handleProductPress.bind(this, p)}
            >
              <Body>
                <Text style={{ color: p.gotten ? '#bbb' : '#000' }}>
                  {p.name}
                </Text>
              </Body>
              <Right>
                <CheckBox
                  checked={p.gotten}
                  onPress={this._handleProductPress.bind(this, p)}
                />
              </Right>
            </ListItem>
          );
       })}
       </List>
     </Content>
     <Fab
       style={{ backgroundColor: '#5067FF' }}
       position="bottomRight"
       onPress={this._handleAddProductPress.bind(this)}
     >
       <Icon name="add" />
     </Fab>
     <Fab
       style={{ backgroundColor: 'red' }}
       position="bottomLeft"
       onPress={this._handleClearPress.bind(this)}
     >
       <Icon ios="ios-remove" android="md-remove" />
     </Fab>
   </Container>
   );
 }
...

请注意,我们添加了三个onPress事件处理程序:

  • <ListItem>上,当用户点击列表中的一个产品时做出反应

  • <CheckBox>上,当用户点击列表中每个产品旁边的复选框图标时做出反应

  • 在两个<Fab>按钮上

如果你了解 React,你可能明白为什么我们在所有的处理程序函数中使用.bind,但是,如果你有疑问,.bind将确保我们可以在处理程序的定义中使用this作为对组件本身的引用,而不是全局范围。这将允许我们在组件内调用方法,如this.setState或读取我们组件的属性,比如this.propsthis.state

对于用户点击特定产品的情况,我们还绑定产品本身,这样我们可以在事件处理程序中使用它们。

现在,让我们定义将作为事件处理程序的函数:

/*** ShoppingList.js ***/

...
_handleProductPress(product) {
 this.state.products.forEach(p => {
   if (product.id === p.id) {
     p.gotten = !p.gotten;
   }
   return p;
 });

 this.setState({ products: this.state.products });
}
...

首先,让我们为用户点击购物清单中的产品或其复选框时创建一个处理程序。我们希望将产品标记为“已购得”(或者如果已经“已购得”,则取消标记),因此我们将使用正确地标记产品来更新状态。

接下来,我们将为蓝色的<Fab>按钮添加一个处理程序,以导航到AddProduct屏幕:

/*** ShoppingList.js ***/

...
_handleAddProductPress() {
  this.props.navigation.navigate('AddProduct', {
    addProduct: product => {
      this.setState({
        products: this.state.products.concat(product)
      });
    },
    deleteProduct: product => {
      this.setState({
        products: this.state.products.filter(p => p.id !== product.id)
      });
    },
    productsInList: this.state.products
  });
}
...

这个处理程序使用了this.props.navigation,这是一个由react-navigation中的Navigator组件自动传递的属性。这个属性包含一个名为navigate的方法,接收应用程序应该导航到的屏幕的名称,以及一个可以作为全局状态使用的对象。在这个应用程序的情况下,我们将存储三个键:

  • addProduct:一个函数,允许AddProduct屏幕修改ShoppingList组件的状态,以反映向购物清单添加新产品的操作。

  • deleteProduct:一个函数,允许AddProduct屏幕修改ShoppingList组件的状态,以反映从购物清单中删除产品的操作。

  • productsInList:一个变量,保存着已经在购物清单上的产品列表,这样AddProducts屏幕就可以知道哪些产品已经添加到购物清单中,并将它们显示为“已添加”,防止重复添加物品。

在导航中处理状态应该被视为简单应用程序的一种解决方法,其中包含有限数量的屏幕。在更大的应用程序中(正如我们将在后面的章节中看到的),应该使用状态管理库,比如 Redux 或 MobX,来保持纯数据和用户界面处理之间的分离。

接下来,我们将为蓝色的<Fab>按钮添加最后一个处理程序,这样用户就可以清空购物清单中的所有项目,以便开始一个新的清单:

/*** ShoppingList.js ***/

...
_handleClearPress() {
  Alert.alert('Clear all items?', null, [
    { text: 'Cancel' },
    { text: 'Ok', onPress: () => this.setState({ products: [] }) }
  ]);
}
...

我们正在使用Alert来在清空购物清单中的所有元素之前提示用户确认。一旦用户确认了这个操作,我们将清空组件状态中的products属性。

把所有东西放在一起

让我们看看当把所有方法放在一起时,整个组件的结构会是什么样子:

/*** ShoppingList.js ***/

import React from 'react';
import { Alert } from 'react-native';
import { ... } from 'native-base';

export default class ShoppingList extends React.Component {
 static navigationOptions = {
   title: 'My Groceries List'
 };

 constructor(props) {
   ...
 }

 /*** User Actions Handlers ***/
 _handleProductPress(product) {
   ...
 }

 _handleAddProductPress() {
   ...
 }

 _handleClearPress() {
   ...
 }

 /*** Render ***/
 render() {
   ...
 }
}

React Native 组件的结构非常类似于普通的 React 组件。我们需要导入 React 本身,然后一些组件来构建我们的屏幕。我们还有几个事件处理程序(我们已经用下划线作为纯粹的约定),最后是一个render方法来使用标准的 JSX 显示我们的组件。

与 React web 应用程序唯一的区别是,我们使用 React Native UI 组件而不是 DOM 组件。

构建 AddProduct 屏幕

由于用户需要向购物清单中添加新产品,我们需要构建一个屏幕,可以提示用户输入要添加的产品的名称,并将其保存在手机的存储中以供以后使用。

使用 AsyncStorage

在构建 React Native 应用程序时,了解移动设备如何处理每个应用程序使用的内存是很重要的。我们的应用程序将与设备中的其他应用程序共享内存,因此,最终,我们的应用程序使用的内存将被另一个应用程序占用。因此,我们不能依赖将数据放在内存中以供以后使用。如果我们想确保数据在我们的应用程序的用户之间可用,我们需要将数据存储在设备的持久存储中。

React Native 提供了一个 API 来处理与移动设备中的持久存储的通信,这个 API 在 iOS 和 Android 上是相同的,因此我们可以舒适地编写跨平台代码。

API 的名称是AsyncStorage,我们可以在从 React Native 导入后使用它:

import { AsyncStorage } from 'react-native';

我们只会使用AsyncStorage的两个方法:getItemsetItem。例如,我们将在我们的屏幕内创建一个本地函数来处理将产品添加到产品列表中的操作。

/*** AddProduct ***/

...
async addNewProduct(name) {
  const newProductsList = this.state.allProducts.concat({
    name: name,
    id: Math.floor(Math.random() * 100000)
  });

  await AsyncStorage.setItem(
    '@allProducts',
    JSON.stringify(newProductsList)
  );

  this.setState({
    allProducts: newProductsList
  });
 }
...

这里有一些有趣的事情需要注意:

  • 我们正在使用 ES7 的特性,比如asyncawait来处理异步调用,而不是使用 promises 或回调函数。理解 ES7 不在本书的范围之内,但建议学习和了解asyncawait的使用,因为这是一个非常强大的特性,在本书中我们将广泛使用它。

  • 每当我们向allProducts添加一个产品时,我们还会调用AsyncStorage.setItem来永久存储产品在设备的存储中。这个操作确保用户添加的产品即使在操作系统清除我们的应用程序使用的内存时也是可用的。

  • 我们需要向setItem(以及getItem)传递两个参数:一个键和一个值。它们都必须是字符串,所以如果我们想存储 JSON 格式的数据,我们需要使用JSON.stringify

向我们的屏幕添加状态

正如我们刚刚看到的,我们将在组件状态中使用一个名为allProducts的属性,其中将包含用户可以添加到购物清单中的完整产品列表。

我们可以在组件的构造函数中初始化这个状态,以便在应用程序的第一次运行期间给用户一个概述,让他/她看到这个屏幕上的内容(这是许多现代应用程序用来引导用户的技巧,通过伪造一个“已使用”状态):

/*** AddProduct.js ***/

...
constructor(props) {
  super(props);
  this.state = {
    allProducts: [
      { id: 1, name: 'bread' },
      { id: 2, name: 'eggs' },
      { id: 3, name: 'paper towels' },
      { id: 4, name: 'milk' }
    ],
    productsInList: []
  };
}
...

除了allProducts,我们还将有一个productsInList数组,其中包含已经添加到当前购物清单中的所有产品。这将允许我们将产品标记为“已经在购物清单中”,防止用户尝试在列表中两次添加相同的产品。

这个构造函数对我们应用程序的第一次运行非常有用,但一旦用户添加了产品(因此将它们保存在持久存储中),我们希望这些产品显示出来,而不是这些测试数据。为了实现这个功能,我们应该从AsyncStorage中读取保存的产品,并将其设置为我们状态中的初始allProducts值。我们将在componentWillMount上执行这个操作。

/*** AddProduct.js ***/

...
async componentWillMount() {
  const savedProducts = await AsyncStorage.getItem('@allProducts');
  if(savedProducts) {
    this.setState({
      allProducts: JSON.parse(savedProducts)
    }); 
  }

  this.setState({
    productsInList: this.props.navigation.state.params.productsInList
  });
}
...

一旦屏幕准备好被挂载,我们就会更新状态。首先,我们将通过从持久存储中读取它来更新allProducts值。然后,我们将根据“购物清单”屏幕在“导航”属性中设置的状态更新产品列表productsInList

有了这个状态,我们可以构建我们的产品列表,这些产品可以添加到购物清单中:

/*** AddProduct ***/

...
render(){
  <List>
    {this.state.allProducts.map(product => {
       const productIsInList = this.state.productsInList.find(
         p => p.id === product.id
       );
       return (
         <ListItem key={product.id}>
           <Body>
             <Text
               style={{
                color: productIsInList ? '#bbb' : '#000'
               }}
             >
               {product.name}
             </Text>
             {
               productIsInList &&
               <Text note>
                 {'Already in shopping list'}
               </Text>
             }
          </Body>
        </ListItem>
      );
    }
 )}
 </List>
}
...

在我们的render方法中,我们将使用Array.map函数来迭代和打印每个可能的产品,检查产品是否已经添加到当前购物清单中以显示一个提示,警告用户:“已经在购物清单中”。

当然,我们仍然需要为所有可能的用户操作添加更好的布局、按钮和事件处理程序。让我们开始改进我们的render方法,将所有功能放在适当的位置。

添加事件监听器

就像“购物清单”屏幕一样,我们希望用户能够与我们的AddProduct组件进行交互,因此我们将添加一些事件处理程序来响应一些用户操作。

我们的render方法应该看起来像这样:

/*** AddProduct.js ***/

...
render() {
  return (
    <Container>
      <Content>
        <List>
          {this.state.allProducts.map(product => {
            const productIsInList = this.state.productsInList.
            find(p => p.id === product.id);
            return (
              <ListItem
                key={product.id}
                onPress={this._handleProductPress.bind
                (this, product)}
              >
                <Body>
                  <Text
                    style={{ color: productIsInList? '#bbb' : '#000' }}
                  >
                    {product.name}
                  </Text>
                 {
                   productIsInList &&
                   <Text note>
                     {'Already in shopping list'}
                   </Text>
                 }
                 </Body>
                 <Right>
                   <Icon
                     ios="ios-remove-circle"
                     android="md-remove-circle"
                     style={{ color: 'red' }}
                     onPress={this._handleRemovePress.bind(this, 
                     product )}
                   />
                 </Right>
               </ListItem>
             );
           })}
         </List>
       </Content>
     <Fab
       style={{ backgroundColor: '#5067FF' }}
       position="bottomRight"
       onPress={this._handleAddProductPress.bind(this)}
     >
       <Icon name="add" />
     </Fab>
   </Container>
   );
 }
...

在这个组件中,有三个事件处理程序响应三个按压事件:

  • 在蓝色的<Fab>按钮上,负责向产品列表中添加新产品

  • 在每个<ListItem>上,这将把产品添加到购物清单中

  • 在每个<ListItem>内的删除图标上,以将此产品从可以添加到购物清单中的产品列表中移除

让我们在用户按下<Fab>按钮时开始向可用产品列表中添加新产品:

/*** AddProduct.js ***/

...
_handleAddProductPress() {
  prompt(
    'Enter product name',
    '',
    [
      { text: 'Cancel', style: 'cancel' },
      { text: 'OK', onPress: this.addNewProduct.bind(this) }
    ],
    {
      type: 'plain-text'
    }
  );
}
...

我们在这里使用了react-native-prompt-android模块的prompt函数。尽管它的名称是这样,但它是一个跨平台的弹出式提示库,我们将使用它通过我们之前创建的addNewProduct函数来添加产品。在使用之前,我们需要导入prompt函数,如下所示:

import prompt from 'react-native-prompt-android';

以下是输出:

一旦用户输入产品名称并按下确定,产品将被添加到列表中,这样我们就可以转到下一个事件处理程序,当用户点击产品名称时将产品添加到购物清单中:

/*** AddProduct.js ***/

...
_handleProductPress(product) {
  const productIndex = this.state.productsInList.findIndex(
    p => p.id === product.id
  );
  if (productIndex > -1) {
    this.setState({
      productsInList: this.state.productsInList.filter(
        p => p.id !== product.id
      )
    });
    this.props.navigation.state.params.deleteProduct(product);
  } else {
    this.setState({
      productsInList: this.state.productsInList.concat(product)
    });
    this.props.navigation.state.params.addProduct(product);
 }
}
...

此处理程序检查所选产品是否已在购物清单上。如果是,它将通过调用导航状态中的deleteProduct和通过调用setState从组件状态中删除它。否则,它将通过调用导航状态中的addProduct将产品添加到购物清单,并通过调用setState刷新本地状态。

最后,我们将为每个<ListItems>上的删除图标添加事件处理程序,以便用户可以从可用产品列表中删除产品:

/*** AddProduct.js ***/

...
async _handleRemovePress(product) {
  this.setState({
    allProducts: this.state.allProducts.filter(p => p.id !== product.id)
  });
  await AsyncStorage.setItem(
    '@allProducts',
    JSON.stringify(
      this.state.allProducts.filter(p => p.id !== product.id)
    )
  );
}
...

我们需要从组件的本地状态和AsyncStorage中删除产品,这样在应用程序的后续运行中就不会显示。

将所有内容整合在一起

我们已经拥有构建AddProduct屏幕的所有组件,所以让我们来看一下这个组件的一般结构:

import React from 'react';
import prompt from 'react-native-prompt-android';
import { AsyncStorage } from 'react-native';
import {
 ...
} from 'native-base';

export default class AddProduct extends React.Component {
  static navigationOptions = {
    title: 'Add a product'
  };

  constructor(props) {
   ...
  }

  async componentWillMount() {
    ...
  }

  async addNewProduct(name) {
    ...
  }

  /*** User Actions Handlers ***/
  _handleProductPress(product) {
   ...
  }

  _handleAddProductPress() {
    ...
  }

  async _handleRemovePress(product) {
    ...
  }

  /*** Render ***/
  render() {
    ....
  }
}

我们的结构与我们为ShoppingList构建的结构非常相似:构建初始状态的navigatorOptions构造函数,用户操作处理程序和render方法。在这种情况下,我们添加了一对异步方法,作为处理AsyncStorage的便捷方式。

安装和分发应用程序

在模拟器/仿真器上运行我们的应用程序是感受应用程序在移动设备上行为的非常可靠的方法。当在模拟器/仿真器中工作时,我们可以模拟触摸手势、网络连接不佳的环境,甚至内存问题。但最终,我们希望将应用程序部署到物理设备上,这样我们就可以进行更深入的测试。

有几种选项可以安装或分发使用 React Native 构建的应用程序,直接连接电缆是最简单的方法。Facebook 在 React Native 的网站上保持了一份更新的指南,介绍了如何实现在设备上的直接安装(facebook.github.io/react-native/docs/running-on-device.html),但是当分发应用程序给其他开发人员、测试人员或指定用户时,还有其他选择。

Testflight

Testflight(developer.apple.com/testflight/)是一个很棒的工具,用于将应用程序分发给测试人员和开发人员,但它有一个很大的缺点——它只适用于 iOS。它非常容易设置和使用,因为它集成在 iTunes Connect 中,苹果认为它是在开发团队内分发应用程序的官方工具。此外,它是完全免费的,使用限制相当大:

  • 最多 25 名团队成员进行测试

  • 每个测试人员团队最多 30 台设备

  • 最多 2,000 名团队外的外部测试人员(具有分组功能)

简而言之,Testflight 是在只针对 iOS 设备时选择的平台。

由于在本书中,我们希望专注于跨平台开发,我们将介绍其他分发我们的应用程序到 iOS 和 Android 设备的替代方案。

Diawi

Diawi(diawi.com)是一个网站,开发人员可以在上面上传他们的.ipa.apk文件(已编译的应用程序),并与任何人分享链接,因此该应用程序可以在连接到互联网的任何 iOS 或 Android 设备上下载和安装。这个过程很简单:

  1. 在 XCode/Android studio 中构建.ipa(iOS)/.apk(Android)。

  2. 将生成的.ipa/.apk文件拖放到 Diawi 的网站上。

  3. 通过电子邮件或其他方式与测试人员列表共享 Diawi 创建的链接。

链接是私有的,可以为那些需要更高安全性的应用程序设置密码保护。主要缺点是测试设备的管理,因为一旦链接分发,Diawi 就失去了对它们的控制,因此开发人员无法知道哪些版本被下载和测试。如果手动管理测试人员列表是一个选择,Diawi 是 Testflight 的一个很好的替代方案。

Installr

如果我们需要管理分发给哪些测试人员的版本以及他们是否已经开始测试应用程序,我们应该尝试使用 Installr(www.installrapp.com),因为在功能上它与 Diawi 相当类似,但它还包括一个仪表板,用于控制用户是谁,哪些应用程序已经单独发送给他们,以及测试设备上应用程序的状态(未安装、已安装或已打开)。这个仪表板非常强大,当我们的要求之一是对测试人员、设备和构建有良好的可见性时,它绝对是一个重要的优势。

Installr 的缺点是其免费计划仅覆盖每次构建的三个测试设备,尽管他们提供了一个廉价的一次性付费方案,以防我们真的想增加那个数字。当我们需要可见性和跨平台分发时,这是一个非常合理的选择。

总结

在本章的过程中,我们学会了如何启动 React Native 项目,构建一个包括基本导航和处理多个用户交互的应用程序。我们看到了如何使用导航模块处理持久数据和基本状态,以便我们可以在项目中的屏幕之间进行过渡。

所有这些模式都可以用来构建许多简单的应用程序,但在下一章中,我们将深入探讨更复杂的导航模式以及如何通信和处理从互联网获取的外部数据,这将使我们能够为应用程序的增长进行结构化和准备。除此之外,我们将使用 JavaScript 库 MobX 进行状态管理,这将以一种非常简单和有效的方式使我们的领域数据可用于应用程序中的所有屏幕。

第二章:RSS 阅读器

在本章中,我们将创建一个应用程序,能够获取、处理和显示用户多个 RSS 订阅。RSS 是一种 Web 订阅,允许用户以标准化和计算机可读的格式访问在线内容的更新。它们通常用于新闻网站、新闻聚合器、论坛和博客,以表示更新的内容,并且非常适合移动世界,因为我们可以通过在一个应用程序中输入订阅的 URL 来获取来自不同博客或报纸的所有内容。

一个 RSS 订阅阅读器将作为一个示例,演示如何获取外部数据,存储它,并向用户显示它,但同时,它将给我们的状态树增加一些复杂性;我们需要存储和管理订阅、条目和帖子的列表。除此之外,我们将引入 MobX 作为一个库来管理所有这些状态模型,并根据用户的操作更新我们的视图。因此,我们将介绍行为和存储的概念,这在一些最流行的状态管理库中被广泛使用,比如 Redux 或 MobX。

与上一章一样,因为我们将在这个应用程序中需要的 UI 模式在两个平台上非常相似,我们将致力于在 iOS 和 Android 上共享 100%的代码。

概述

为了更好地理解我们的 RSS 阅读器,让我们看看完成后应用程序将会是什么样子。

iOS:

Android:

主屏幕将显示用户已添加的订阅列表。导航标题还会显示一个(+)按钮,用于向列表中添加新的订阅。当按下该按钮时,应用程序将导航到添加订阅屏幕。

iOS:

Android:

一旦添加了新的订阅,它将显示在主屏幕上,用户只需点击即可打开它。

iOS:

Android:

在这个阶段,应用程序将检索所选订阅的更新条目列表,并在列表上显示它。在导航标题中,一个垃圾桶图标将允许用户从应用程序中删除该订阅。如果用户对任何条目感兴趣,她可以点击它以显示该条目的完整内容。

iOS:

Android:

这个最后的屏幕基本上是一个 WebView,默认情况下在 URL 中打开的轻量级浏览器,其中包含所选条目的内容。用户将能够浏览子部分并完全与此屏幕中打开的网站进行交互,还可以通过在导航标题中点击返回箭头来返回到源的详细信息。

在本章中,我们将涵盖以下主题:

  • 使用 MobX 进行状态管理

  • 从 URL 获取外部数据

  • WebView

  • 将基本链接模块与本地资源链接起来

  • 添加图标

  • ActivityIndicator

设置文件夹结构

就像我们在第一章中所做的那样,我们需要通过 React Native 的 CLI 初始化一个新的 React Native 项目。这次,我们将把我们的项目命名为RSSReader

react-native init --version="0.49.3" RSSReader

对于这个应用程序,我们将需要总共四个屏幕:

  • FeedList:这是一个包含已添加到应用程序中的源标题的列表,按它们被添加的时间排序。

  • AddFeed:这是一个简单的表单,允许用户通过发送其 URL 来添加源。我们将在这里检索源的详细信息,最终将它们添加并保存在我们的应用程序中以供以后使用。

  • FeedDetail:这是一个包含所选源的最新条目(在挂载屏幕之前检索)的列表。

  • EntryDetail:这是一个 WebView,显示所选条目的内容。

除了屏幕之外,我们还将包括一个actions.js文件,其中包含修改应用程序状态的所有用户操作。虽然我们将在后面的部分中审查状态的管理,但重要的是要注意,除了这个actions.js文件之外,我们还需要一个store.js文件来包含状态结构和修改它的方法。

最后,正如在大多数 React Native 项目中一样,我们将需要一个index.js文件(已经由 React Native 的 CLI 创建)和一个main.js文件作为我们应用程序组件树的入口点。

所有这些文件将被组织在src/src/screens/文件夹中,如下所示:

添加依赖项

对于这个项目,我们将使用几个 npm 模块来节省开发时间,并将重点放在 RSS 阅读器本身的功能方面,而不是处理自定义状态管理框架、自定义 UI 或数据处理。对于这些问题,我们将使用以下package.json文件:

{ 
  "name":"rssReader",
  "version":"0.0.1",
  "private":true,
  "scripts":{ 
  "start":"node node_modules/react-native/local-cli/cli.js start",
  "test":"jest"
  },
  "dependencies":{ 
  "mobx":"³.1.9",
  "mobx-react":"⁴.1.8",
  "native-base":"².1.3",
  "react":"16.0.0-beta.5",
    "react-native": "0.49.3",
  "react-native-vector-icons":"⁴.1.1",
  "react-navigation":"¹.0.0-beta.9",
  "simple-xml2json":"¹.2.3"
  },
  "devDependencies":{ 
  "babel-jest":"20.0.0",
  "babel-plugin-transform-decorators-legacy":"¹.3.4",
  "babel-preset-react-native":"1.9.1",
  "babel-preset-react-native-stage-0":"¹.0.1",
  "jest":"20.0.0",
  "react-test-renderer":"16.0.0-alpha.6"
  },
  "jest":{ 
  "preset":"react-native"
  }
}

正如在这个文件中所看到的,我们将与标准的 React Native 模块一起使用以下 npm 模块:

  • mobx:这是我们将使用的状态管理库

  • mobx-react:这是 MobX 的官方 React 绑定

  • native-base:与上一章一样,我们将使用 NativeBase 的 UI 库

  • react-native-vector-icons:NativeBase 需要这个模块来显示图形图标

  • react-navigation:我们将再次使用 React Native 的社区导航库

  • simple-xml2json:一个轻量级库,用于将 XML(RSS 订阅的标准格式)转换为 JSON,以便在我们的代码中轻松管理 RSS 数据

有了这个package.json文件,我们可以在项目的根文件夹中运行以下命令来完成安装:

npm install

一旦 npm 完成安装所有依赖项,我们就可以在 iOS 模拟器中启动我们的应用程序:

react-native run-ios

或者在 Android 模拟器中:

react-native run-android

使用矢量图标

对于这个应用程序,我们将使用两个图标:一个加号用于添加订阅,一个垃圾桶用于删除它们。React Native 默认不包括要使用的图标列表,因此我们需要添加一个。在我们的情况下,由于我们正在使用native-base作为我们的 UI 库,使用react-native-vector-icons非常方便,因为它在native-base中受到原生支持,但需要一个额外的配置步骤:

react-native link

一些库使用额外的原生功能,这些功能在 React Native 中不存在。在react-native-vector-icons的情况下,我们需要包含存储在库中的一些矢量图标,可以在原生中访问。对于这些类型的任务,React Native 包括react-native link,这是一个脚本,可以自动链接提供的库,准备所有原生代码和资源,这些资源在我们的应用程序中需要访问此库。许多库将需要这一额外步骤,但由于 React Native 的 CLI,这是一个非常简单的步骤,过去需要在项目之间移动文件并处理配置选项。

使用 MobX 管理我们的状态

MobX 是一个库,通过透明地应用函数式响应式编程,使状态管理变得简单和可扩展。MobX 背后的哲学非常简单:任何可以从应用程序状态派生出来的东西,都应该自动派生。这个哲学适用于 UI、数据序列化和服务器通信。

在其网站mobx.js.org/,上可以找到大量关于使用 MobX 的文档和示例,尽管在本节中我们将对其进行简要介绍,以便在本章中充分理解我们应用的代码。

商店

MobX 使用“observable”属性的概念。我们应该声明一个包含我们一般应用状态的对象,它将保存和声明这些 observable 属性。当我们修改其中一个属性时,MobX 会自动更新所有订阅的观察者。这是 MobX 背后的基本原则,让我们看一个示例代码:

/*** src/store.js ***/

import {observable} from 'mobx';

class Store {
 @observable feeds;

 ...

 constructor() {
   this.feeds = [];
 }

 addFeed(url, feed) {
   this.feeds.push({ 
     url, 
     entry: feed.entry,
     title: feed.title,
     updated: feed.updated
   });
   this._persistFeeds();
 }

 ...

}

const store = new Store()
export default store

我们有一个被标记为@observable的属性feeds,这意味着任何组件都可以订阅它,并在值发生变化时得到通知。这个属性在类构造函数中被初始化为空数组。

最后,我们还创建了addFeed方法,它将新的订阅推送到feeds属性中,因此将自动触发所有观察者的更新。为了更好地理解 MobX 观察者,让我们看一个观察订阅列表的示例组件:

import React from 'react';
import { Container, Content, List, ListItem, Text } from 'native-base';
import { observer } from 'mobx-react/native';

@observer
export default class FeedsList extends React.Component {

 render() {
  const { feeds } = this.props.screenProps.store;
  return (
    <Container>
      <Content>
        <List>
          {feeds &&
            feeds.map((f, i) => (
              <ListItem key={i}>
                <Text>{f.title}</Text>
              </ListItem>
            ))}
        </List>
      </Content>
    </Container>
  );
 }
}

我们注意到的第一件事是需要使用@observer装饰器标记我们的组件,以确保当我们商店中的任何@observable属性发生变化时它会被更新。

默认情况下,React Native 的 Babel 配置不支持@<decorator>语法。为了使其工作,我们需要修改我们项目根目录中的.babelrc文件,并将transform-decorator-legacy添加为插件。

另一个需要注意的事情是需要将存储作为属性传递给组件。在这种情况下,由于我们使用react-navigation,我们将在screenProps中传递它,这是在react-navigation中在<Navigator>和其子屏幕之间共享属性的标准方式。

MobX 还有许多其他功能,但我们将把这些留给更复杂的应用程序,因为本章的一个目标是展示在构建小型应用程序时简单状态管理可以是多么简单。

设置商店

在了解了 MobX 的工作原理之后,我们准备创建我们的商店:

/*** src/store.js ** */

import { observable } from 'mobx';
import { AsyncStorage } from 'react-native';

class Store {
  @observable feeds;
  @observable selectedFeed;
  @observable selectedEntry;

  constructor() {
    AsyncStorage.getItem('@feeds').then(sFeeds => {
      this.feeds = JSON.parse(sFeeds) || [];
    });
  }

  _persistFeeds() {
    AsyncStorage.setItem('@feeds', JSON.stringify(this.feeds));
  }

  addFeed(url, feed) {
    this.feeds.push({
      url,
      entry: feed.entry,
      title: feed.title,
      updated: feed.updated,
    });
    this._persistFeeds();
  }

  removeFeed(url) {
    this.feeds = this.feeds.filter(f => f.url !== url);
    this._persistFeeds();
  }

  selectFeed(feed) {
    this.selectedFeed = feed;
  }

  selectEntry(entry) {
    this.selectedEntry = entry;
  }
}

const store = new Store();
export default store;

我们已经在本章的 MobX 部分看到了该文件的基本结构。现在,我们将添加一些方法来修改订阅列表,并在用户在我们应用的订阅/条目列表中点击它们时选择特定的订阅/条目。

我们还利用AsyncStorage来在addFeedremoveFeed修改时持久化订阅列表。

定义动作

在我们的应用程序中将有两种类型的动作:影响特定组件状态的动作和影响一般应用程序状态的动作。我们希望将后者存储在组件代码之外的某个地方,这样我们可以重用并轻松维护它们。在 MobX(以及 Redux 或 Flux)应用程序中的一种常见做法是创建一个名为actions.js的文件,我们将在其中存储修改应用程序业务逻辑的所有动作。

在我们的 RSS 阅读器中,业务逻辑围绕订阅源和条目展开,因此我们将在此文件中捕获处理这些模型的所有逻辑。

/*** actions.js ** */

import store from './store';
import xml2json from 'simple-xml2json';

export async function fetchFeed(url) {
  const response = await fetch(url);
  const xml = await response.text();
  const json = xml2json.parser(xml);
  return {
    entry:
      (json.feed && json.feed.entry) || (json.rss && 
      json.rss.channel.item),
    title:
      (json.feed && json.feed.title) || (json.rss && 
      json.rss.channel.title),
    updated: (json.feed && json.feed.updated) || null,
  };
}

export function selectFeed(feed) {
  store.selectFeed(feed);
}

export function selectEntry(entry) {
  store.selectEntry(entry);
}

export function addFeed(url, feed) {
  store.addFeed(url, feed);
}

export function removeFeed(url) {
  store.removeFeed(url);
}

由于操作修改了应用程序的一般状态,它们将需要访问存储。让我们分别看看每个动作:

  • fetchFeed:当用户想要将订阅源添加到 RSS 阅读器时,他将需要传递 URL,以便应用程序可以下载该订阅源的详细信息(订阅源标题、最新条目列表以及上次更新时间)。此动作负责从提供的 URL 检索此数据(格式化为 XML 文档),并将该数据转换为应用程序的标准格式的 JSON 对象。从提供的 URL 获取数据将由 React Native 中的内置库fetch执行,该库用于向任何 URL 发出 HTTP 请求。由于fetch支持 promises,我们将使用 async/await 来处理异步行为并简化我们的代码。一旦检索到包含订阅源数据的 XML 文档,我们将使用simple-xml2json将该数据转换为 JSON 对象,这是一种非常轻量级的库,用于这种需求。最后,该动作返回一个仅包含我们在应用程序中真正需要的数据(标题、条目和最后更新时间)的 JSON 对象。

  • selectFeed:一旦用户向阅读器添加了一个或多个订阅源,她应该能够选择其中一个以获取该订阅源的最新条目列表。此动作只是将特定订阅源的详细信息保存在存储中,以便任何对显示与该订阅源相关的数据感兴趣的屏幕(即FeedDetail屏幕)可以使用它。

  • selectEntry:类似于selectFeed,用户应该能够选择订阅源中的条目之一,以获取该特定条目的详细信息。在这种情况下,显示该数据的屏幕将是EntryDetail,我们将在后面的部分中看到。

  • addFeed:这个动作需要两个参数:订阅的 URL 和订阅的详细信息。这些参数将用于将订阅存储在保存的订阅列表中,以便在我们的应用中全局可用。在这个应用的情况下,我们决定使用 URL 作为存储订阅详细信息的键,因为它是任何 RSS 订阅的唯一属性。

  • removeFeed:用户还可以决定他们不再想在 RSS 阅读器中看到特定的订阅,因此我们需要一个动作来从订阅列表中移除该订阅。这个动作只需要传递订阅的 URL 作为参数,因为我们使用 URL 作为 ID 来唯一标识订阅。

React Native 中的网络操作

大多数移动应用需要从外部 URL 获取和更新数据。在 React Native 中可以使用几个 npm 模块来通信和下载远程资源,比如 Axios 或 SuperAgent。如果你熟悉特定的 HTTP 库,你可以在 React Native 项目中使用它(只要不依赖于任何特定于浏览器的 API),尽管一个安全和熟练的选择是使用Fetch,这是 React Native 中内置的网络库。

Fetch非常类似于XMLHttpRequest,因此对于任何需要从浏览器执行 AJAX 请求的 web 开发人员来说都会感到熟悉。除此之外,Fetch支持 promises 和 ES2017 的 async/await 语法。

Fetch API 的完整文档可以在 Mozilla 开发者网络网站上找到developer.mozilla.org/en-US/docs/Web/API/Fetch_API

默认情况下,iOS 将阻止任何未使用 SSL 加密的请求。如果您需要从明文 URL(以http开头而不是https)获取数据,您首先需要添加一个App Transport SecurityATS)异常。如果您事先知道需要访问哪些域名,为这些域名添加异常更安全;如果域名直到运行时才知道,您可以完全禁用 ATS。然而,请注意,从 2017 年 1 月起,苹果的 App Store 审核将要求合理的理由来禁用 ATS。更多信息请参阅苹果的文档。

创建我们应用的入口点

所有的 React Native 应用都有一个入口文件:index.js,我们将把组件树的根委托给我们的src/main.js文件:

/*** index.js ***/

import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('rssReader', () => App);

我们还将在操作系统中注册我们的应用。

现在,让我们看一下 src/main.js 文件,了解我们将如何设置导航并启动我们的组件树:

/** * src/main.js ***/

import React from 'react';
import { StackNavigator } from 'react-navigation';

import FeedsList from './screens/FeedsList.js';
import FeedDetail from './screens/FeedDetail.js';
import EntryDetail from './screens/EntryDetail.js';
import AddFeed from './screens/AddFeed.js';

import store from './store';

const Navigator = StackNavigator({
  FeedsList: { screen: FeedsList },
  FeedDetail: { screen: FeedDetail },
  EntryDetail: { screen: EntryDetail },
  AddFeed: { screen: AddFeed },
});

export default class App extends React.Component {
  constructor() {
    super();
  }

  render() {
    return <Navigator screenProps={{ store }} />;
  }
}

我们将使用 react-navigation 作为我们的导航库,StackNavigator 作为我们的导航模式。将每个屏幕添加到 StackNavigator 函数中以生成我们的 <Navigator>。所有这些都与我们在第一章中使用的导航模式非常相似,但我们对其进行了改进:我们将 store 作为 <Navigator>screenProps 属性传递,而不是直接传递属性和方法来修改我们应用程序的状态。这简化和清理了代码库,并且正如我们将在后面的部分中看到的那样,它将使我们摆脱每次状态更改时通知导航的负担。所有这些改进都是由于 MobX 而免费获得的。

构建 FeedsList 屏幕

feeds 列表将作为此应用的主屏幕使用,因此让我们专注于构建 feeds 标题列表:

/** * src/screens/FeedsList.js ***/

import React from 'react';
import { Container, Content, List, ListItem, Text } from 'native-base';

export default class FeedsList extends React.Component {
  render() {
    const { feeds } = this.props.screenProps.store;
    return (
      <Container>
        <Content>
          <List>
            {feeds &&
              feeds.map((f, i) => (
              <ListItem key={i}>
              <Text>{f.title}</Text>
              </ListItem>
             ))
          </List>
        </Content>
      </Container>
    );
  }
}

该组件期望从 this.props.screenProps.store 接收 feeds 列表,然后遍历该列表构建一个 NativeBase <List>,显示存储中每个 feed 的标题。

让我们现在介绍一些 MobX 的魔法。由于我们希望当 feeds 列表发生变化时(添加或删除 feed 时)我们的组件能够重新渲染,因此我们必须使用 @observer 装饰器标记我们的组件。MobX 将自动在任何更新时强制组件重新渲染。现在让我们看看如何将装饰器添加到我们的组件中:

...

@observer
export default class FeedsList extends React.Component {

...

就是这样。现在,我们的组件将在存储更改时收到通知,并将触发重新渲染。

添加事件处理程序

让我们添加一个事件处理程序,当用户点击 feed 标题时,将在新屏幕(FeedDetail)上显示该 feed 的条目列表:

/** * src/screens/FeedsList.js ***/

...

@observer
export default class FeedsList extends React.Component {
  _handleFeedPress(feed) {
    selectFeed(feed);
    this.props.navigation.navigate('FeedDetail', { feedUrl: feed.url });
  }

  render() {
    const { feeds } = this.props.screenProps.store;
    return (
      <Container>
        <Content>
          <List>
            {feeds &&
              feeds.map((f, i) => (
              <ListItem key={i} onPress=
              {this._handleFeedPress.bind(this, f)}>
              <Text>{f.title}</Text>
              </ListItem>
             ))
            }
          </List>
        </Content>
      </Container>
    );
  }
}

...

为此,我们在组件中添加了一个名为 _handleFeedPress 的方法,该方法将接收 feed 的详细信息作为参数。当调用此方法时,它将运行 selectFeed 动作,并将传递 feed 的 URL 作为属性触发导航事件,以便下一个屏幕(FeedDetail)可以根据该 URL 包含一个删除 feed 的按钮。

最后,我们将添加 navigationOptions,包括导航标题和添加 feed 的按钮:

/** * src/screens/FeedsList.js ***/

...

@observer
export default class FeedsList extends React.Component {
  static navigationOptions = props => ({
    title: 'My Feeds',
    headerRight: (
      <Button transparent onPress={() => 
      props.navigation.navigate('AddFeed')}>
        <Icon name="add" />
      </Button>
    ),
  });

...

}

按下AddFeed按钮将导航到AddFeed屏幕。通过将它作为名为headerRight的属性传递给navigationOptions,该按钮将显示在导航标题的右侧。

让我们看看这个组件是如何一起的:

/*** src/screens/FeedsList.js ** */

import React from 'react';
import {
  Container,
  Content,
  List,
  ListItem,
  Text,
  Icon,
  Button,
} from 'native-base';
import { observer } from 'mobx-react/native';
import { selectFeed, removeFeed } from '../actions';

@observer
export default class FeedsList extends React.Component {
  static navigationOptions = props => ({
    title: 'My Feeds',
    headerRight: (
      <Button transparent onPress={() => 
       props.navigation.navigate('AddFeed')}>
        <Icon name="add" />
      </Button>
    ),
  });

  _handleFeedPress(feed) {
    selectFeed(feed);
    this.props.navigation.navigate('FeedDetail', { feedUrl: feed.url });
  }

  render() {
    const { feeds } = this.props.screenProps.store;
    return (
      <Container>
        <Content>
          <List>
            {feeds &&
              feeds.map((f, i) => (
              <ListItem key={i} onPress=
              {this._handleFeedPress.bind(this, f)}>
              <Text>{f.title}</Text>
              </ListItem>
             ))
          </List>
        </Content>
      </Container>
    );
  }
}

现在我们的 feeds 列表功能完全可用,让我们允许用户通过AddFeed屏幕添加一些 feeds。

构建 AddFeed 屏幕

该屏幕包括一个基本表单,包括一个用于从 feed 获取 URL 的<Input>和一个用于从提供的 URL 检索 feed 信息以后将 feed 的详细信息存储在我们的存储中的<Button>

我们需要导入两个操作(addFeedfetchFeed),这两个操作将在按下Add按钮时被调用:

/*** src/screens/AddFeed.js ** */

import React from 'react';
import {
  Container,
  Content,
  Form,
  Item,
  Input,
  Button,
  Text,
} from 'native-base';
import { addFeed, fetchFeed } from '../actions';
import { Alert, ActivityIndicator } from 'react-native';

export default class AddFeed extends React.Component {
  static navigationOptions = {
    title: 'Add feed',
  };

  constructor(props) {
    super(props);
    this.state = {
      url: '',
      loading: false,
    };
  }

  _handleAddPress() {
    if (this.state.url.length > 0) {
      this.setState({ loading: true });
      fetchFeed(this.state.url)
        .then(feed => {
          addFeed(this.state.url, feed);
          this.setState({ loading: false });
          this.props.navigation.goBack();
        })
        .catch(() => {
          Alert.alert("Couldn't find any rss feed on that url");
          this.setState({ loading: false });
        });
    }
  }

  render() {
    return (
      <Container style={{ padding: 10 }}>
        <Content>
          <Form>
            <Item>
              <Input
                autoCapitalize="none"
                autoCorrect={false}
                placeholder="feed's url"
                onChangeText={url => this.setState({ url })}
              />
            </Item>
            <Button
              block
              style={{ marginTop: 20 }}
              onPress={this._handleAddPress.bind(this)}
            >
              {this.state.loading && (
                <ActivityIndicator color="white" style={{ margin: 10 }}  
                />
              )}
              <Text>Add</Text>
            </Button>
          </Form>
        </Content>
      </Container>
    );
  }
}

这个组件中大部分功能都在_handleAddPress中,因为它是处理程序,一旦按下Add按钮就会被触发。这个处理程序负责四个任务:

  • 检查是否存在 URL 以检索数据

  • 从提供的 URL 检索 feed 数据(通过fetchFeed操作)

  • 将数据保存到应用程序状态中(通过addFeed操作)

  • 在获取或保存数据时,向用户发出警告。

需要注意的一件重要的事情是fetchFeed操作的使用方式。由于它是用async语法声明的,我们可以将它用作一个 promise,并将它附加到其监听器的结果上,用于thencatch

ActivityIndicator

在每次应用程序需要等待 HTTP 请求的响应时显示一个旋转器是一个很好的做法。iOS 和 Android 都有标准的活动指示器来显示这种行为,两者都可以通过 React Native 模块中的<ActivityIndicator>组件获得。

显示这个指示器的最简单方法是在组件状态中保持一个loading标志。由于这个标志只被我们的组件用来显示这个<ActivityIndicator>,所以把它放在组件的状态中而不是移动到通用的应用程序状态中是有意义的。然后,它可以在render函数中使用:

{ this.state.loading && <ActivityIndicator color='white' style={{margin: 10}}/>}

这种语法在 React 应用程序中非常常见,用于根据标志或简单条件显示或隐藏组件。它利用了 JavaScript 评估&&操作的方式:检查第一个操作数的真实性,如果为真,则返回第二个操作数;否则,返回第一个操作数。这种语法在一种非常常见的指令上节省了代码行数,因此它将在本书中广泛使用。

构建FeedDetail屏幕

让我们回顾一下当用户在FeedsList屏幕上点击一个 feed 时发生了什么:

_handleFeedPress(feed) {
  selectFeed(feed);
  this.props.navigation.navigate('FeedDetail', { feedUrl: feed.url });
}

navigation属性上调用了navigate方法,以打开FeedDetail屏幕。作为参数,_handleFeedPress函数传递了feedUrl,这样它就可以检索 feed 数据并显示给用户。这是一个必要的步骤,因为我们在存储中为所选的 feed 拥有的数据可能已经过时。因此,在向用户显示之前,最好重新获取数据,以确保它是 100%更新的。我们也可以进行更复杂的检查,而不是每次用户选择 feed 时都检索整个 feed,但为了保持这个应用程序的简单性,我们将坚持采用给定的方法。

让我们从componentWillMount方法中检索更新后的条目列表开始:

/*** src/screens/FeedDetail.js ***/

import React from 'react';
import { observer } from 'mobx-react/native';
import { fetchFeed} from '../actions';

@observer
export default class FeedDetail extends React.Component {
 ... 

 constructor (props) {
  super(props);
  this.state = {
    loading: false,
    entry: null
  }
 }

 componentWillMount() {
  this.setState({ loading: true });
  fetchFeed(this.props.screenProps.store.selectedFeed.url)
   .then((feed) => {
    this.setState({ loading: false });
    this.setState({ entry: feed.entry});
  });
 }

 ...

}

我们将把我们的组件标记为@observer,这样它就会在所选的 feed 改变时得到更新。然后,我们需要一个具有两个属性的状态:

  • loading:这是一个标志,用来向用户表示我们正在获取更新的数据

  • entry:这是要显示给用户的条目列表

然后,在组件挂载之前,我们想要开始检索更新后的条目。为此,我们可以重用在AddFeed屏幕中使用的fetchFeed操作。当接收到 feed 数据时,组件状态中的loading标志被设置为false,这将隐藏<ActivityIndicator>,并且条目列表将被设置在组件状态中。现在我们有了一个条目列表,让我们看看我们将如何向用户显示它:

/** * src/screens/FeedDetail.js ** */

import React from 'react';
import {
  Container,
  Content,
  List,
  ListItem,
  Text,
  Button,
  Icon,
  Spinner,
} from 'native-base';
import { observer } from 'mobx-react/native';
import { fetchFeed } from '../actions';
import { ActivityIndicator } from 'react-native';

@observer
export default class FeedDetail extends React.Component {

  ...

  render() {
    const { entry } = this.state;

    return (
      <Container>
        <Content>
          {this.state.loading && <ActivityIndicator style=
          {{ margin: 20 }} />}
          <List>
            {entry &&
              entry.map((e, i) => (
                <ListItem key={i}>
                  <Text>{e.title}</Text>
                </ListItem>
              ))}
          </List>
        </Content>
      </Container>
    );
  }
}

&& 语法再次被用来显示<ActivityIndicator>,直到数据被检索出来。一旦数据可用并且正确存储在组件状态的entry属性中,我们将渲染包含所选字段条目标题的列表项。

现在,我们将添加一个事件处理程序,当用户点击条目标题时将被触发:

/** * src/screens/FeedDetail.js ** */

import React from 'react';
import {
  Container,
  Content,
  List,
  ListItem,
  Text,
  Button,
  Icon,
  Spinner,
} from 'native-base';
import { observer } from 'mobx-react/native';
import { selectEntry, fetchFeed } from '../actions';
import { ActivityIndicator } from 'react-native';

@observer
export default class FeedDetail extends React.Component {

  ...

  _handleEntryPress(entry) {
    selectEntry(entry);
    this.props.navigation.navigate('EntryDetail');
  }

  render() {
    const { entry } = this.state;

    return (
      <Container>
        <Content>
          {this.state.loading && <ActivityIndicator style=
          {{ margin: 20 }} />}
          <List>
            {entry &&
              entry.map((e, i) => (
                <ListItem
                  key={i}
                  onPress={this._handleEntryPress.bind(this, e)}
                >
                  <Text>{e.title}</Text>
                </ListItem>
              ))}
          </List>
        </Content>
      </Container>
    );
  }
}

这个处理程序被命名为_handleEntryPress,负责两个任务:

  • 将点击的条目标记为已选

  • 导航到EntryDetail

最后,让我们通过navigationOptions方法添加导航标题:

/** * src/screens/FeedDetail.js ** */

...

@observer
export default class FeedDetail extends React.Component {
  static navigationOptions = props => ({
    title: props.screenProps.store.selectedFeed.title,
    headerRight: (
      <Button
        transparent
        onPress={() => {
          removeFeed(props.navigation.state.params.feedUrl);
          props.navigation.goBack();
        }}
      >
        <Icon name="trash" />
      </Button>
    ),
  });

  ...

}

除了为这个屏幕添加标题(feed 的标题)之外,我们还希望为用户的导航栏添加一个图标,以便用户能够从应用程序中存储的 feed 列表中删除该 feed。我们将使用native-basetrash图标来实现这个目的。当按下时,将调用removeFeed动作,传递当前 feed URL,以便可以从存储中删除,然后将强制导航返回到FeedList屏幕。

让我们来看看完成的组件:

/*** src/screens/FeedDetail.js ** */

import React from 'react';
import {
  Container,
  Content,
  List,
  ListItem,
  Text,
  Button,
  Icon,
  Spinner,
} from 'native-base';
import { observer } from 'mobx-react/native';
import { selectEntry, fetchFeed, removeFeed } from '../actions';
import { ActivityIndicator } from 'react-native';

@observer
export default class FeedDetail extends React.Component {
  static navigationOptions = props => ({
    title: props.screenProps.store.selectedFeed.title,
    headerRight: (
      <Button
        transparent
        onPress={() => {
          removeFeed(props.navigation.state.params.feedUrl);
          props.navigation.goBack();
        }}
      >
        <Icon name="trash" />
      </Button>
    ),
  });

  constructor(props) {
    super(props);
    this.state = {
      loading: false,
      entry: null,
    };
  }

  componentWillMount() {
    this.setState({ loading: true });
    fetchFeed(this.props.screenProps.store.selectedFeed.url).
    then(feed => {
      this.setState({ loading: false });
      this.setState({ entry: feed.entry });
    });
  }

  _handleEntryPress(entry) {
    selectEntry(entry);
    this.props.navigation.navigate('EntryDetail');
  }

  render() {
    const { entry } = this.state;

    return (
      <Container>
        <Content>
          {this.state.loading && <ActivityIndicator style=
          {{ margin: 20 }} />}
          <List>
            {entry &&
              entry.map((e, i) => (
              <ListItem key={i} onPress=
              {this._handleEntryPress.bind(this, e)}>
              <Text>{e.title}</Text>
          </ListItem>
          ))
          </List>
        </Content>
      </Container>
    );
  }
}

现在,让我们继续到最后一个屏幕:EntryDetail

构建 EntryDetail 屏幕

EntryDetail屏幕只是一个 WebView:一个能够在原生视图中呈现 web 内容的组件。您可以将 WebView 视为一个轻量级的 web 浏览器,显示提供的 URL 的网站内容:

import React from 'react';
import { Container, Content } from 'native-base';
import { WebView } from 'react-native';

export default class EntryDetail extends React.Component {
  render() {
    const entry = this.props.screenProps.store.selectedEntry;
    return <WebView source={{ uri: entry.link.href || entry.link }} />;
  }
}

这个组件中的render方法只是返回一个新的WebView组件,加载存储中所选条目的 URL。就像我们在前面的部分中对 feed 的数据所做的那样,我们需要从this.props.screenProps.store中检索selectedEntry数据。URL 可以以两种不同的方式存储,这取决于 feed 的 RSS 版本:在链接属性中或者在link.href中再深一层。

总结

当应用程序的复杂性开始增长时,每个应用程序都需要一个状态管理库。作为一个经验法则,当应用程序由四个以上的屏幕组成并且它们之间共享信息时,添加状态管理库是一个好主意。对于这个应用程序,我们使用了 MobX,它简单但足够强大,可以处理所有的订阅和条目数据。在本章中,您学习了 MobX 的基础知识以及如何与react-navigation一起使用它。重要的是要理解动作和存储的概念,因为我们将在未来的应用程序中使用它们,不仅建立在 MobX 周围,还建立在 Redux 周围。

您还学会了如何从远程 URL 获取数据。这是大多数移动应用程序中非常常见的操作,尽管我们只涵盖了它的基本用法。在接下来的章节中,我们将深入研究Fetch API。此外,我们还看到了如何处理和格式化获取的数据,以便在我们的应用程序中加以规范化。

最后,我们回顾了什么是 WebView 以及如何将 web 内容插入到我们的原生应用程序中。这可以通过本地 HTML 字符串或通过 URL 远程完成,因此这是移动开发人员用来重用或访问仅限于 web 的内容的一个非常强大的技巧。

第三章:汽车预订应用

在之前的章节中,我们将重点放在功能开发上,而不是在构建用户界面上,将我们应用的样式委托给 UI 库,如native-base。在本章中,我们将做相反的事情,花更多的时间来构建自定义 UI 组件和屏幕。

我们要构建的应用是一个汽车预订应用,用户可以选择想要被接送的位置以及想要预订的车辆类型。由于我们想要专注于用户界面,我们的应用只会有两个屏幕,并且需要一些状态管理。相反,我们将更深入地研究诸如动画、组件布局、使用自定义字体或显示外部图像等方面。

该应用将适用于 iOS 和 Android 设备,由于所有用户界面都将是定制的,因此代码的 100%将在两个平台之间重复使用。我们只会使用两个外部库:

    • React-native-geocoder:这将把坐标转换为人类可读的位置
    • React-native-maps:这将轻松显示地图和显示可预订汽车位置的标记

由于其性质,大多数汽车预订应用将其复杂性放在后端代码中,以有效地连接司机和乘客。我们将跳过这种复杂性,并在应用程序本身中模拟所有这些功能,以便专注于构建美观和可用的界面。

概述

在构建移动应用程序时,我们需要确保将界面复杂性降至最低,因为一旦应用程序打开,向用户呈现侵入式手册或工具提示通常是有害的。让我们的应用自解释是一个好习惯,这样用户就可以通过浏览应用屏幕来理解使用方法。这就是为什么使用标准组件,如抽屉菜单或标准列表,总是一个好主意,但并非总是可能的(就像我们当前的应用中发生的情况),因为我们想要向用户呈现的数据类型。

在我们的情况下,我们将所有功能放在主屏幕和一个模态框中。让我们来看看这款应用在 iOS 设备上的样子:

我们主屏幕的背景是地图组件本身,我们将在地图中显示所有可用的汽车作为标记。在地图上,我们将显示三个组件:

    • 选择位置框,显示所选的接送位置
    • 位置图钉,可以在地图上拖动以选择新位置
  • 用户想要预订的汽车类型的选择器。我们将显示三个选项:经济型,特别型和高级型

由于大多数组件都是自定义构建的,因此此屏幕在任何 Android 设备上看起来都非常相似:

iOS 和 Android 版本之间的主要区别将是地图组件。虽然 iOS 将默认使用 Apple 地图,但 Android 使用 Google 地图。我们将保留此设置,因为每个平台都有其自己优化的地图组件,但值得知道的是,我们可以通过配置我们的组件将 iOS 版本切换到使用 Google 地图。

一旦用户选择了取货地点,我们将显示一个模态框来确认预订并联系最近的司机接送。

与主屏幕一样,此屏幕使用自定义组件:我们甚至决定创建自己的动画活动指示器。因此,Android 版本将看起来非常相似:

由于我们的应用程序不会连接到任何外部 API,它应该被视为 React Native 的视觉能力的纯粹展示,尽管可以通过添加状态管理库和匹配的 API 轻松扩展。

在本章中,我们将涵盖以下主题:

  • 在我们的应用程序中使用地图

  • React Native 中的样式表

  • React Native 中的 Flexbox

  • 在 React Native 应用程序中使用外部图像

  • 添加自定义字体

  • React Native 中的动画

  • 使用模态框

  • 处理阴影和不透明度

设置文件夹结构

让我们使用 React Native 的 CLI 初始化一个 React Native 项目。 该项目将被命名为carBooking,并将适用于 iOS 和 Android 设备:

react-native init --version="0.49.3" carBooking

在此应用程序中,只有一个屏幕,因此代码的文件夹结构应该非常简单。由于我们将使用外部图像和字体,我们将这些资源组织在两个单独的文件夹中:imgfonts,都在根文件夹下:

用于构建此应用程序的图像和字体可以从一些图像和字体库网站免费下载。我们将使用的字体名称是Blair ITC

我们还将以下图像存储在img文件夹中:

  • car.png:一辆汽车的简单图画,用于表示地图上可预订的汽车。

  • class.png:一辆汽车的轮廓,显示在类别选择按钮内部。

  • classBar.png:用于滑动更改班级选择按钮的栏。

  • loading.png:我们自定义的旋转器。它将被存储为静态图像,并通过代码进行动画处理。

最后,让我们来看看我们的package.json文件:

{
    "name": "carBooking",
    "version": "0.0.1",
    "private": true,
    "scripts": {
        "start": "node node_modules/react-native/local-cli/cli.js start",
        "test": "jest"
    },
    "dependencies": {
        "react": "16.0.0-beta.5",
        "react-native": "0.49.3",
        "react-native-geocoder": "⁰.4.8",
 "react-native-maps": "⁰.15.2"
    },
    "devDependencies": {
        "babel-jest": "20.0.3",
        "babel-preset-react-native": "1.9.2",
        "jest": "20.0.4",
        "react-test-renderer": "16.0.0-alpha.6"
    },
    "jest": {
        "preset": "react-native"
    },
    "rnpm": {
 "assets": ["./fonts"]
 }
}

我们只使用两个 npm 模块:

  • react-native-geocoder:这将坐标转换为可读的位置

  • react-native-maps:这可以轻松显示地图和显示可预订汽车位置的标记

为了允许应用程序使用自定义字体,我们需要确保它们可以从本机端访问。为此,我们需要在package.json中添加一个名为rnpm的新键。这个键将存储一个assets数组,在其中我们将定义我们的fonts文件夹。在构建时,React Native 将把字体复制到一个位置,从那里它们将在本机端可用,因此可以在我们的代码中使用。这仅对字体和一些特殊资源是必需的,但不适用于图像。

由 React Native 的 CLI 创建的文件和文件夹

让我们利用这个应用程序中的简单文件夹结构来展示通过react-native init <projectName>初始化项目时 React Native 的 CLI 创建的其他文件和文件夹。

__ 测试 __/

React Native 的 CLI 包括 Jest 作为开发人员依赖项,并且为了开始测试,它包括一个名为__tests__的文件夹,其中可以存储所有测试。默认情况下,React Native 的 CLI 添加一个测试文件:index.js,代表初始一组测试。开发人员可以为应用程序中的任何组件添加后续测试。React Native 还在我们的package.json中添加了一个test脚本,因此我们可以从一开始就运行npm run test

Jest 已准备好与通过 CLI 初始化的每个项目一起使用,当涉及到测试 React 组件时,它绝对是最简单的选择,尽管也可以使用其他库,如 Jasmine 或 Mocha。

android/和 ios/

这两个文件夹分别保存了两个平台的原生构建应用程序。这意味着我们可以在这里找到我们的.xcodeproj.java文件。每当我们需要对应用程序的本机代码进行更改时,我们都需要修改这两个目录中的一些文件。

在这些文件夹中查找和修改文件的最常见原因是:

  • 通过更改Info.plist(iOS)或AndroidManifest.xml(Android)来修改权限(推送通知,访问位置服务,访问指南针等)

  • 更改任何平台的构建设置

  • 为原生库添加 API 密钥

  • 添加或修改原生库,以便从我们的 React Native 代码中使用

node_modules/

这个文件夹对大多数使用 npm 的 JavaScript 开发人员来说应该很熟悉,因为 npm 将所有标记为项目依赖项的模块存储在这里。在这个文件夹内修改任何内容的必要性并不常见,因为一切都应该通过 npm 的 CLI 和我们的package.json文件来处理。

根文件夹中的文件

React Native 的 CLI 在项目的根目录中创建了许多文件;让我们来看看最重要的文件:

  • .babelrc:Babel 是 React Native 中用于编译包含 JSX 和 ES6(例如,语法的 JavaScript 文件的默认库,可以转换为大多数 JavaScript 引擎能够理解的普通 JavaScript)。在这里,我们可以修改这个编译器的配置,例如,我们可以使用@语法作为装饰器,就像在 React 的最初版本中所做的那样。

  • .buckconfig:Buck 是 Facebook 使用的构建系统。这个文件用于配置使用 Buck 时的构建过程。

  • .watchmanconfig:Watchman 是一个监视项目中文件的服务,以便在文件发生变化时触发重新构建。在这个文件中,我们可以添加一些配置选项,比如应该被忽略的目录。

  • app.json:这个文件被react-native eject命令用来配置原生应用程序。它存储了在每个平台上标识应用程序的名称,以及在设备的主屏幕上安装应用程序时将显示的名称。

  • yarn.lockpackage.json文件描述了原始作者期望的版本,而yarn.lock描述了给定应用程序的最后已知的良好配置。

react-native link

一些应用程序依赖具有原生能力的库,在 React Native CLI 之前,开发人员需要将原生库文件复制到原生项目中。这是一个繁琐和重复的工作,直到react-native link出现才得以解救。在本章中,我们将使用它来从react-native-maps复制库文件,并将自定义字体从我们的/fonts文件夹链接到编译后的应用程序。

通过在项目的根文件夹中运行react-native link,我们将触发链接步骤,这将使那些原生能力和资源可以从我们的 React Native 代码中访问。

在模拟器中运行应用程序

package.json文件中具有依赖项并且所有初始文件就位后,我们可以运行以下命令(在项目的根文件夹中)来完成安装:

npm install

然后,所有依赖项都应该安装在我们的项目中。一旦 npm 完成安装所有依赖项,我们就可以在 iOS 模拟器中启动我们的应用程序:

react-native run-ios

或者在 Android 模拟器中使用以下命令:

react-native run-android

当 React Native 检测到应用程序在模拟器中运行时,它会通过一个隐藏菜单启用开发人员工具集,可以通过快捷键command + D(在 iOS 上)或command + M(在 Android 上,Windows 上应使用Crtl而不是command)访问。这是 iOS 中开发人员菜单的样子:

这是在 Android 模拟器中的样子:

开发人员菜单

在构建 React Native 应用程序的过程中,开发人员将需要调试。React Native 通过能够在 Chrome 开发者工具或外部应用程序(如 React Native Debugger)中远程调试我们的应用程序来满足这些需求。错误、日志甚至 React 组件都可以像在普通的 Web 环境中一样轻松地进行调试。

此外,React Native 提供了一种自动重新加载应用程序的方式,每次进行更改时都会重新加载应用程序,从而节省了开发人员手动重新加载应用程序的任务(可以通过按command + RCtrl + R来实现)。当我们为自动重新加载设置应用程序时,有两个选项:

  • 实时重新加载检测到我们在应用程序代码中进行的任何更改,并在重新加载后将应用程序重置为其初始状态。

  • 热重载还可以检测更改并重新加载应用程序,但保持应用程序的当前状态。当我们正在实现用户流程以节省开发人员重复每个步骤时(例如,登录或注册测试用户)时,这非常有用。

最后,我们可以启动性能监视器来检测执行复杂操作(如动画或数学计算)时可能出现的性能问题。

创建我们应用程序的入口点

让我们通过创建我们应用程序的入口点index.js来开始我们的应用程序代码。我们在这个文件中导入src/main.js,以便为我们的代码库使用一个公共根组件。此外,我们将使用名称carBooking注册应用程序:

/*** index.js ***/

import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('carBooking', () => App);

让我们通过添加地图组件来开始构建我们的src/main.js

/*** src/main.js ** */

import React from 'react';
import { View, StyleSheet } from 'react-native';
import MapView from 'react-native-maps';

export default class Main extends React.Component {
  constructor(props) {
    super(props);
    this.initialRegion = {
      latitude: 37.78825,
      longitude: -122.4324,
      latitudeDelta: 0.00922,
      longitudeDelta: 0.00421,
    };
  }

  render() {
    return (
      <View style={{ flex: 1 }}>
        <MapView
          style={styles.fullScreenMap}
          initialRegion={this.initialRegion}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  fullScreenMap: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    left: 0,
    right: 0,
  },
});

我们将使用StyleSheet来创建自己的样式,而不是使用样式库,StyleSheet是一个类似于 CSS 样式表的抽象的 React Native API。使用StyleSheet,我们可以从对象中创建样式表(通过create方法),然后通过引用每个样式的 ID 在我们的组件中使用它们。

这样,我们可以重用样式代码,并使代码更易读,因为我们将使用有意义的名称来引用每个样式(例如,<Text style={styles.title}>Title 1</Text>)。

在这一点上,我们只会创建一个由键fullScreenMap引用的样式,并通过将topbottomleftright坐标添加到零来将其设置为绝对位置,覆盖全屏大小。除此之外,我们需要为我们的容器视图添加一些样式,以确保它填满整个屏幕:{flex: 1}。将flex设置为1,我们希望我们的视图填满其父级占用的所有空间。由于这是主视图,{flex: 1}将占据整个屏幕。

对于我们的地图组件,我们将使用react-native-maps,这是由 Airbnb 创建的一个开放模块,利用了 Google 和 Apple 地图的本地地图功能。react-native-maps是一个非常灵活的模块,得到了很好的维护,并且功能齐全,因此它已经成为 React Native 的事实标准地图模块。正如我们将在本章后面看到的,react-native-maps要求开发人员运行react-native link才能正常工作。

除了样式,<MapView/>组件将以initialRegion作为属性,将地图居中在特定的坐标上,这应该是用户当前位置。出于一致性原因,我们将把地图的中心定位在旧金山,在那里我们还将放置一些可预订的汽车:

/** * src/main.js ** */

import React from 'react';
import { View, Animated, Image, StyleSheet } from 'react-native';
import MapView from 'react-native-maps';

export default class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      carLocations: [
 {
 rotation: 78,
 latitude: 37.78725,
 longitude: -122.4318,
 },
 {
 rotation: -10,
 latitude: 37.79015,
 longitude: -122.4318,
 },
 {
 rotation: 262,
 latitude: 37.78525,
 longitude: -122.4348,
 },
 ],
    };
    this.initialRegion = {
      latitude: 37.78825,
      longitude: -122.4324,
      latitudeDelta: 0.00922,
      longitudeDelta: 0.00421,
    };
  }

  render() {
    return (
      <View style={{ flex: 1 }}>
        <MapView
          style={styles.fullScreenMap}
          initialRegion={this.initialRegion}
        >
          {this.state.carLocations.map((carLocation, i) => (
 <MapView.Marker key={i} coordinate={carLocation}>
 <Animated.Image
 style={{
 transform: [{ rotate: `${carLocation.rotation}deg` }],
 }}
 source={require('../img/car.png')}
 />
 </MapView.Marker>
 ))}
        </MapView>
      </View>
    );
  }
}

...

我们已经添加了一个carLocations数组,以便在地图上显示为标记。在我们的render函数中,我们将遍历这个数组,并在提供的坐标中放置相应的<MapView.Marker/>。在每个标记内,我们将添加汽车的图像,并将其旋转特定角度,以使其与街道方向匹配。旋转图像必须使用AnimatedAPI 完成,这将在本章后面更好地解释。

让我们在我们的状态中添加一个新属性,用于存储地图所居中的位置的可读位置:

/** * src/main.js ** */

import GeoCoder from 'react-native-geocoder';

export default class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
 position: null,

      ...

    };

    ...

  }

  _onRegionChange(region) {
 this.setState({ position: null });
 const self = this;
 if (this.timeoutId) clearTimeout(this.timeoutId);
 this.timeoutId = setTimeout(async () => {
 try {
 const res = await GeoCoder.geocodePosition({
 lat: region.latitude,
 lng: region.longitude,
 });
 self.setState({ position: res[0] });
 } catch (err) {
 console.log(err);
 }
 }, 2000);
  }
  componentDidMount() {
 this._onRegionChange.call(this, this.initialRegion);
 }

  render() {
    <View style={{ flex: 1 }}>
      <MapView
        style={styles.fullScreenMap}
        initialRegion={this.initialRegion}
        onRegionChange={this._onRegionChange.bind(this)}
      >

      ...

      </MapView>
    </View>;
  }
}

...

为了填充这个状态变量,我们还创建了一个名为_onRegionChange的函数,它使用react-native-geocoder模块。该模块使用 Google Maps 的逆地理编码服务将一些坐标转换为可读的位置。因为这是一个 Google 服务,我们可能需要添加一个 API 密钥来验证我们的应用程序与该服务的身份。可以在其存储库 URL 中找到完全安装此模块的所有说明github.com/airbnb/react-native-maps/blob/master/docs/installation.md

我们希望这个状态变量从主组件的第一个挂载就可用,所以我们将在componentDidMount中调用_onRegionChange,以便初始位置的名称也存储在状态中。此外,我们将在我们的<MapView/>上添加onRegionChange属性,以确保位置的名称在地图移动到显示不同区域时重新计算,这样我们总是可以在我们的position状态变量中拥有地图中心的位置名称。

作为屏幕的最后一步,我们将添加所有子视图和另一个函数来确认预订请求:

/** * src/main.js ** */

...

import LocationPin from './components/LocationPin';
import LocationSearch from './components/LocationSearch';
import ClassSelection from './components/ClassSelection';
import ConfirmationModal from './components/ConfirmationModal';

export default class Main extends React.Component {
  ...

  _onBookingRequest() {
 this.setState({
 confirmationModalVisible: true,
 });
 }

  render() {
    return (
      <View style={{ flex: 1 }}>
        ...

        <LocationSearch
 value={
 this.state.position &&
 (this.state.position.feature ||
 this.state.position.formattedAddress)
 }
 />
        <LocationPin onPress={this._onBookingRequest.bind(this)} />
        <ClassSelection />
        <ConfirmationModal
          visible={this.state.confirmationModalVisible}
          onClose={() => {
            this.setState({ confirmationModalVisible: false });
          }}
        />
      </View>
    );
  }
}

...

我们添加了四个子视图:

  • LocationSearch:在这个组件中,我们将向用户显示地图中心的位置,以便她可以知道她确切请求接送的位置的名称。

  • LocationPin:一个指向地图中心的图钉,这样用户可以在地图上看到她将要请求接送的位置。它还将显示一个确认接送的按钮。

  • ClassSelection:用户可以在其中选择接送车辆类型(经济、特殊或高级)的条形图。

  • ConfirmationModal:显示请求确认的模态框。

_onBookingRequest方法将负责在请求预订时弹出确认模态框。

向我们的应用程序添加图像

React Native 处理图像的方式与网站类似:图像应放在项目文件夹结构内的一个文件夹中,然后可以通过<Image/>(或<Animated.Image/>)的source属性引用它们。让我们看一个来自我们应用程序的例子:

  • car.png:这个文件放在我们项目根目录的img/文件夹中

  • 然后,通过使用source属性创建一个<Image/>组件来显示图像:

       <Image source={require('../img/car.png')} />

请注意source属性不接受字符串,而是require('../img/car.png')。这在 React Native 中是一个特殊情况,可能会在将来的版本中更改。

LocationSearch

这应该是一个简单的文本框,显示地图中心的可读名称。让我们看一下代码:

/*** src/components/LocationSearch.js ** */

import React from 'react';
import {
  View,
  Text,
  TextInput,
  ActivityIndicator,
  StyleSheet,
} from 'react-native';

export default class LocationSearch extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>PICKUP LOCATION</Text>
        {this.props.value && (
          <TextInput style={styles.location} value={this.props.value} />
        )}
        {!this.props.value && <ActivityIndicator style={styles.spinner} />}
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: 'white',
    margin: 20,
    marginTop: 40,
    height: 60,
    padding: 10,
    borderColor: '#ccc',
    borderWidth: 1,
  },
  title: {
    alignSelf: 'center',
    fontSize: 12,
    color: 'green',
    fontWeight: 'bold',
  },
  location: {
    height: 40,
    textAlign: 'center',
    fontSize: 13,
  },
  spinner: {
    margin: 10,
  },
});

它只接收一个属性:value(要显示的位置名称)。如果未设置,它将显示一个旋转器以显示活动。

由于在此组件中需要应用许多不同的样式,因此最好使用StyleSheet API 将样式组织在键/值对象中,并从我们的render方法中引用它。逻辑和样式之间的分离有助于代码的可读性,还可以使代码重用,因为样式可以级联到子组件。

对齐元素

React Native 使用 Flexbox 来设置应用程序中元素的布局。这通常很简单,但有时在对齐元素时可能会令人困惑,因为有四个属性可用于此目的:

  • justifyContent:它定义了子元素沿着主轴的对齐方式

  • alignItems:它定义了子元素沿着交叉轴的对齐方式

  • alignContent:当交叉轴上有额外空间时,它会对齐 flex 容器的行

  • alignSelf:它允许覆盖单个 flex 项的默认对齐方式(或由alignItems指定的对齐方式)

前三个属性应分配给容器元素,而第四个属性将应用于子元素,以便在需要覆盖默认对齐方式时使用。

在我们的情况下,我们只希望一个元素(标题)居中对齐,因此我们可以使用alignSelf: 'center'。在本章的后面,我们将看到不同的align属性的其他用途。

LocationPin

在本节中,我们将专注于构建指向地图中心的标记,以直观确认取货位置。此标记还包含一个按钮,可用于触发取货请求:

/** * src/components/LocationPin.js ** */

import React from 'react';
import {
  View,
  Text,
  Dimensions,
  TouchableOpacity,
  StyleSheet,
} from 'react-native';

const { height, width } = Dimensions.get('window');

export default class LocationPin extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <View style={styles.banner}>
          <Text style={styles.bannerText}>SET PICKUP LOCATION</Text>
          <TouchableOpacity
 style={styles.bannerButton}
 onPress={this.props.onPress}
 >
            <Text style={styles.bannerButtonText}>{'>'}</Text>
          </TouchableOpacity>
        </View>
        <View style={styles.bannerPole} />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    top: height / 2 - 60,
 left: width / 2 - 120,
  },
  banner: {
    flexDirection: 'row',
 alignSelf: 'center',
 justifyContent: 'center',
    borderRadius: 20,
    backgroundColor: '#333',
    padding: 10,
    paddingBottom: 10,
    shadowColor: '#000000',
 shadowOffset: {
 width: 0,
 height: 3,
 },
 shadowRadius: 5,
 shadowOpacity: 1.0,
  },
  bannerText: {
    alignSelf: 'center',
    color: 'white',
    marginRight: 10,
    marginLeft: 10,
    fontSize: 18,
  },
  bannerButton: {
    borderWidth: 1,
    borderColor: '#ccc',
    width: 26,
    height: 26,
    borderRadius: 13,
  },
  bannerButtonText: {
    color: 'white',
    textAlign: 'center',
    backgroundColor: 'transparent',
    fontSize: 18,
  },
  bannerPole: {
    backgroundColor: '#333',
    width: 3,
    height: 30,
    alignSelf: 'center',
  },
});

就功能而言,这个组件再次非常轻量级,但具有许多自定义样式。让我们深入了解一些样式细节。

flexDirection

默认情况下,React Native 和 Flexbox 会垂直堆叠元素:

对于我们的标记中的横幅,我们希望将每个元素水平堆叠在一起,如下所示:

这可以通过向包含元素添加以下样式来实现flexDirection: 'row'flexDirection的其他有效选项是:

  • row-reverse

  • column(默认)

  • column-reverse

尺寸

在这个组件中的代码的第一行从设备中提取高度和宽度到两个变量中:

const {height, width} = Dimensions.get('window');

获取设备的高度和宽度使我们开发人员能够绝对定位一些元素,确信它们将正确对齐显示。例如,我们希望我们的图钉的横幅对齐在屏幕中央,所以它指向地图的中心。我们可以在样式表中的banner样式中添加{top: (height/2), left: (width/2)}来实现这一点。当然,这会将其对齐到左上角,所以我们需要从每个属性中减去横幅大小的一半,以确保它在元素的中间得到居中。每当我们需要对齐一个与组件树中的任何其他元素无关的元素时,都可以使用这个技巧,尽管在可能的情况下建议使用相对定位。

阴影

让我们专注于我们横幅的样式,特别是shadows属性:

banner: {
  ...
 shadowColor: '#000000',
 shadowOffset: {
 width: 0,
 height: 3
 },
 shadowRadius: 5,
 shadowOpacity: 1.0 }

为了给组件添加阴影,我们需要添加四个属性:

  • shadowColor:这添加了我们组件所需的颜色的十六进制或 RGBA 值

  • shadowOffset:这显示了我们希望阴影投射多远

  • shadowRadius:这显示了阴影在角落的半径值

  • shadowOpacity:这显示了我们希望阴影有多深

这就是我们的LocationPin组件的全部内容。

类选择

在这个组件中,我们将探索 React Native 中的Animated API,以开始使用动画。此外,我们将使用自定义字体来改善用户体验,并增加我们应用程序中的定制感:

/*** src/components/ClassSelection.js ** */

import React from 'react';
import {
  View,
  Image,
  Dimensions,
  Text,
  TouchableOpacity,
  Animated,
  StyleSheet,
} from 'react-native';

const { height, width } = Dimensions.get('window');

export default class ClassSelection extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      classButtonPosition: new Animated.Value(15 + width * 0.1),
    };
  }

  _onClassChange(className) {
    if (className === 'superior') {
      Animated.timing(this.state.classButtonPosition, {
 toValue: width * 0.77,
 duration: 500,
 }).start();
    }

    if (className === 'special') {
      Animated.timing(this.state.classButtonPosition, {
 toValue: width * 0.5 - 20,
 duration: 500,
 }).start();
    }

    if (className === 'economy') {
      Animated.timing(this.state.classButtonPosition, {
 toValue: 15 + width * 0.1,
 duration: 500,
 }).start();
    }
  }

  render() {
    return (
      <View style={styles.container}>
        <Image
          style={styles.classBar}
          source={require('../../img/classBar.png')}
        />
        <Animated.View
 style={[styles.classButton, { left: this.state.classButtonPosition }]}
 >
          <Image
            style={styles.classButtonImage}
            source={require('../../img/class.png')}
          />
        </Animated.View>
        <TouchableOpacity
          style={[
            styles.classButtonContainer,
            {
              width: width / 3 - 10,
              left: width * 0.11,
            },
          ]}
          onPress={this._onClassChange.bind(this, 'economy')}
        >
          <Text style={styles.classLabel}>economy</Text>
        </TouchableOpacity>
        <TouchableOpacity
          style={[
            styles.classButtonContainer,
            { width: width / 3, left: width / 3 },
          ]}
          onPress={this._onClassChange.bind(this, 'special')}
        >
          <Text style={[styles.classLabel, { textAlign: 'center' }]}>
            Special
          </Text>
        </TouchableOpacity>
        <TouchableOpacity
          style={[
            styles.classButtonContainer,
            { width: width / 3, right: width * 0.11 },
          ]}
          onPress={this._onClassChange.bind(this, 'superior')}
        >
          <Text style={[styles.classLabel, { textAlign: 'right' }]}>
            Superior
          </Text>
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    height: 80,
    backgroundColor: 'white',
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    paddingBottom: 10,
  },
  classBar: {
    width: width * 0.7,
 left: width * 0.15,
 resizeMode: 'contain',
    height: 30,
    top: 35,
  },
  classButton: {
    top: 30,
    justifyContent: 'center',
    borderRadius: 20,
    borderColor: '#ccc',
    borderWidth: 1,
    position: 'absolute',
    backgroundColor: 'white',
    height: 40,
    width: 40,
  },
  classButtonImage: {
    alignSelf: 'center',
 resizeMode: 'contain',
    width: 30,
  },
  classButtonContainer: {
    backgroundColor: 'transparent',
    position: 'absolute',
    height: 70,
    top: 10,
  },
  classLabel: {
    paddingTop: 5,
    fontSize: 12,
  },
});

这个简单的组件由五个子组件组成:

  • classBar:这是显示条和每个类的停靠点的图像

  • classButton:这是圆形按钮,一旦用户按下特定的类,它将移动到所选的类

  • classButtonContainer:这是可触摸组件,用于检测用户想要选择的类

  • classLabel:这些是每个类的标题,将显示在条的顶部

让我们从样式开始,因为我们可以在图像组件中找到一个新的属性:resizeMode,它确定当框架与原始图像尺寸不匹配时如何调整图像大小。从五种可能的值(covercontainstretchrepeatcenter)中,我们选择了contain,因为我们希望均匀缩放图像(保持图像的纵横比),以便图像的两个尺寸都等于或小于视图的相应尺寸。我们在classBarclassButtonImage中都使用了这些属性,这是我们在这个视图中需要调整大小的两个图像。

添加自定义字体

React Native 默认包含一长串跨平台字体。字体列表可以在github.com/react-native-training/react-native-fonts上查看。

然而,添加自定义字体是开发应用程序时的常见需求,特别是涉及到设计师时,因此我们将使用我们的汽车预订应用程序作为测试这一功能的场所。

添加自定义字体到我们的应用程序是一个三步任务:

  1. 将字体文件(.ttf)添加到项目内的一个文件夹中。我们在这个应用程序中使用了fonts/

  2. 将以下行添加到我们的package.json

      “rnpm”: {
          “assets”: [“./fonts”]
      }
  1. 在终端中运行以下命令:
 react-native link

就是这样,React Native 的 CLI 将一次性处理fonts文件夹及其文件的插入到 iOS 和 Android 项目中。我们的字体将通过它们的字体名称(可能与文件名不同)可用。在我们的情况下,我们在样式表中有fontFamily: 'Blair ITC'

现在我们可以修改ClassSelection组件中的classLabel样式,以包含新的字体:

...

classLabel: {
    fontFamily: 'Blair ITC',
    paddingTop: 5,
    fontSize: 12,
},

...

动画

React Native 的Animated API 旨在以高性能的方式,简洁地表达各种有趣的动画和交互模式。动画侧重于输入和输出之间的声明关系,中间有可配置的转换,并且有简单的start/stop方法来控制基于时间的动画执行。

我们在应用程序中要做的是,当用户按下她想要预订的班级时,将classButton移动到特定位置。让我们更仔细地看看我们在应用程序中如何使用这个 API:

/** * src/components/ClassSelection ***/

...

export default class ClassSelection extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      classButtonPosition: new Animated.Value(15 + width * 0.1),
    };
  }

  _onClassChange(className) {
    if (className === 'superior') {
      Animated.timing(this.state.classButtonPosition, {
        toValue: width * 0.77,
        duration: 500,
      }).start();
    }

    ...

  }

  render() {
    return (
      ...

      <Animated.View style={{ left: this.state.classButtonPosition }}>
        <Image
          style={styles.classButtonImage}
          source={require('../../img/class.png')}
        />
      </Animated.View>

      ...

      <TouchableOpacity
        onPress={this._onClassChange.bind(this, 'superior')}
      >
        <Text>Superior</Text>
      </TouchableOpacity>

      ...
    );
  }
}

...

为了使这种移动正确发生,我们需要将classButtonImage包装在Animated.View中,并为其提供一个初始的Animated.Value作为左坐标。我们将使用this.state.classButtonPosition来做到这一点,这样当用户选择特定的类别时我们可以改变它。

我们准备开始我们的动画。它将由_onClassChange方法触发,因为当用户按下classButtonContainer<TouchableOpacity/>)时,它被调用。这个方法调用Animated.timing函数传递两个参数:

  • 驱动动画的动画值(this.state.classButtonPosition

  • 包含动画的结束值和持续时间的对象

调用Animated.timing将导致一个包含start()方法的对象,我们立即调用它来启动动画。然后 React Native 将知道Animated.Viewleft坐标需要根据提供的参数慢慢改变。

由于这可能对于简单的移动动画来说有点复杂,但它允许广泛的定制,如链接动画或修改缓动函数。我们将在本章后面看到旋转动画。

ConfirmationModal

我们的最后一个组件是一个模态视图,当用户按下“设置取货位置”按钮时,它将被打开。我们将显示模态和自定义活动指示器,它将使用复杂的动画设置来持续在其位置旋转:

/** * src/components/ConfirmationModal.js ***/

import React from 'react';
import {
  Modal,
  View,
  Text,
  Animated,
  Easing,
  TouchableOpacity,
  StyleSheet,
} from 'react-native';

export default class ConfirmationModal extends React.Component {
  componentWillMount() {
 this._animatedValue = new Animated.Value(0);
  }

  cycleAnimation() {
 Animated.sequence([
 Animated.timing(this._animatedValue, {
 toValue: 100,
 duration: 1000,
 easing: Easing.linear,
 }),
 Animated.timing(this._animatedValue, {
 toValue: 0,
 duration: 0,
 }),
 ]).start(() => {
 this.cycleAnimation();
 });
 }

  componentDidMount() {
 this.cycleAnimation();
 }

  render() {
    const interpolatedRotateAnimation = this._animatedValue.interpolate({
 inputRange: [0, 100],
 outputRange: ['0deg', '360deg'],
 });

    return (
      <Modal
 animationType={'fade'}
 visible={this.props.visible}
 transparent={true}
 >
        <View style={styles.overlay}>
          <View style={styles.container}>
            <Text style={styles.title}>Contacting nearest car...</Text>
            <Animated.Image
 style={[
 styles.spinner,
 { transform: [{ rotate: interpolatedRotateAnimation }] },
 ]}
 source={require('../../img/loading.png')}
 />
            <TouchableOpacity
              style={styles.closeButton}
              onPress={this.props.onClose}
            >
              <Text style={styles.closeButtonText}>X</Text>
            </TouchableOpacity>
          </View>
        </View>
      </Modal>
    );
  }
}

const styles = StyleSheet.create({
  overlay: {
    flex: 1,
    backgroundColor: '#0006',
    justifyContent: 'center',
  },
  container: {
    backgroundColor: 'white',
    alignSelf: 'center',
    padding: 20,
    borderColor: '#ccc',
    borderWidth: 1,
  },
  title: {
    textAlign: 'right',
    fontFamily: 'Blair ITC',
    paddingTop: 5,
    fontSize: 12,
  },
  spinner: {
    resizeMode: 'contain',
    height: 50,
    width: 50,
    margin: 50,
    alignSelf: 'center',
  },
  closeButton: {
    backgroundColor: '#333',
    width: 40,
    height: 40,
    borderRadius: 20,
    justifyContent: 'center',
    alignSelf: 'center',
  },
  closeButtonText: {
    color: 'white',
    alignSelf: 'center',
    fontSize: 20,
  },
});

对于这个组件,我们使用 React Native 中可用的<Modal />组件来利用其淡入淡出动画和可见性功能。属性this.props.visible将驱动此组件的可见性,因为它是知道用户的取货请求的父组件。

让我们再次专注于动画,因为我们想为显示活动的旋转器做一个更复杂的设置。我们想要显示一个无休止的旋转动画,所以我们需要系统地调用我们的start()动画方法。为了实现这一点,我们创建了一个cycleAnimation()方法,它在组件挂载时被调用(以启动动画),并且从返回的Animated.timing对象中调用,因为它作为回调传递以在每次动画结束时被调用。

我们还使用Animated.sequence来连接两个动画:

  • 从 0 度移动到 360 度(在一秒钟内使用线性缓动)

  • 从 360 度移动到 0 度(在 0 秒内)

这是为了在每个周期结束时重复第一个动画。

最后,我们定义了一个名为interpolatedRotateAnimation的变量,用于存储从 0 度到 360 度的插值,因此可以将其传递给transform/rotate样式,定义了在动画我们的Animated.Image时可用的旋转值。

作为一个实验,我们可以尝试用替代图像更改 loading.png,并看看它如何被动画化。这可以通过替换我们的<Animated.Image />组件中的源属性轻松实现。

...            

            <Animated.Image
              style={[
                styles.spinner,
                { transform: [{ rotate: interpolatedRotateAnimation }] },
              ]}
              source={require('../../img/spinner.png')}
            />

...

总结

使用诸如native-basereact-native-elements之类的 UI 库在构建应用程序时节省了大量时间和维护麻烦,但结果最终呈现出一种标准风格,这在用户体验方面并不总是理想的。这就是为什么学习如何操纵我们应用程序的样式总是一个好主意,特别是在由 UX 专家或应用程序设计师提供设计的团队中。

在本章中,我们深入研究了使用 React Native 的 CLI 初始化项目时创建的文件夹和文件。此外,我们熟悉了开发人员菜单及其调试功能。

在构建我们的应用程序时,我们专注于布局和组件样式,还学习了如何添加和操纵动画,使我们的界面对用户更具吸引力。我们研究了 Flexbox 布局系统以及如何在组件中堆叠和居中元素。诸如尺寸之类的 API 被用来检索设备的宽度和高度,以在某些组件上执行定位技巧。

您学会了如何将字体和图像添加到我们的应用程序中,并如何显示它们以改善用户体验。

既然我们知道如何构建更多定制的界面,让我们在下一章中构建一个图像分享应用程序,其中设计起着关键作用。

第四章:图像分享应用

到目前为止,我们知道如何创建一个具有自定义界面的功能齐全的应用程序。您甚至学会了如何添加状态管理库来控制我们应用程序中的共享数据,以便代码库保持可维护和可扩展。

在本章中,我们将专注于使用不同的状态管理库(Redux)构建应用程序,利用相机功能,编写特定于平台的代码,并深入构建既吸引人又可用的自定义用户界面。图像分享应用将作为这些功能的一个很好的示例,并且还将为理解如何在 React Native 上构建大型应用程序奠定基础。

我们将在这个应用程序可用的两个平台上重用大部分代码:iOS 和 Android。虽然我们的大部分用户界面将是自定义的,但我们将使用native-base来简化 UI 元素,如图标。对于导航,我们将再次使用react-navigation,因为它为每个平台提供了最常用的导航:iOS 的选项卡导航和 Android 的抽屉菜单导航。最后,我们将使用react-native-camera来处理与设备相机的交互。这不仅会减少实现复杂性,还会为我们提供一大堆免费的功能,我们可以用来在未来扩展我们的应用程序。

对于这个应用程序,我们将模拟多个 API 调用,这样我们就不需要构建后端。当构建连接的应用程序时,这些调用应该很容易被真实的 API 替换。

概述

构建图像分享应用的主要要求之一是吸引人的设计。我们将遵循一些最流行的图像分享应用的设计模式,为每个平台调整这些模式,同时尽量重用尽可能多的代码,利用 React Native 的跨平台能力。

让我们首先看一下 iOS 中的用户界面:

主屏幕显示一个简单的标题和图像列表,包括用户图片、姓名和一个更多图标来分享图像。在底部,选项卡导航显示三个图标,代表三个主要屏幕:所有图像、我的图像和相机。

本示例应用程序中使用的所有图像都可以以任何形式使用。

当用户按下特定图像的更多图标时,将显示分享菜单。

这是一个标准的 iOS 组件。在模拟器上使用它并没有太多意义,最好在实际设备上进行测试。

让我们来看看第二个屏幕,我的图片:

这是当前用户上传的所有图像的网格表示,可以通过下一个屏幕“相机”进行更新:

iOS 模拟器不支持任何相机,因此这个功能最好还是在实际设备上进行测试,尽管react-native-camera是完全可用的,并且在访问时会返回虚假数据。我们将使用静态图像进行测试。

这就是 iOS 的全部内容;现在让我们转到 Android 版本:

由于 Android 鼓励使用抽屉式导航而不是选项卡,我们将在标题中包含一个抽屉菜单图标,并且还将通过不同的图标使相机可用。

与 iOS 共享菜单一样,Android 也有自己的控制器,因此我们将利用这一功能,并在用户点击特定图像上的“更多”图标时包含它:

当用户点击抽屉菜单图标时,菜单将显示出来,显示三个可用屏幕。从这里,用户可以导航到我的图片屏幕:

最后,相机屏幕也可以通过抽屉菜单访问:

Android 模拟器包括一个彩色移动的正方形相机模拟,可以用于测试。然而,出于一致性的原因,我们将继续使用 iOS 版本中使用的固定图像。

在本章中,我们将涵盖以下主题:

  • React Native 中的 Redux

  • 使用相机

  • 特定平台的代码

  • 抽屉和选项卡导航

  • 与其他应用程序共享数据

设置文件夹结构

让我们使用 React Native 的 CLI 初始化一个 React Native 项目。该项目将被命名为imageShare,并且将可用于 iOS 和 Android 设备:

react-native init --version="0.44.0" imageShare

为了在此应用程序中使用一些包,我们将使用特定版本的 React Native(0.44.0)。

我们将在我们的应用程序中使用 Redux,因此我们将创建一个文件夹结构,其中可以容纳我们的reducersactionscomponentsscreensapi调用:

此外,我们在img文件夹中添加了logo.png。对于其余部分,我们有一个非常标准的 React Native 项目。入口点将是index.ios.js用于 iOS 和index.android.js用于 Android:

/*** index.ios.js and index.android.js ***/ 

import { AppRegistry } from 'react-native';
import App from './src/main';

AppRegistry.registerComponent('imageShare', () => App);

我们对这两个文件的实现是相同的,因为我们希望使用src/main.js作为两个平台的通用入口点。

让我们跳转到我们的package.json文件,了解我们应用中将有哪些依赖项:

/*** package.json ***/

{
        "name": "imageShare",
        "version": "0.0.1",
        "private": true,
        "scripts": {
                "start": "node node_modules/react-native/
                local-cli/cli.js start",
                "test": "jest"
        },
        "dependencies": {
                "native-base": "².1.5", "react": "16.0.0-alpha.6",
                "react-native": "0.44.0", "react-native-camera": "⁰.8.0",
                "react-navigation": "¹.0.0-beta.9",
                "react-redux": "⁵.0.5",
                "redux": "³.6.0",
                "redux-thunk": "².2.0"
        },
        "devDependencies": {
                "babel-jest": "20.0.3",
                "babel-preset-react-native": "1.9.2",
                "jest": "20.0.3",
                "react-test-renderer": "16.0.0-alpha.6"
        },
        "jest": {
                "preset": "react-native"
        }
}

一些依赖项,如react-navigationnative-base,是前几章的老朋友。其他依赖项,如react-native-camera,将在本章中首次介绍。其中一些与我们将在此应用程序中使用的状态管理库 Redux 密切相关:

  • redux:这是状态管理库本身

  • react-redux:这些是 Redux 的 React 处理程序

  • redux-thunk:这是处理异步操作执行的 Redux 中间件

完成安装后,我们需要链接react-native-camera,因为它需要在我们应用的本地部分进行一些更改:

react-native link react-native-camera

在 iOS 10 及更高版本中,我们还需要修改我们的ios/imageShare/Info.plist以添加相机使用说明,这应该显示以请求在应用程序中启用相机的权限。我们需要在最后一个</dict></plist>之前添加这些行:

<key>NSCameraUsageDescription</key>
<string>imageShare requires access to the camera on this device to perform this action</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>imageShare requires access to the image library on this device to perform this action</string>

Redux

Redux 是基于简单原则的 JavaScript 应用程序的可预测状态容器:

  • 您应用的整个状态存储在单个存储内的对象树中

  • 更改状态树的唯一方法是发出操作,描述发生了什么的对象

  • 为了指定操作如何转换状态树,您编写纯减速器

它的流行程度来自于在任何类型的代码库(前端或后端)中使用它所能产生的一致性、可测试性和开发人员体验的程度。由于其严格的单向数据流,它也很容易理解和掌握:

用户触发和操作减速器处理,这只是应用基于该操作的更改的纯函数。生成的状态保存在一个存储中,该存储由我们应用中的视图使用,以显示应用程序的当前状态。

Redux 是本书范围之外的一个复杂主题,但它将在本书的一些章节中广泛使用,因此可能有益于查看它们的官方文档(redux.js.org/)以熟悉这个状态管理库的基本概念。

Redux 的一些基本概念将在我们的src/main.js文件中使用。

/*** src/main.js ***/

import React from 'react';
import { DrawerNavigator,TabNavigator } from 'react-navigation';
import { Platform } from 'react-native';

import { Provider } from 'react-redux';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import imagesReducer from './reducers/images';

import ImagesList from './screens/ImagesList.js';
import MyImages from './screens/MyImages.js';
import Camera from './screens/Camera.js';

let Navigator;
if(Platform.OS === 'ios'){
  Navigator = TabNavigator({
    ImagesList: { screen: ImagesList },
    MyImages: { screen: MyImages },
    Camera: { screen: Camera }
  }, {
    tabBarOptions: {
      inactiveTintColor: '#aaa',
      activeTintColor: '#000',
      showLabel: false
    }
  });
} else {
  Navigator = DrawerNavigator({
    ImagesList: { screen: ImagesList },
    MyImages: { screen: MyImages },
    Camera: { screen: Camera }
  });
}

let store = createStore(combineReducers({ imagesReducer }), applyMiddleware(thunk));

export default class App extends React.Component {
  render() {
    return (
 <Provider store={store}>
        <Navigator/>
      </Provider>
    )
  }
}

让我们首先关注 Redux 的仪式。let store = createStore(combineReducers({ imagesReducer }), applyMiddleware(thunk));通过组合导入的 reducer(我们这个应用只有一个 reducer,所以这只是信息性的)并应用Thunk中间件来设置存储,这将使我们的应用能够使用异步操作。我们将模拟几个 API 调用,这些调用将返回异步承诺,因此需要这个中间件来正确处理这些承诺的解析。

然后,我们有我们的render方法:

<Provider store={store}>
   <Navigator/>
</Provider>

这在大多数使用 React 的 Redux 应用中都是标准的。我们将根组件(在我们的情况下是<Navigator />)与<Provider />组件包装在一起,以确保我们可以从应用的根部获取store。Redux 的connect方法将在本章中继续使用在我们的容器或屏幕中。

我们将使用<Navigator />组件作为我们应用的根,但它将根据运行的平台具有不同的性质:

let Navigator;
if(Platform.OS === 'ios'){
  Navigator = TabNavigator({

    ...

  });
} else {
  Navigator = DrawerNavigator({

    ...

  });
}

Platform是一个 React Native API,主要用于识别我们的应用正在运行的平台。我们可以通过将代码包装在if(Platform.OS === 'ios'){ ... }中来编写特定于 iOS 的代码,对于 Android 也是一样:if(Platform.OS === 'android'){ ... }

在这种情况下,我们使用它来在 iOS 上构建一个选项卡导航器,在 Android 上构建一个抽屉导航器,这些是这些平台的事实导航模式。在这两个导航器上,我们将设置ImagesListMyImagesCamera作为我们应用程序中的三个主要屏幕。

ImagesList

我们应用程序中的主屏幕是从后端检索的图像列表。我们将显示这些图像以及它们对应的上传者个人资料图片和名称。对于每个图像,我们将显示更多,可以用于与用户设备上的其他应用程序共享图像,例如消息应用程序或社交网络。这个屏幕的大部分 UI 将来自<Gallery />组件,因此我们将专注于将屏幕与 Redux 存储连接起来,添加自定义标题,并添加一个滚动视图使画廊可滚动,并添加一个活动指示器来警告用户有网络活动:

/*** src/components/ImagesList ***/

import React from 'react';
import { View, ScrollView } from 'react-native';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions'; import { Icon } from 'native-base';

import Header from '../components/Header';
import Gallery from '../components/Gallery';
import ActivityIndicator from '../components/ActivityIndicator';

class ImagesList extends React.Component {
  static navigationOptions = {
    tabBarIcon: ({ tintColor }) => (
      <Icon name='list' style={{fontSize: 40, color: tintColor}}/>
    ),
    drawerLabel: 'All Images'
  };

 componentWillMount() {
    this.props.fetchImages();
  }

  componentWillReceiveProps(nextProps) {
    if(!this.props.addingImage && nextProps.addingImage) {
      this.scrollable.scrollTo({y: 0});
    }
  }

  render() {
    return (
      <View style={{flex: 1}}>
        <Header onMenuButtonPress={() => 
        this.props.navigation.navigate('DrawerOpen')}
        onCameraButtonPress={() => 
        this.props.navigation.navigate('Camera')}/>
        <ScrollView ref={(scrollable) => {
            this.scrollable = scrollable;
          }}>
          { this.props.addingImage && <ActivityIndicator 
            message='Adding image' /> }
          <Gallery imageList={this.props.images} loading=
          {this.props.fetchingImages}/>
        </ScrollView>
      </View>
    );
  }
}

function mapStateToProps(state) { return { images: state.imagesReducer.images, addingImage: state.imagesReducer.addingImage, fetchingImages: state.imagesReducer.fetchingImages } }
function mapStateActionsToProps(dispatch) { return bindActionCreators(Actions, dispatch) }

export default connect(mapStateToProps, mapStateActionsToProps)(ImagesList);

由于大多数 React 应用程序使用 Redux,我们需要将我们的组件与状态和操作连接起来。我们将创建两个函数(mapStateToPropsmapStateActionsToProps)来装饰我们的<ImageList />组件,以映射组件感兴趣的状态和部分操作:

  • images:这是我们将在<Gallery />中使用的图像列表

  • addingImage:这是在上传图像时将设置为true的标志

  • fetchingImages:当应用程序请求从后端获取图像列表以更新存储时,将设置为true的标志

在这个屏幕上我们唯一需要的操作是fetchImages,通过props组件可访问,因为我们将操作列表在Actions中连接到我们的<ImagesList />组件。同样,通过props,我们可以访问三个状态变量(imagesaddingImagefetchingImages),这要归功于相同的connect调用:

function mapStateToProps(state) {
  return {
    images: state.imagesReducer.images,
    addingImage: state.imagesReducer.addingImage,
    fetchingImages: state.imagesReducer.fetchingImages
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(Actions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(ImagesList);

这就是我们从 Redux 需要的一切。我们将在其他屏幕中看到这种模式,因为这是连接 React 组件与存储部分和操作列表的常见解决方案。

fetchImages操作在componentWillMount上调用,作为要呈现的图像列表的初始检索:

componentWillMount() { 
   this.props.fetchImages(); 
}

我们还添加了一种方法来检测addingImage标志设置为true时显示活动指示器。

componentWillReceiveProps(nextProps) {
  if(!this.props.addingImage && nextProps.addingImage) {
    this.scrollable.scrollTo({y: 0});
  }
}

此方法将在<Scrollview />中调用scrollTo,以确保显示顶部部分,因此用户可以看到<ActivityIndicator />。这次我们使用自定义的<ActivityIndicator />(从src/components/ActivityIndicator导入),因为我们不仅想显示旋转器,还想显示消息。

最后,我们将添加两个组件:

  • <Header />:显示标志和(在 Android 版本中)两个图标,用于导航到抽屉菜单和相机屏幕

  • <Gallery />:显示格式化的图片列表和上传者

在转移到另一个屏幕之前,让我们看一下我们在其中包含的三个自定义组件:<ActivityIndicator /><Header /><Gallery />

画廊

Gallery 包含了所有图片列表的渲染逻辑。它依赖于native-base,更具体地说,依赖于它的两个组件,<List /><ListItem />

/*** src/components/Gallery ***/

import React from 'react';
import { List, ListItem, Text, Icon, Button, Container, Content }
 from 'native-base';
import { Image, Dimensions, View, Share, ActivityIndicator, StyleSheet } from 'react-native';

var {height, width} = Dimensions.get('window');

export default class Gallery extends React.Component {
  _share(image) {
   Share.share({message: image.src, title: 'Image from: ' + 
                image.user.name}) 
  }

  render() {
    return (
      <View>
        <List style={{margin: -15}}>
          {
            this.props.imageList && this.props.imageList.map((image) =>  
            {
              return (
                <ListItem 
                    key={image.id} 
                    style={{borderBottomWidth: 0, 
                    flexDirection: 'column', marginBottom: -20}}>
                  <View style={styles.user}>
                    <Image source={{uri: image.user.pic}} 
                     style={styles.userPic}/>
                    <Text style={{fontWeight: 'bold'}}>
                    {image.user.name}</Text>
                  </View>
                  <Image source={{uri: image.src}} 
                  style={styles.image}/>
                  <Button style={{position: 'absolute', right: 15, 
                  top: 25}} transparent 
                  onPress={this._share.bind(this, image)}>
                    <Icon name='ios-more' style={{fontSize: 20, 
                    color: 'black'}}/>
                  </Button>
                </ListItem>
              );
            })
          }
        </List>
        {
          this.props.loading &&
          <View style={styles.spinnerContainer}>
            <ActivityIndicator/>
          </View>
        }
      </View>
    );
  }
}

const styles = StyleSheet.create({
  user: {
    flexDirection: 'row',
    alignSelf: 'flex-start',
    padding: 10
  },
  userPic: {
    width: 50,
    height: 50,
    resizeMode: 'cover',
    marginRight: 10,
    borderRadius: 25
  },
  image: {
    width: width,
    height: 300,
    resizeMode: 'cover'
  },
  spinnerContainer: {
    justifyContent: 'center',
    height: (height - 50)
  }
});

这个组件从其父组件中获取两个 props:loadingimageList

loading用于显示标准的<ActivityIndicator />,显示用户的网络活动。这次我们使用标准的指示器,而不是自定义指示器,因为应该很清楚网络活动表示的是什么。

imageList是存储图片列表的数组,这些图片将在我们的<Gallery />中一次一个<ListenItem />地呈现。每个<ListItem />都包含一个<Button />,其中onPress={this._share.bind(this, image)用于与其他应用程序共享图片。让我们看一下_share函数:

_share(image) {
  Share.share({message: image.src, title: 'Image from: ' 
               + image.user.name}) 
}

分享是一个用于分享文本内容的 React Native API。在我们的情况下,我们将分享图片的 URL(img.src)以及一个简单的标题。分享文本是在应用程序之间共享内容的最简单方式,因为许多应用程序都会接受文本作为共享格式。

值得注意的是我们对图片应用的样式,使其占据整个宽度并具有固定高度(300),因此即使显示的图片大小不同,我们也有一个稳定的布局。对于这种设置,我们使用resizeMode: 'cover',这样图片在任何维度上都不会被拉伸。这意味着我们可能会裁剪图片,但这可以弥补不同尺寸图片的统一性。另一个选项是使用resizeMode: contain,如果我们不想裁剪任何东西,而是想要将图片适应这些边界,甚至可能缩小它们。

标题

我们想要在几个屏幕之间重用一个自定义标题。这就是为什么最好为它创建一个单独的组件,并在这些屏幕中导入它的原因:

/*** src/components/Header ***/

import React from 'react';
import { View, Image, StyleSheet } from 'react-native';
import { Icon, Button } from 'native-base';
import { Platform } from 'react-native';

export default class Header extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        {
          Platform.OS === 'android' &&
          <Button transparent onPress={this.props.onMenuButtonPress}>
            <Icon android='md-menu' style={styles.menuIcon}/>
          </Button>
        }
        <Image source={require('../../img/logo.png')} 
          style={styles.logo} />
        {
          Platform.OS === 'android' &&
          <Button onPress={this.props.onCameraButtonPress} transparent>
            <Icon name='camera' style={styles.cameraIcon}/>
          </Button>
        }
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    paddingTop: 20,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-around',
    borderBottomWidth: 1,
    borderBottomColor: '#ccc'
  },
  menuIcon: {
    fontSize: 30,
    color: 'black'
  },
  logo: {
    height: 25,
    resizeMode: 'contain',
    margin: 10
  },
  cameraIcon: {
    fontSize: 30,
    color: 'black'
  }
});

我们再次使用Platform API 来检测 Android 设备,并且只在该平台上显示抽屉菜单按钮和相机按钮。我们决定这样做是为了使这些功能更加突出,这些功能是应用程序的核心,通过减少需要按下的按钮数量来使 Android 用户更加突出。按下按钮时执行的操作通过父组件通过两个 props 传递:

  • onMenuButtonPress

  • onCameraButtonPress

这两个属性调用了两个单独的函数,调用了导航器的navigate方法:

  • this.props.navigation.navigate('DrawerOpen')

  • this.props.navigation.navigate('Camera')

最后要注意的是我们如何在这个组件中设置容器的布局。我们使用justifyContent: 'space-around',这是告诉 Flexbox 均匀分布项目在行中,周围有相等的空间。请注意,从视觉上看,这些空间并不相等,因为所有项目在两侧都有相等的空间。第一个项目将在容器边缘有一个单位的空间,但在下一个项目之间有两个单位的空间,因为下一个项目有自己的间距。

活动指示器

我们的自定义ActivityIndicator是一个非常简单的组件:

/*** src/components/ActivityIndicator ***/

import React from 'react';
import { ActivityIndicator, View, Text, StyleSheet } 
from 'react-native';

export default class CustomActivityIndicator extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <ActivityIndicator style={{marginRight: 10}}/>
        <Text>{this.props.message}</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    justifyContent: 'center',
    padding: 10,
    backgroundColor: '#f0f0f0'
  }
});

它接收一个消息作为属性,并在标准旋转器旁边显示它。我们还添加了自定义背景颜色(#f0f0f0)以使其在白色背景上更加可见。

现在让我们转移到相机屏幕,将我们的图像添加到列表中。

相机

在使用react-native-camera时,大部分拍照逻辑可以被抽象化,因此我们将专注于在我们的组件中使用这个模块,并确保通过 Redux 操作将其连接到我们应用的状态:

/*** src/screens/Camera ***/

import React, { Component } from 'react';
import {
  Dimensions,
  StyleSheet,
  Text,
  TouchableHighlight,
  View
} from 'react-native';
import { Button, Icon } from 'native-base';
import Camera from 'react-native-camera';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions';

class CameraScreen extends Component {
  static navigationOptions = {
    tabBarIcon: ({ tintColor }) => (
      <Icon name='camera' style={{fontSize: 40, color: tintColor}}/>
    ),
  };

  render() {
    return (
      <View style={styles.container}>
        <Camera
          ref={(cam) => {
            this.camera = cam;
          }}
          style={styles.preview}
          aspect={Camera.constants.Aspect.fill}>
          <Button onPress={this.takePicture.bind(this)} 
          style={styles.cameraButton} transparent>
            <Icon name='camera' style={{fontSize: 70,
            color: 'white'}}/>
          </Button>
        </Camera>
        <Button onPress={() => 
         this.props.navigation.navigate('ImagesList')} 
         style={styles.backButton} transparent>
          <Icon ios='ios-arrow-dropleft' android='md-arrow-dropleft' 
           style={{fontSize: 30, color: 'white'}}/>
        </Button>
      </View>
    );
  }

  takePicture() {
    const options = {};
    this.camera.capture({metadata: options})
      .then((data) => {
        this.props.addImage(data);
        this.props.navigation.navigate('ImagesList');
      })
      .catch(err => console.error(err));
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'row',
  },
  preview: {
    flex: 1,
    justifyContent: 'flex-end',
    padding: 20
  },
  capture: {
    flex: 0,
    backgroundColor: '#fff',
    borderRadius: 5,
    color: '#000',
    padding: 10,
    margin: 40
  },
  cameraButton: {
    flex: 0, 
    alignSelf: 'center'
  },
  backButton: {
    position: 'absolute',
    top:20
  }
});

function mapStateToProps(state) { return {} }
function mapStateActionsToProps(dispatch) { return bindActionCreators(Actions, dispatch) }

export default connect(mapStateToProps, mapStateActionsToProps)(CameraScreen);

react-native-camera的工作方式是通过提供一个我们可以包含在屏幕中的组件,并且通过引用,我们可以调用它的capture方法,该方法返回一个我们可以使用的 promise,以调用addImage将我们的图像上传到应用的后端。

让我们更仔细地看看<Camera />组件:

<Camera
   ref={(cam) => {
     this.camera = cam;
   }}
   style={styles.preview}
   aspect={Camera.constants.Aspect.fill}>

...

</Camera>

<Camera />组件有三个属性:

  • ref:这在父组件中为<Camera />组件设置一个引用,以便调用capture方法。

  • style:这允许开发人员指定应用中组件的外观。

  • aspect:这允许您定义视图渲染器在显示相机视图时的行为。有三个选项:fillfitstretch

当用户按下相机按钮时,将调用takePicture函数:

takePicture() {
    const options = {};
    this.camera.capture({metadata: options})
    .then((data) => {
      this.props.addImage(data);
      this.props.navigation.navigate('ImagesList');
    })
    .catch(err => console.error(err));
}

我们将使用保存的相机引用来调用它的capture方法,我们可以传递一些元数据(例如,照片拍摄的位置)。这个方法返回一个 promise,将以图像数据解析,因此我们将使用这些数据调用addImage动作将这些数据发送到后端,以便将图片添加到imagesList。在将图片发送到后端后,我们将使应用程序导航回ImagesList屏幕。addImage方法将设置addingImages标志,因此ImageList屏幕可以显示相应消息的活动指示器。

让我们继续看看我们应用程序中的最后一个屏幕:MyImages

MyImages

这个屏幕显示了已登录用户上传的所有图片。我们在这个屏幕上使用虚假图片来预先填充这个屏幕,但更多的图片可以通过相机屏幕添加。

大部分渲染逻辑将被移动到一个名为<ImagesGrid />的单独组件中:

/*** src/screens/MyImages ***/

import React from 'react';
import { 
  Image,
  TouchableOpacity,
  Text,
  View,
  ActivityIndicator,
  Dimensions 
} from 'react-native';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions';
import { Icon } from 'native-base';

import Header from '../components/Header';
import ImagesGrid from '../components/ImagesGrid';

var {height, width} = Dimensions.get('window');

class MyImages extends React.Component {
  static navigationOptions = {
    drawerLabel: 'My Images',
    tabBarIcon: ({ tintColor }) => (
      <Icon name='person' style={{fontSize: 40, color: tintColor}}/>
    )
  };

  componentWillMount() {
 this.props.fetchImages(this.props.user.name);
  }

  render() {
    return (
      <View>
        <Header onMenuButtonPress={() => 
        this.props.navigation.navigate('DrawerOpen')} 
        onCameraButtonPress={() => 
        this.props.navigation.navigate('Camera')}/>
        {
          this.props.fetchingImages &&
          <View style={{justifyContent: 'center', 
           height: (height - 50)}}>
            <ActivityIndicator/>
          </View>
        }
        <ImagesGrid images={this.props.images}/>
      </View>
    );
  }
}

function mapStateToProps(state) { return { images: state.imagesReducer.userImages, user: state.imagesReducer.user, fetchingImages: state.imagesReducer.fetchingUserImages } }
function mapStateActionsToProps(dispatch) { return bindActionCreators(Actions, dispatch) }

export default connect(mapStateToProps, mapStateActionsToProps)(MyImages);

这个组件的第一件事是调用fetchImages动作,但与<ImagesList />组件不同的是,它只传递用户名以仅检索已登录用户的图片。当我们创建这个动作时,我们需要考虑这一点,并接收一个可选的userName参数来过滤我们将检索的图片列表。

除此之外,这个组件将大部分行为委托给<ImageGrid />,以便我们可以重用渲染能力用于其他用户。让我们继续看<ImageGrid />

图片网格

一个简单的滚动视图和一系列图片。这个组件就是这么简单,但它的配置方式使得图片可以像网格一样轻松地流动:

/*** src/components/ImageGrid ***/

import React from 'react';
import { 
  Image,
  TouchableOpacity, 
  ScrollView, 
  Dimensions, 
  View,
  StyleSheet
} from 'react-native';

var {height, width} = Dimensions.get('window');

export default class ImagesGrid extends React.Component {
  render() {
    return (
      <ScrollView>
        <View style={styles.imageContainer}>
          {
            this.props.images && 
            this.props.images.map(img => {
              return (<Image style={styles.image} 
              key={img.id} source={{uri: img.src}}/>);
            })
          }
        </View>
      </ScrollView>
    );
  }
}

const styles = StyleSheet.create({
  imageContainer: {
    flexDirection: 'row',
    alignItems: 'flex-start',
    flexWrap: 'wrap'
  },
  image: {
    width: (width/3 - 2),
    margin: 1,
    height: (width/3 - 2),
    resizeMode: 'cover'
  }
});

在样式化容器时,我们使用flexWrap: 'wrap'来确保图片不仅在row方向上流动,而且当设备宽度覆盖一行图片时也扩展到新行。通过为每个图像设置widthheightwidth/3 - 2,我们确保容器可以每行容纳三张图片,包括两个像素的小间距。

通过 npm 还有几个网格模块可用,但我们决定为此构建我们自己的组件,因为我们不需要网格中的额外功能,并且以这种方式可以获得灵活性。

这些就是我们在图片分享应用程序中需要的所有屏幕和视觉组件。现在让我们来看看让它们一起工作的粘合剂,即动作和减速器。

动作

正如我们在屏幕上看到的,这个应用只需要两个动作,fetchImages(对所有用户或特定用户)和addImage

/*** src/actions/index ***/

import api from '../api';

export function fetchImages(userId = null) {
  let actionName, actionNameSuccess, actionNameError;
  if(userId) {
    actionName = 'FETCH_USER_IMAGES';
    actionNameSuccess = 'FETCH_USER_IMAGES_SUCCESS';
    actionNameError = 'FETCH_USER_IMAGES_ERROR';
  } else {
    actionName = 'FETCH_IMAGES';
    actionNameSuccess = 'FETCH_IMAGES_SUCCESS';
    actionNameError = 'ADD_IMAGE_ERROR';
  }

  return dispatch => {
    dispatch({ type: actionName });
    api
      .fetchImages(userId)
      .then(images => {
        dispatch({ 
          type: actionNameSuccess,
          images
        })  
      })
      .catch(error => {
        dispatch({ 
          type: actionNameError,
          error
        });  
      });
  };
}

export function addImage(data = null) {
  return dispatch => {
    dispatch({ type: 'ADD_IMAGE' });
    api
      .addImage()
      .then(imageSrc => {
        dispatch({ 
          type: 'ADD_IMAGE_SUCCESS',
          imageSrc
        });  
      })
      .catch(error => {
        dispatch({ 
          type: 'ADD_IMAGE_ERROR',
          error
        });  
      });
  };
}

Redux 的 actions 只是描述事件的简单对象,包括其有效负载。由于我们正在使用redux-thunk,我们的action creators将返回一个函数,在该函数中 Redux 的dispatch函数将被调用,传递 action。让我们更仔细地看看我们的addImage动作:

export function addImage(data = null) {
  return dispatch => {
    dispatch({ type: 'ADD_IMAGE' });
    api
      .addImage()
      .then(imageSrc => {
        dispatch({ 
          type: 'ADD_IMAGE_SUCCESS',
          imageSrc
        }); 
      })
      .catch(error => {
        dispatch({ 
          type: 'ADD_IMAGE_ERROR',
          error
        }); 
      });
  };
}

我们返回的函数首先通过分发一个名为ADD_IMAGE的动作来开始,没有有效负载,因为我们只是想让 Redux 知道我们准备好发起网络请求将图像上传到我们的后端。然后,我们使用我们的api进行该请求(稍后我们将模拟这个调用)。这个请求将返回一个 promise,所以我们可以附加.then.catch回调来处理响应。如果响应是积极的(图像被正确上传),我们将分发一个ADD_IMAGE_SUCCESS动作,传递上传图像的 URL。如果出现错误,我们将分发一个ADD_IMAGE_ERROR动作,涵盖所有可能的状态。

大多数 action creators 在ReduxThunk中进行网络请求时都以类似的方式工作。事实上,我们的 action fetchImagesaddImage非常相似,只有一个例外:它需要检查是否传递了userId,并发出不同的一组动作,以便 reducers 可以相应地修改状态。让我们来看看将处理所有这些动作的 reducers。

Reducers

在 Redux 中,reducers 是负责在发生新动作时更新状态的函数。它们接收当前状态和动作(包括任何有效负载),并返回一个新的state对象。我们不会深入研究 reducers 的工作原理,我们只需要了解它们的基本结构:

/*** src/reducers/index ***/

const initialState = {
  images: null,
  userImages: null,
  error: null,
  user: {
    id: 78261,
    name: 'Sharer1',
    pic: 'https://cdn.pixabay.com/photo/2015/07/20/12/53/
          man-852762_960_720.jpg'
  }
}

export default function (state = initialState, action) {
  switch(action.type){
    case 'FETCH_IMAGES': 
      return Object.assign({}, state, {
        images: [],
        fetchingImages: true,
        error: null
      });
    case 'FETCH_IMAGES_SUCCESS': 
      return Object.assign({}, state, {
        fetchingImages: false,
        images: action.images,
        error: null
      });
    case 'FETCH_IMAGES_ERROR': 
      return Object.assign({}, state, {
        fetchingImages: false,
        images: null,
        error: action.error
      });
    case 'FETCH_USER_IMAGES': 
      return Object.assign({}, state, {
        userImages: [],
        fetchingUserImages: true,
        error: null
      });
    case 'FETCH_USER_IMAGES_SUCCESS': 
      return Object.assign({}, state, {
        fetchingUserImages: false,
        userImages: action.images,
        error: null
      });
    case 'FETCH_USER_IMAGES_ERROR': 
      return Object.assign({}, state, {
        fetchingUserImages: false,
        userImages: null,
        error: action.error
      });
    case 'ADD_IMAGE': 
      return Object.assign({}, state, {
        addingImage: true,
        error: null
      });
    case 'ADD_IMAGE_SUCCESS': 
      let image = {
        id: Math.floor(Math.random() * 99999999),
        src: action.imageSrc, 
        user: state.user
      }
      return Object.assign({}, state, {
        addingImage: false,
        images: [image].concat(state.images),
        userImages: [image].concat(state.images),
        error: null
      });
    case 'ADD_IMAGE_ERROR': 
      return Object.assign({}, state, {
        addingImage: false,
        error: action.error
      });
    default:
      return state;
  }
}

让我们来分解一下:

const initialState = {
  images: null,
  userImages: null,
  error: null,
  user: {
    id: 78261,
    name: 'Sharer1',
    pic: 'https://cdn.pixabay.com/photo/2015/07/20/12/53/
          man-852762_960_720.jpg'
  }
}

我们从一个初始状态开始,其中所有属性都将设置为null,除了user,它将包含模拟用户数据。这个初始状态默认注入到启动时的 reducer 中:

export default function (state = initialState, action) {

  ...

}

在后续调用中,Redux 将在应用任何动作后注入实际状态。在这个函数内部,我们有一个switch来评估每个触发的动作类型,以根据该动作及其有效负载修改状态。让我们以FETCH_IMAGES_SUCCESS动作为例:

case 'FETCH_IMAGES_SUCCESS': 
  return Object.assign({}, state, {
    fetchingImages: false,
    images: action.images,
    error: null
  });

Redux 中的一个规则是 reducers 不应该改变状态,而是在触发动作后返回一个新对象。使用Object.assign,我们返回一个包含当前状态和基于刚刚发生的动作的所需更改的新对象。在这种情况下,我们将fetchingImages标志设置为false,以便让我们的组件知道它们可以隐藏与获取图像相关的任何活动指示器。我们还将从actions.images中接收到的图像列表设置为我们状态的images键,以便将它们注入到需要它们的组件中。最后,我们将error标志设置为null,以隐藏由于先前状态而显示的任何错误。

正如我们之前提到的,每个异步操作都应该分成三个单独的动作来表示三种不同的状态:异步请求挂起,成功和出错。这样,我们的应用将有三组动作:

  • FETCH_IMAGESFETCH_IMAGES_SUCCESSFETCH_IMAGES_ERROR

  • FETCH_USER_IMAGESFETCH_USER_IMAGES_SUCCESSFETCH_USER_IMAGES_ERROR

  • ADD_IMAGEADD_IMAGE_SUCCESSADD_IMAGE_ERROR

重要的是要注意,我们为FETCH_IMAGESFETCH_USER_IMAGES有单独的情况,因为我们希望同时保留两个不同的图像列表:

  • 一个包含用户正在关注的所有人的图片的通用图片

  • 用户已上传的图片列表

最后缺失的部分是从动作创建者调用的 API 调用。

API

在真实的应用程序中,我们会将所有对后端的调用放在一个单独的api文件夹中。出于教育目的,我们只是模拟了我们应用程序的核心的两个 API 调用,addImagefetchImages

/*** src/api/index ***/

export default {
  addImage: function(image) {
    return new Promise((resolve, reject) => {
      setTimeout(()=>{
        resolve( '<imgUrl>' );
      }, 3000)
    })
  },
  fetchImages: function(user = null){
    const images = [

      {id: 1, src: '<imgUrl>', user: {pic: '<imgUrl>', name: 'Naia'}},
      {id: 2, src: '<imgUrl>', user: {pic: '<imgUrl>', 
       name: 'Mike_1982'}},
      {id: 5, src: '<imgUrl>', user: {pic: '<imgUrl>', 
       name: 'Sharer1'}},
      {id: 3, src: '<imgUrl>', user: {pic: '<imgUrl>', name: 'Naia'}},
      {id: 6, src: '<imgUrl>', user: {pic: '<imgUrl>', 
       name: 'Sharer1'}},
      {id: 4, src: '<imgUrl>', user: {pic: '<imgUrl>', 
       name: 'Sharer1'}},
      {id: 7, src: '<imgUrl>', user: {pic: '<imgUrl>', 
       name: 'Sharer1'}}

    ]
    return new Promise((resolve, reject) => {
      setTimeout(()=>{
        resolve( images.filter(img => !user || user === img.user.name)   
      );
      }, 1500);
    })
  }
}

为了模拟网络延迟,我们添加了一些setTimeouts,这将有助于测试我们设置的用于显示用户网络活动的活动指示器。我们还使用了 promise 而不是普通的回调来使我们的代码更易于阅读。在这些示例中,我们还跳过了图像 URL,以使其更简洁。

总结

我们在这个应用程序中使用了 Redux,并且这塑造了我们使用的文件夹结构。虽然使用 Redux 需要一些样板代码,但它有助于以合理的方式拆分我们的代码库,并消除容器或屏幕之间的直接依赖关系。当我们需要在屏幕之间保持共享状态时,Redux 绝对是一个很好的补充,因此在本书的其余部分我们将继续使用它。在更复杂的应用程序中,我们需要构建更多的 reducers,并可能按领域将它们分开,并使用 Redux combineReducers。此外,我们需要添加更多的操作,并为每组操作创建单独的文件。例如,我们需要登录、注销和注册的操作,我们可以将它们放在名为src/actions/user.js的文件夹中。然后,我们应该将我们与图像相关的操作(目前在index.js中)移动到src/actions/images.js中,这样我们就可以修改src/actions/index.js,以便在需要一次性导入所有操作时将其用作用户和图像操作的组合器。

Redux 还有助于测试,因为它将应用程序的业务逻辑隔离到 reducers 中,因此我们可以专注于对它们进行彻底的测试。

模拟 API 调用使我们能够为我们的应用程序快速建立原型。当后端可用时,我们可以重用这些模型进行测试,并用真正的 HTTP 调用替换src/api/index.js。无论如何,最好为我们所有的 API 调用建立一个单独的文件夹,这样如果后端发生任何更改,我们就可以轻松地替换它们。

您还学会了如何构建特定平台的代码(在我们的案例中是特定于 Android),这对大多数应用程序来说是非常有用的功能。一些公司更喜欢为每个平台编写单独的应用程序,并且只重用它们的业务逻辑代码,在任何基于 Redux 的应用程序中都应该非常容易,因为它驻留在 reducers 中。

在 React Native 中没有特定的 API 来控制设备的相机,但我们可以使用react-native-camera模块来实现。这是一个访问 iOS 和 Android 原生 API 并将其暴露在 React Native JavaScript 世界中的库的示例。在我们的下一章中,我们将通过构建吉他调音器应用程序来探索并跨越 React Native 应用程序中原生和 JavaScript 世界之间的桥梁。

第五章:吉他调音器

React Native 涵盖了 iOS 和 Android 中大部分可用的组件和 API。诸如 UI 组件、导航或网络等点可以完全在我们的 JavaScript 代码中使用 React Native 组件进行设置,但并非所有平台的功能都已从本地世界映射到 JavaScript 世界。尽管如此,React Native 提供了一种编写真正的本地代码并访问平台全部功能的方法。如果 React Native 不支持您需要的本地功能,您应该能够自己构建它。

在本章中,我们将利用 React Native 的能力,使我们的 JavaScript 代码能够与自定义的本地代码进行通信;具体来说,我们将编写一个本地模块来检测来自设备麦克风的频率。这些能力不应该是 React Native 开发人员日常任务的一部分,但最终,我们可能需要使用仅在 Objective-C、Swift 或 Java 上可用的模块或 SDK。

在本章中,我们将专注于 iOS,因为我们需要编写超出本书范围的本地代码。将此应用程序移植到 Android 应该相当简单,因为我们可以完全重用 UI,但我们将在本章中将其排除在外,以减少编写的本地代码量。由于我们只关注 iOS,我们将涵盖构建应用程序的所有方面,添加启动画面和图标,使其准备好提交到 App Store。

我们将需要一台 Mac 和 XCode 来为这个项目添加和编译本地代码。

概述

理解吉他的调音概念应该很简单:吉他的六根弦在开放状态下(即没有按下任何品)发出特定频率的声音。调音意味着拉紧弦直到发出特定频率的声音。以下是每根弦应该发出的标准频率列表:

吉他调音的数字过程将遵循以下步骤:

  1. 记录通过设备麦克风捕获的频率的实时样本。

  2. 找到该样本中最突出的频率。

  3. 计算出前表中最接近的频率,以检测正在演奏的是哪根弦。

  4. 计算发出的频率与该弦的标准调音频率之间的差异,以便让用户纠正弦的张力。

我们还需要克服一些障碍,比如忽略低音量,这样我们就不会因为检测到不是来自琴弦的声音的频率而混淆用户。

在这个过程中,我们将使用原生代码,不仅因为我们需要处理 React Native API 中不可用的功能(例如,通过麦克风录音),而且因为我们可以以更有效的方式进行复杂的计算。我们将在这里使用的算法来检测从麦克风获取的样本中的主频率被称为快速傅里叶变换FFT)。我们不会在这里详细介绍,但我们将使用一个原生库来执行这些计算。

这个应用程序的用户界面应该非常简单,因为我们只有一个屏幕来展示给用户。复杂性将存在于逻辑中,而不是展示一个漂亮的界面,尽管我们将使用一些图像和动画使其更具吸引力。重要的是要记住,界面是使应用程序在应用商店中吸引人的因素,所以我们不会忽视这一方面。

这就是我们的应用程序完成后的样子:

在屏幕顶部,我们的应用程序显示一个“模拟”调谐器,显示吉他弦发出的频率。一个红色指示器将在调谐器内移动,以显示吉他弦是否接近调谐频率。如果指示器在左侧,意味着吉他弦的频率较低,需要调紧。因此,用户应该尝试使指示器移动到调谐器的中间,以确保琴弦调谐。这是一种非常直观的方式来显示琴弦的调谐情况。

然而,我们需要让用户知道她试图调谐的是哪根琴弦。我们可以通过检测最接近的调谐频率来猜测这一点。一旦我们知道是哪根琴弦被按下,我们将在屏幕底部向用户显示它,那里有每根琴弦的表示,以及调谐后应该演奏的音符。我们将改变相应音符的边框颜色为绿色,以通知用户应用程序检测到了特定音符。

让我们回顾一下本章将涵盖的主题列表:

  • 从 JavaScript 运行原生代码

  • 动画图像

  • <StatusBar />

  • propTypes

  • 添加启动画面

  • 添加图标

设置文件夹结构

让我们使用 React Native 的 CLI 初始化一个 React Native 项目。该项目将命名为guitarTuner,并且将专门用于 iOS:

react-native init --version="0.45.1" guitarTuner

由于这是一个单屏应用程序,我们不需要像 Redux 或 MobX 这样的状态管理库,因此,我们将使用一个简单的文件夹结构:

我们有三张图片来支持我们的自定义界面:

  • indicator.jpg:指示弦音调的红色条

  • tuner.jpg:指示器将移动的背景

  • string.jpg:吉他弦的表示

我们的src/文件夹包含两个子文件夹:

  • components/:这里存储了<Strings/>组件和<Tuner/>组件

  • utils/:这里保存了将在我们应用的几个部分中使用的函数和常量列表

最后,我们应用程序的入口点将是index.ios.js,因为我们将专门为 iOS 平台构建我们的应用程序。

让我们看看我们的package.json,以确定我们将有哪些依赖项:

/*** package.json ***/

{
        "name": "guitarTuner",
        "version": "0.0.1",
        "private": true,
        "scripts": {
                "start": "node node_modules/react-native/
                local-cli/cli.js start",
                "test": "jest"
        },
        "dependencies": {
                "react": "16.0.0-alpha.12",
                "react-native": "0.45.1"
        },
        "devDependencies": {
                "babel-jest": "20.0.3",
                "babel-preset-react-native": "2.0.0",
                "jest": "20.0.4",
                "react-test-renderer": "16.0.0-alpha.12"
        },
        "jest": {
                "preset": "react-native"
        }
}

可以看到,除了由 React Native 的 CLI 在运行init脚本时创建的reactreact-native之外,没有其他依赖项。

为了获得从麦克风录制的权限,我们还需要修改我们的ios/guitarTuner/Info.plist,添加一个Microphone Usage Description,这是一个要显示给用户的消息,请求在她的设备上访问麦克风。我们需要在最后的</dict></plist>之前添加这些行:

<key>NSMicrophoneUsageDescription</key><key>NSMicrophoneUsageDescription</key> 
<string>This app uses the microphone to detect what guitar 
         string is being pressed.
</string>

通过这最后一步,我们应该已经准备好开始编写应用程序的 JavaScript 部分。但是,我们仍然需要设置我们将用于录制和频率检测的原生模块。

编写原生模块

我们需要 XCode 来编写原生模块,该模块将使用麦克风录制样本,并分析这些样本以计算主频率。由于我们对这些计算方式不感兴趣,我们将使用一个开源库来委托大部分录制和 FFT 计算。该库名为SCListener,其分支可以在github.com/emilioicai/sc_listener找到。

我们需要下载该库,并按照以下步骤将其文件添加到项目中:

  1. 导航到我们的 iOS 项目所在的文件夹:<project_folder>/ios/

  2. 双击guitarTuner.xcodeproj,这将打开 XCode。

  3. 右键单击guitarTuner文件夹,然后单击“添加文件到"guitarTuner"...”:

  1. 选择从下载的SCListener库中选择所有文件:

  1. 单击 Accept。您应该在 XCode 中得到一个类似于这样的文件结构:

  1. SCListener需要安装 AudioToolbox 框架。我们可以通过在 XCode 中点击项目的根目录来实现这一点。

  2. 选择 Build Phases 选项卡。

  1. 转到 Link Binary with Libraries。

  2. 单击+图标。

  3. 选择 AudioToolbox.framework。

  1. 现在,让我们添加一个将使用SCListener并将数据发送到 React Native 的模块。右键单击guitarTuner文件夹,然后单击 New File。

  2. 添加一个名为FrequencyDetector.h的头文件:

  1. 让我们重复这个过程,为我们的模块添加一个实现文件:右键单击guitarTuner文件夹,然后单击 New File。

  2. 添加一个名为FrequencyDetector.m的 Objective-C 文件:

我们的模块FrequencyDetector现在已经准备好实现了。让我们看看FrequencyDetector.h应该是什么样子:

/*** FrequencyDetector.h ***/

#import <React/RCTBridgeModule.h>
#import <Accelerate/Accelerate.h>

@interface FrequencyDetector : NSObject 
@end

它只导入了两个模块:Accelerate用于进行傅立叶变换计算,RCTBridgeModule用于使我们的本地模块与应用的 JavaScript 代码进行交互。现在,让我们来实现这个模块:

/*** FrequencyDetector.m ***/

#import "FrequencyDetector.h"
#import "SCListener.h"

NSString *freq = @"";

@implementation FrequencyDetector

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(getFrequency:(RCTResponseSenderBlock)callback)
{
  double power = [[SCListener sharedListener] averagePower];
  if(power < 0.03) { //ignore low volumes
    freq = @"0";
  } else {
    freq = [NSString stringWithFormat:@"%0.3f",
           [[SCListener sharedListener] frequency]];
  }
  callback(@[[NSNull null], freq]);
}

RCT_EXPORT_METHOD(initialise)
{
  [[SCListener sharedListener] listen];
}

@end

即使对于非 Objective-C 开发人员,这段代码也应该很容易理解:

  1. 首先,我们导入SCListener,这个模块暴露了从设备麦克风录制和计算录制样本的 FFT 的方法

  2. 然后,我们公开了两种方法:getFrequencyinitialise

getFrequency的实现也非常简单。我们只需要通过调用我们的 SCListener 共享实例上的averagePower来读取麦克风上检测到的音量。如果音量足够强,我们就认为弹了一根吉他弦,所以我们更新一个名为freq的变量,它将被传递到我们 JavaScript 代码提供的回调中。请注意,由于本地代码和 JavaScript 代码之间的桥接的性质,只能通过回调(或承诺)将数据发送回 JavaScript。

我们将本地世界中的方法暴露到 JavaScript 世界的方式是使用RCTBridgeModule提供的宏RCT_EXPORT_METHOD。我们还需要让 React Native 知道这个模块可以从我们的 JavaScript 代码中使用。我们通过调用另一个宏来做到这一点:RCT_EXPORT_MODULE。这就是我们需要的全部;从这一刻起,我们可以使用这个模块的方法:

import { NativeModules } from 'react-native';
var FrequencyDetector = NativeModules.FrequencyDetector;

FrequencyDetector.initialise();
FrequencyDetector.getFrequency((res, freq) => {});

正如我们所看到的,我们将一个回调传递给getFrequency,其中将接收当前记录的频率。我们现在可以使用这个值来计算按下了哪根弦以及它的调谐情况。让我们看看我们将如何在我们应用程序的 JavaScript 组件中使用这个模块。

index.ios.js

我们已经看到了我们如何访问我们从本地模块FrequencyDetector中暴露的方法。现在让我们看看如何在我们的组件树中使用它来更新我们应用程序的状态:

/*** index.ios.js ***/

...

var FrequencyDetector = NativeModules.FrequencyDetector;

export default class guitarTuner extends Component {

  ...

  componentWillMount() {
 FrequencyDetector.initialise();
    setInterval(() => {
      FrequencyDetector.getFrequency((res, freq) => {
        let stringData = getClosestString(parseInt(freq));
        if(!stringData) {
          this.setState({
            delta: null,
            activeString: null
          });
        } else {
          this.setState({
            delta: stringData.delta,
            activeString: stringData.number
          });
        }
      });
    }, 500);
  }

 ...

});

AppRegistry.registerComponent('guitarTuner', () => guitarTuner);

大部分逻辑将放在我们的入口文件的componentWillMount方法中。我们需要初始化FrequencyDetector模块,从设备的麦克风开始监听,然后我们调用setInterval来重复(每 0.5 秒)调用FrequencyDetectorgetFrequency方法来获取更新的显著频率。每次我们获得一个新的频率,我们将通过调用一个名为getClosestString的支持函数来检查最可能被按下的吉他弦,并将返回的数据保存在我们的组件状态中。我们将把这个函数存储在我们的utils文件中。

utils

在继续index.ios.js之前,让我们看看我们位于src/utils/index.js中的utils文件:

/*** src/utils/index.js ***/

const stringFrequencies = [
  { min: 287, max: 371, tuned: 329 },
  { min: 221, max: 287, tuned: 246 },
  { min: 171, max: 221, tuned: 196 },
  { min: 128, max: 171, tuned: 146 },
  { min: 96, max: 128, tuned: 110 },
  { min: 36, max: 96, tuned: 82}
];

export function getClosestString(freq) {
  let stringData = null;
  for(var i = 0; i < stringFrequencies.length; i++) {
    if(stringFrequencies[i].min < freq && stringFrequencies[i].max 
       >= freq){
      let delta = freq - stringFrequencies[i].tuned; //absolute delta
      if(delta > 0){
        delta = Math.floor(delta * 100 / (stringFrequencies[i].max - 
                           stringFrequencies[i].tuned));
      } else {
        delta = Math.floor(delta * 100 / (stringFrequencies[i].tuned - 
                           stringFrequencies[i].min));
      }
      if(delta > 75) delta = 75; //limit deltas
      if(delta < -75) delta = -75;
      stringData = { number: 6 - i, delta } //relative delta
      break;
    }
  }
  return stringData;
}

export const colors = {
  black: '#1f2025',
  yellow: '#f3c556',
  green: '#3bd78b'
}

getClosestString是一个函数,根据提供的频率,将返回一个包含两个值的 JavaScript 对象:

  • number:这是最可能被按下的吉他弦的数字

  • delta:这是提供的频率与最可能被按下的吉他弦的调谐频率之间的差异

我们还将导出一组颜色及其十六进制表示,这将被一些用户界面组件使用,以保持整个应用程序的一致性。

在调用getClosestString之后,我们有足够的信息来构建我们应用程序的状态。当然,我们需要将这些数据提供给调谐器(显示吉他弦的调谐情况)和弦的表示(显示哪根吉他弦被按下)。让我们看看整个根组件,看看这些数据是如何在组件之间传播的:

/*** index.ios.js ***/

import React, { Component } from 'react';
import {
  AppRegistry,
  StyleSheet,
  Image,
  View,
  NativeModules,
  Animated,
  Easing,
  StatusBar,
  Text
} from 'react-native';
import Tuner from './src/components/Tuner';
import Strings from './src/components/Strings';
import { getClosestString, colors } from './src/utils/';

var FrequencyDetector = NativeModules.FrequencyDetector;

export default class guitarTuner extends Component {
  state = {
 delta: null,
    activeString: null
  }

  componentWillMount() {
    FrequencyDetector.initialise();
    setInterval(() => {
      FrequencyDetector.getFrequency((res, freq) => {
        let stringData = getClosestString(parseInt(freq));
        if(!stringData) {
          this.setState({
            delta: null,
            activeString: null
          });
        } else {
          this.setState({
            delta: stringData.delta,
            activeString: stringData.number
          });
        }
      });
    }, 500);
  }

  render() {
    return (
      <View style={styles.container}>
 <StatusBar barStyle="light-content"/>
        <Tuner delta={this.state.delta} />
        <Strings activeString={this.state.activeString}/>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: colors.black,
    flex: 1
  }
});

AppRegistry.registerComponent('guitarTuner', () => guitarTuner);

我们将使用两个组件来渲染当前按下的弦(<Strings/>)以及按下的弦的调谐程度(<Tuner/>)。

除此之外,我们还使用了一个名为<StatusBar/>的 React Native 组件。<StatusBar/>允许开发人员选择应用程序在顶部栏中显示的颜色,其中显示运营商、时间、电池电量等:

因为我们希望我们的应用有一个黑色的背景,我们决定使用light-content的 bar 样式。这个组件允许我们完全隐藏 bar,改变其背景颜色(仅限 Android),或隐藏网络活动,等等。

现在让我们转向显示所有视觉组件的组件。我们将从<Tuner/>开始。

调谐器

我们的<Tuner/>组件包括两个元素:一个背景图像将屏幕分成几个部分,以及一个指示器,它将根据弹奏的吉他弦的音调移动。为了使其用户友好,我们将使用动画来移动指示器,类似于模拟调谐器的行为:

/*** src/components/Tuner/index ***/

import React, { Component } from 'react';
import {
  StyleSheet,
  Image,
  View,
  Animated,
  Easing,
  Dimensions
} from 'react-native';

import { colors } from '../utils/';

var {height, width} = Dimensions.get('window');

export default class Tuner extends Component {
  state = {
 xIndicator:  new Animated.Value(width/2)
  }

  static propTypes = {
    delta: React.PropTypes.number
  }

  componentWillReceiveProps(newProps) {
    if(this.props.delta !== newProps.delta) {
      Animated.timing(
        this.state.xIndicator,
        {
          toValue: (width/2) + (newProps.delta*width/2)/100,
          duration: 500,
          easing: Easing.elastic(2)
        }
      ).start();
    }
  }

  render() {
    let { xIndicator } = this.state;

    return (
      <View style={styles.tunerContainer}>
        <Image source={require('../../img/tuner.jpg')} 
         style={styles.tuner}/>
 <Animated.Image source={require('../../img/indicator.jpg')} 
         style={[styles.indicator, {left: xIndicator}]}/>
      </View>
    )
  }
}

const styles = StyleSheet.create({
  tunerContainer: {
    flex: 1,
    backgroundColor: colors.black,
    marginTop: height * 0.05
  },
  tuner: {
    width,
    resizeMode: 'contain'
  },
  indicator: {
    position: 'absolute',
    top: 10
  }
});

我们将使用组件的state变量来进行动画命名为xIndicator,它将以动画方式存储指示器应该在的位置的值。记住,越接近中心,弦的音调就会调得越好。我们将使用componentWillReceiveProps方法和Animated.timing函数每次从父组件接收到新的delta属性时更新这个值,以确保图像是动画的。为了使其更加逼真,我们还添加了一个缓动函数,这将使指示器像真正的模拟指示器一样弹跳。

我们还为我们的类添加了一个propTypes静态属性进行类型检查。这样我们就可以确保我们的组件以正确的格式接收到一个 delta。

最后,还记得我们在utils文件中导出了颜色列表及其十六进制值吗?我们在这里使用它来显示这个组件的背景颜色是什么。

最后一个组件是吉他的六根弦的表示。当我们的FrequencyDetector原生模块检测到弹奏的频率时,我们将通过将音符容器的边框更改为绿色来显示具有发射最接近频率的弦:

因此,我们需要从其父组件接受一个属性:活动吉他弦的编号。让我们来看一下这个简单组件的代码:

/*** src/components/Strings ***/

import React, { Component } from 'react';
import {
  StyleSheet,
  Image,
  View,
  Text
} from 'react-native';

import { colors } from '../utils/';

const stringNotes = ['E','A','D','G','B','E'];

export default class Strings extends Component {
 static propTypes = {
    activeString: React.PropTypes.number
  }

  render() {
    return (
      <View style={styles.stringsContainer}>
        {
          stringNotes.map((note, i) => {
            return (
              <View key={i} style={styles.stringContainer}>
                <Image source={require('../../img/string.jpg')} 
                 style={styles.string}/>
                <View style={[styles.noteContainer, 
                 {borderColor: (this.props.activeString === (i+1))
                  ? '#3bd78b' : '#f3c556'}]}>
                  <Text style={styles.note}>
                    {note}
                  </Text>
                </View>
              </View>
            )
          })
        }
      </View>
    );
  }
}

const styles = StyleSheet.create({
  stringsContainer: {
    borderTopColor: colors.green,
    borderTopWidth: 5,
 justifyContent: 'space-around',
    flexDirection: 'row'
  },
  stringContainer: {
    alignItems: 'center'
  },
  note: {
    color: 'white',
    fontSize: 19,
    textAlign: 'center'
  },
  noteContainer: {
    top: 50,
    height: 50,
    width: 50,
    position: 'absolute',
    padding: 10,
    borderColor: colors.yellow,
    borderWidth: 3,
    borderRadius: 25,
    backgroundColor: colors.black
  }
});

我们正在渲染六个图像,每个代表一根吉他弦,并使用space-around来使它们在整个设备屏幕上分布,留下两个小空间。我们使用一个包含吉他每根弦音符的常量数组将它们映射到字符串表示中。我们还将使用从其父级接收到的activeString属性来决定是否应该为每个音符显示黄色边框还是绿色边框。

我们再次使用propTypes来检查所提供的属性的类型(在这种情况下是一个数字)。

这就是我们构建吉他调音器所需的所有代码。现在让我们添加一个图标和一个启动画面,使应用程序准备好提交到 App Store。

添加图标

一旦我们设计好图标并将其保存为大图像,我们需要将其调整为苹果要求的所有格式。一般来说,这些是所需的尺寸:

  • 20 x 20 px(iPhone Notification 2x)

  • 60 x 60 px(iPhone Notification 3x)

  • 58 x 58 px(iPhone Spotlight - iOS 5,6 2x)

  • 67 x 67 px(iPhone Spotlight - iOS 5,6 3x)

  • 80 x 80 px(iPhone Spotlight - iOS 7-10 2x)

  • 120 x 120 px(iPhone Spotlight - iOS 7-10 3x && iPhone App ios 7-10 2x)

  • 180 x 180 px(iPhone App ios 7-10 3x)

由于这是一个非常繁琐的过程,我们可以使用在线工具之一,通过提供足够大的图像来自动完成所有调整大小的任务。最受欢迎的工具之一可以在resizeappicon.com/找到。

一旦我们有了适当尺寸的图标,我们需要将它们添加到我们的 XCode 项目中。我们可以通过在 XCode 中点击Images.xcassets,并将每个图像与其相应的尺寸添加到此窗口中的每个资产来实现这一点:

下次编译我们的应用程序时,我们将在模拟器中看到我们的新图标(使用command + Shift + H来显示主屏幕)。

添加启动画面

启动画面是 iOS 在应用程序加载时显示的图像。有几种技术可以使这个介绍对用户愉快,比如显示用户界面的预览,用户一旦加载应用程序就会看到。然而,我们将采用更简单的方法:我们将显示带有标题的应用程序标志。

最简单和更灵活的方法是使用 XCode 中的界面构建器,通过点击LaunchScreen.xib来实现:

我们需要取消勾选左横向和右横向选项,以便在所有情况下只允许纵向模式。

总结

这个应用程序的主要挑战是从我们的 JavaScript 代码访问用 Objective-C 编写的本地模块。幸运的是,React Native 有手段可以用相对较少的代码轻松实现这两个世界之间的通信。

我们只专注于 iOS 应用程序,但现实情况是,在 Android 中构建相同的应用程序应该遵循非常相似的过程,考虑到我们应该用 Java 而不是 Objective-C 构建我们的本地模块。此外,我们学会了在应用程序中包含图标和启动屏幕的过程,以完成发布前的开发周期。

由于我们的应用程序只有一个屏幕,我们选择不使用任何路由或状态管理库,这使我们能够将重点放在我们的 JavaScript 代码和我们实现的本地模块之间的通信上。

我们还创建了一些动画来模拟模拟调谐器,为这个应用程序增添了吸引人和有趣的外观。

除了图标和启动屏幕外,我们还注意到了另一个在许多应用程序中很重要的视觉元素:状态栏。我们看到了根据我们的应用程序外观轻松更改其内容颜色有多容易。在这种情况下,我们选择了深色背景,因此我们需要在状态栏中使用浅色内容,尽管一些应用程序(如游戏)可能在没有状态栏的情况下看起来更好。

在下一章中,我们将转向一种不同类型的应用程序:即消息应用程序。

第六章:消息应用

一对一通信是手机的主要用途,尽管短信已经很快被直接消息应用所取代。在本章中,我们将使用 React Native 和 Firebase 构建一个消息应用,Firebase 是一个移动后端服务,可以使我们摆脱为应用构建整个后端的工作。相反,我们将专注于完全从前端处理应用的状态。当然,这可能会有安全方面的影响,需要最终解决,但为了保持本书对 React Native 功能的关注,我们将坚持在应用内部保留所有逻辑的方法。

Firebase 是一个建立在自同步数据集合上的实时数据库,它与 MobX 非常搭配,所以我们将再次使用它来控制应用的状态。但在本章中,我们将更深入地挖掘,因为我们将构建更大的数据存储,这些数据将通过mobx-react连接器注入到我们的组件树中。

我们将构建该应用,使其可以在 iOS 和 Android 上使用,为导航编写一些特定于平台的代码(我们将在 iOS 上使用选项卡导航,在 Android 上使用抽屉导航)。

为了减少代码的大小,在本章中,我们将专注于功能而不是设计。大部分用户界面将是简单明了的,但我们会尽量考虑可用性。此外,我们将在我们的聊天屏幕上使用react-native-gifted chat--一个预先构建的 React Native 组件,用于根据消息列表渲染聊天室。

概述

消息应用需要比我们在前几章中审查的应用更多的工作,因为它需要一个用户管理系统,包括登录、注册和退出登录。我们将使用 Firebase 作为后端来减少构建此系统的复杂性。除了用户管理系统,我们还将使用他们的推送通知系统,在新消息发送给用户时通知用户。Firebase 还提供了分析平台、lambda 函数服务和免费的存储系统,但我们将从中获益最多的功能是他们的实时数据库。我们将在那里存储用户的个人资料、消息和聊天数据。

让我们看看我们的应用将会是什么样子,以便心中有个印象,我们将要构建的屏幕:

第一个屏幕将是登录/注册屏幕,因为我们需要用户提供姓名和一些凭据,以将他们的设备连接到特定帐户,这样他们就可以接收每条消息的推送通知。这两种身份验证方法都使用 Firebase 的 API 进行验证,成功后将显示聊天屏幕:

在联系人列表中按下一个联系人时,应用程序将在聊天屏幕中显示与所选联系人的对话:

聊天屏幕将显示所有为登录用户启动的聊天。最初,这个屏幕将是空的,因为用户还没有开始任何聊天。要开始对话,用户应该去搜索屏幕以找到一些联系人:

这是一个简单的屏幕,用户可以在其中输入联系人姓名以在数据库中搜索。如果联系人的姓名匹配,用户将能够点击它开始对话。从那时起,对话将显示在聊天屏幕中。

最后一个屏幕是个人资料屏幕:

这个屏幕只是用来注销当前用户的。在扩展应用程序时,我们可以添加更多功能,比如更改头像或用户名。

虽然安卓上的应用程序看起来非常相似,但导航将被抽屉取代,从抽屉中可以访问所有屏幕。让我们来看看安卓版本:

登录/注册屏幕具有标准的文本输入和按钮组件用于安卓:

用户登录后,可以通过滑动手指手势打开抽屉来浏览所有屏幕。默认登录后打开的屏幕是聊天屏幕,我们将列出用户拥有的所有打开对话的列表:

从这个屏幕上,用户可以按下特定的对话来列出其中的消息:

接下来的屏幕是搜索屏幕,用于搜索其他用户并与他们开始对话:

最后一个屏幕是个人资料屏幕,可以在其中找到 LOGOUT 按钮:

该应用程序将在横向和纵向模式下在两个平台上运行:

正如我们可以想象的那样,这个应用程序将需要一个强大的后端环境来存储我们的用户、消息和状态。此外,我们将需要一个推送通知平台,在用户收到任何消息时通知他们。由于本书专注于 React Native,我们将把所有这些后端工作委托给移动世界中最流行的移动后端服务之一:Firebase。

在开始编码之前,我们将花一些时间设置我们的 Firebase 推送通知服务和实时数据库,以更好地了解我们的应用程序将要处理的数据类型。

总之,本章我们将涉及以下主题:

  • React Native 中的复杂 Redux

  • Firebase 实时数据库

  • Firebase 推送通知

  • Firebase 用户管理

  • 表单

让我们首先回顾一下我们将使用的数据模型以及我们的应用程序如何与 Firebase 连接以同步其数据。

Firebase

Firebase 是一种移动后端服务MBaaS),这意味着它为移动开发人员提供了所有后端必需品,如用户管理、无 SQL 数据库和推送通知服务器。它通过官方的 node 包轻松集成到 React Native 中,这为数据库连接提供了免费的服务。不幸的是,Firebase 并没有为他们的推送通知服务提供 JavaScript SDK,但有几个 React Native 库通过将 Firebase 的 iOS 和 Java SDK 与 JavaScript 接口进行桥接来填补这一空白。我们将使用react-native-fcm,因为它在这一领域最成熟。

在 Firebase MBaaS 上构建应用程序之前,您需要为其创建一个项目。这是一个免费的过程,可以在 Firebase 的网站firebase.google.com/上找到解释。虽然这个过程与 React Native 没有直接相关,但这是一个很好的起点,可以帮助我们了解如何为我们的应用程序设置和使用 MBaaS。通过遵循 Firebase 文档网站上提供的教程,大部分配置可以在几分钟内完成。设置这个 MBaaS 的好处使得这几分钟的时间和初始麻烦都是值得的。

要设置 Firebase 并将我们的应用连接到正确的项目,我们需要使用在 Firebase 项目仪表板内的设置屏幕中找到的web 配置片段。我们将此初始化片段添加到src/firebase.js中:

import firebase from 'firebase';

var firebaseConfig = {
  apiKey: "<Your Firebase API key>",
  authDomain: "<Your Firebase Auth domain>",
  databaseURL: "<Your Firebase database URL>",
  projectId: "<Your Firebase projectId>",
  storageBucket: "<Your Firebase storageBucket>",
  messagingSenderId: "<Your messaging SenderId>"
};

export const firebaseApp = firebase.initializeApp(firebaseConfig);

项目设置完成后,我们可以开始查看我们的数据库将如何被构建。

实时数据库

Firebase 允许移动开发人员使用云托管的 noSQL 数据库在用户和设备之间存储和同步数据。更新后的数据在毫秒内同步到连接的设备上,如果应用程序离线,数据仍然可用,无论网络连接如何,都提供了良好的用户体验。

在考虑一对一通信应用程序应处理的基本数据时,涉及三个数据模型:

  • users:这将存储头像、名称和推送通知令牌。这里不需要存储身份验证数据,因为它是通过不同的 Firebase API(身份验证 API)处理的。

  • messages:我们将在每个聊天室中单独保存每条消息,以便使用聊天室 ID 作为键进行轻松检索。

  • chats:所有有关已打开聊天的信息都将存储在这里。

为了了解我们将如何请求和使用我们应用程序中的数据,让我们看一下我们实际可以用于测试的示例数据的要点:

{
  "chats" : {
    "--userId1--" : {
      "--userId2----userId1--" : {
        "contactId" : "--userId2--",
        "image" : "https://images.com/person2.jpg",
        "name" : "Jason"
      }
    },
    "--userId2--" : {
      "--userId2----userId1--" : {
        "contactId" : "--userId1--",
        "image" : "https://images.com/person1.jpg",
        "name" : "John"
      }
    }
  },
  "messages" : {
    "--userId2----userId1--" : {
      "-KpEwU8sr01vHSy3qvRY" : {
        "_id" : "2367ad00-301d-46b5-a7b5-97cb88781489",
        "createdAt" : 1500284842672,
        "text" : "Hey man!",
        "user" : {
          "_id" : "--userId2--",
          "name" : "Jason"
        }
      }
    }
  },
  "users" : {
    "--userId1--" : {
      "name" : "John",
      "notificationsToken" : ""
    },
    "--userId2--" : {
      "name" : "Jason",
      "notificationsToken" : "--notificationsId1--"
    }
  }
}

我们以一种易于消息应用程序检索和同步的方式组织我们的数据。我们没有对数据结构进行规范化,而是引入了一些数据重复,以增加数据检索速度,并将前端代码简化到最大程度。

users集合使用用户 ID 作为键(--user1----user2--)保存用户数据。这些用户 ID 在注册/登录期间由 Firebase 自动检索。每个用户都有一个通知令牌,这是用户登录的设备的标识符,用于推送通知服务。当用户注销时,通知令牌将被删除,因此发送给该用户的消息将被存储,但不会通知到任何设备。

chats集合通过用户 ID 存储每个用户的聊天列表。每个聊天都有自己的 ID(两个用户 ID 的连接),并且将被复制,因为该聊天中的每个用户都应该有聊天数据的副本。在每个副本中,有足够的信息供另一个用户构建他们的聊天屏幕。

messages集合存储在一个单独的集合中,可以通过该 ID 引用。每个聊天 ID 指向一个消息列表(在本例中只有一个),其中存储了聊天屏幕所需的所有数据。在这个集合中也存在一些重复,因为一些用户数据与每条消息一起存储,以减少构建聊天屏幕时所需的请求数量。

在他们的网站上可以找到有关如何在 Firebase 的实时数据库中读写数据的完整教程(firebase.google.com/docs/database/),但是我们将快速浏览一下我们在本章中将使用的方法。

从 Firebase 的数据库中读取数据

有两种从 Firebase 的数据库中检索数据的方法。第一种设置一个监听器,每当数据更改时都会被调用,因此我们只需要为我们应用程序的整个生命周期设置一次:

firebaseApp.database().ref('/users/' + userId).on('value', (snapshot) => {
  const userObj = snapshot.val();
  this.name = userObj.name;
  this.avatar = userObj.avatar;
});

正如我们所看到的,为了检索数据的快照,我们需要在我们的src/firebase.js文件中创建的firebaseApp对象中调用database()方法。然后,我们将拥有一个database对象,我们可以在其上调用ref('<uri>'),传递数据存储的 URI。这将返回一个由该 URI 指向的数据片段的引用。我们可以使用on('value', callback)方法,它将附加一个回调,传递数据的快照。Firebase 总是将对象返回为快照,因此我们需要自己将它们转换为普通数据。在这个例子中,我们想要检索一个具有两个键(nameavatar)的对象,所以我们只需要在快照上调用val()方法来检索包含数据的普通对象。

如果我们不需要检索的数据在每次更新时自动同步,我们可以使用once()方法代替on()

import firebase from 'firebase';
import { firebaseApp } from '../firebase';

firebaseApp.database().ref('/users/' + userId).once('value')
.then((snapshot) => {
  const userObj = snapshot.val();
  this.name = userObj.name;
  this.avatar = userObj.avatar;
});

接收快照的回调只会被调用一次。

更新 Firebase 数据库中的数据

在 Firebase 数据库中写入数据也可以通过两种不同的方式完成:

firebaseApp.database().ref('/users/' + userId).update({
  name: userName
});

update()根据作为参数传递的键和值更改由提供的 URI 引用的对象。对象的其余部分保持不变。

另一方面,set()将用我们提供的参数替换数据库中的对象:

firebaseApp.database().ref('/users/' + userId).set({
  name: userName,
  avatar: avatarURL
});

最后,如果我们想要添加一个新的数据快照,但是我们希望 Firebase 为其生成一个 ID,我们可以使用push方法:

firebaseApp.database().ref('/messages/' + chatId).push().set(message);

身份验证

我们将使用 Firebase 身份验证服务,因此我们不需要担心存储登录凭据、处理忘记的密码或验证电子邮件。 这些以及其他相关任务都可以通过 Firebase 身份验证服务免费完成。

为了通过电子邮件和密码激活登录和注册,我们需要在 Firebase 仪表板中将此方法作为会话登录方法启用。 有关如何执行此操作的更多信息,请访问 Firebase 网站上的firebase.google.com/docs/auth/web/password-auth

在我们的应用中,我们只需要使用提供的 Firebase SDK 进行登录:

firebase.auth().signInWithEmailAndPassword(username, password)
  .then(() => {
        //user is logged in
  })
  .catch(() => {
        //error logging in
  })
})

对于注册,我们可以使用以下代码:

firebase.auth().createUserWithEmailAndPassword(email, password)
.then((user) => {
   //user is registered
})
.catch((error) => {
   //error registering
})

所有令牌处理将由 Firebase 处理,我们只需要添加一个监听器来确保我们的应用在身份验证状态更改时得到更新:

firebase.auth().onAuthStateChanged((user) => {
  //user has logged in or out
}

设置文件夹结构

让我们使用 React Native 的 CLI 初始化一个 React Native 项目。 该项目将被命名为messagingApp,并将可用于 iOS 和 Android 设备:

react-native init --version="0.45.1" messagingApp

我们将使用 MobX 来管理我们应用的状态,因此我们将需要一个用于我们存储的文件夹。 其余的文件夹结构对大多数 React 应用程序来说是标准的:

我们需要五个屏幕(ChatsChatLoginProfileSearch),一个组件(ListItem)和两个存储(chatsusers),这些将通过stores/index.js文件可用。 我们还将使用两个辅助程序来支持我们的应用:

  • notifications.js:与推送通知相关的所有逻辑将存储在此文件中

  • firebase.js:这包括 Firebase SDK 的配置和初始化

由于我们将使用 MobX 和其他几个依赖项,让我们查看一下我们的package.json文件,以了解我们将使用哪些包:

/*** package.json ***/

{
        "name": "messagingApp",
        "version": "0.0.1",
        "private": true,
        "scripts": {
                "start": "node node_modules/react-native/local-cli
                         /cli.js start",
                "test": "jest"
        },
        "dependencies": {
                "firebase": "⁴.1.3",
                "mobx": "³.2.0",
                "mobx-react": "⁴.2.2",
                "react": "16.0.0-alpha.12",
                "react-native": "0.45.1",
                "react-native-fcm": "⁷.1.0",
                "react-native-gifted-chat": "⁰.2.0",
                "react-native-keyboard-aware-scroll-view": "⁰.2.9",
                "react-native-vector-icons": "⁴.2.0",
                "react-navigation": "¹.0.0-beta.11"
        },
        "devDependencies": {
                "babel-jest": "20.0.3",
                "babel-plugin-transform-decorators-legacy": "¹.3.4",
                "babel-preset-react-native": "2.1.0",
                "jest": "20.0.4",
                "react-test-renderer": "16.0.0-alpha.12"
        },
        "jest": {
                "preset": "react-native"
        }
}

我们将使用的一些 npm 包是:

  • firebase:Firebase 的身份验证和数据库连接的 SDK

  • mobx:MobX 将处理我们的应用状态

  • react-native-fcm:Firebase 的推送消息 SDK

  • react-native-gifted-chat:用于渲染聊天室的库,包括日期分隔、头像和许多其他功能

  • react-native-keyboard-aware-scroll-view:一个库,确保在处理表单时屏幕键盘不会隐藏任何焦点文本输入

  • react-native-vector-icons:我们将在此应用中使用 Font Awesome 图标

  • react-navigation:我们将有一个抽屉,一个选项卡和一个堆栈导航器来处理我们应用程序中的屏幕

  • babel-plugin-transform-decorators-legacy:这个库允许我们使用装饰器(使用传统的@语法),在使用 MobX 时非常有用

运行npm install后,我们的应用程序将准备好开始编码。与以前的应用程序一样,我们的消息应用程序的入口点将在index.ios.js(iOS)和index.android.js(Android)中是相同的代码:

/*** index.ios.js and index.android.js ***/ 

import React from 'react'
import { AppRegistry } from 'react-native';
import App from './src/main';

import { Provider } from 'mobx-react/native';
import { chats, users } from './src/stores';

class MessagingApp extends React.Component {
  render() {
    return (
      <Provider users={users} chats={chats}>
        <App/>
      </Provider>
    )
  }
}

AppRegistry.registerComponent('messagingApp', () => MessagingApp);

这是一种使用 MobX 启动 React Native 应用程序的标准方式--<Provider />作为根元素提供,以将两个商店(userschats)注入到我们应用程序的屏幕中。所有初始化和导航逻辑都已延迟到src/main.js文件中:

/*** src/main.js ***/

import React from 'react'
import { DrawerNavigator,TabNavigator } from 'react-navigation'
import { Platform, View } from 'react-native'
import { observer, inject } from 'mobx-react/native'

import Login from './screens/Login'
import Chats from './screens/Chats'
import Profile from './screens/Profile'
import Search from './screens/Search'
import { users, chats } from './stores'

let Navigator;
if(Platform.OS === 'ios'){
  Navigator = TabNavigator({
    Chats: { screen: Chats },
    Search: { screen: Search },
    Profile: { screen: Profile }
  }, {
    tabBarOptions: {
      inactiveTintColor: '#aaa',
      activeTintColor: '#000',
      showLabel: true
    }
  });
} else {
  Navigator = DrawerNavigator({
    Chats: { screen: Chats },
    Search: { screen: Search },
    Profile: { screen: Profile }
  });
}

@inject('users') @observer
export default class App extends React.Component {
  constructor() {
    super();
  }

  render() {
 if(this.props.users.isLoggedIn){
      return <Navigator/>
    } else {
      return <Login/>
    }
  }
}

src/main.js文件中我们可以看到的第一件事是,我们将使用不同的导航器,取决于我们运行应用程序的平台:iOS 将打开一个选项卡导航器,而 Android 将打开一个基于抽屉的导航器。

然后,我们看到我们将在应用程序中的许多组件中重复的一行:

@inject('users') @observer

这是告诉 MobX 这个组件需要接收users商店的方式。然后 MobX 将其作为属性传递给这个组件,因此我们可以使用它所持有的所有方法和属性。在这种情况下,我们对isLoggedIn属性感兴趣,以便在用户尚未登录时向用户呈现<Login />屏幕。由于 MobX 将这个属性注入为我们组件的属性,访问它的正确方式将是this.props.users.isLoggedIn

在继续构建组件之前,让我们看一下我们将在本章中使用的商店,以更好地了解可用的数据和操作。

用户商店

这个商店负责保存所有围绕用户的数据和逻辑,但也帮助chats商店在用户登录时初始化:

/*** src/stores/users.js ***/

import {observable, computed, map, toJS, action} from 'mobx';
import chats from './chats'
import firebase from 'firebase';
import { firebaseApp } from '../firebase';
import notifications from '../notifications'

class Users {
        @observable id = null;
        @observable isLoggedIn = false;
        @observable name = null;
        @observable avatar = null;
        @observable notificationsToken = null;
        @observable loggingIn = false;
        @observable registering = false;
        @observable loggingError = null;
        @observable registeringError = null;

        @action login = function(username, password) {
                //login with Firebase email/password method
        }

        @action logout = function() {
                //logout from Firebase authentication service
        }

        @action register = function(email, password, name) {
                //register through firebase authentication service
        }

        @action setNotificationsToken(token) {
                //store the notifications token for this device
        }

        searchUsers(name) {
                //helper for searching users by name in the database
        }

        constructor() {
                this.bindToFirebase();
        }

        bindToFirebase() {
                //Initialise connection to Firebase user 
                //authentication status and data
        }
}

const users = new Users();

export default users;

这些都是我们在这个商店中需要的所有属性和方法。有几个标志(那些包含动词-ing 形式的属性)需要注意网络活动。现在让我们实现每个方法:

@action login = function(username, password) {
        this.loggingIn = true;
        this.loggingError = null;
        firebase.auth().signInWithEmailAndPassword(username, password)
        .then(() => {
                this.loggingIn = false;
                notifications.init((notificationsToken) => {
                        this.setNotificationsToken(notificationsToken);
                });
        })
        .catch((error) => {
                this.loggingIn = false;
                this.loggingError = error.message;
        });
}

使用 Firebase 登录就像在他们的身份验证 SDK 上调用signInWithEmailAndPassword一样简单。如果登录成功,我们将初始化通知模块以使设备能够接收推送通知。在注销时,我们将遵循相反的路径:

@action logout = function() {
        notifications.unbind();
        this.setNotificationsToken('');
        firebase.auth().signOut();
}

在注册操作中,除了设置网络活动的适当标志之外,我们还需要验证用户输入了名称,初始化通知,并将名称存储在数据库中:

@action register = function(email, password, name) {
        if(!name || name == '') {
                this.registering = false;
                this.registeringError = 'Name was not entered';
                return;
        }
        this.registering = true;
        this.registeringError = null;
        firebase.auth().createUserWithEmailAndPassword(email, password)
        .then((user) => {
                this.registering = false;
                notifications.init((notificationsToken) => {
                        this.setNotificationsToken(notificationsToken);
                });
                firebaseApp.database().ref('/users/' + user.uid).set({
                        name: name
                });
        })
        .catch((error) => {
                this.registering = false;
                this.registeringError = error.message;
        })
}

设置通知令牌只是数据库中的简单更新:

@action setNotificationsToken(token) {
        if(!this.id) return;
        this.notificationsToken = token;
        firebaseApp.database().ref('/users/' + this.id).update({
                notificationsToken: token
        });
}

searchUsers()没有标记为@action,因为它不会修改我们应用程序的状态,而只是在数据库中搜索并返回具有提供的名称的用户列表:

searchUsers(name) {
        return new Promise(function(resolve) {
                firebaseApp.database().ref('/users/').once('value')
                .then(function(snapshot) {
                        let foundUsers = [];
                        const users = snapshot.val();
                        for(var id in users) {
                                if(users[id].name === name) {
                                        foundUsers.push({
                                                name: users[id].name,
                                                avatar: 
                                                users[id].avatar,
                                                notificationsToken:  
                                                users[id].
                                                notificationsToken,
                                                id
                                        });
                                }
                        }
                        resolve(foundUsers);
                });
        });
}

由于我们正在进行的请求的异步性质,我们将结果作为一个 promise 返回。

最后,bindToFirebase()将把此存储中的属性附加到 Firebase 数据库中的数据快照上。此方法由构造函数调用,因此它用作用户数据的初始化。重要的是要注意,当身份验证状态更改时,此数据将被更新,以始终反映用户的最新数据:

bindToFirebase() {
  return firebase.auth().onAuthStateChanged((user) => {
    if(this.chatsBind && typeof this.chatsBind.off === 'function')  
      this.chatsBind.off();
    if(this.userBind && typeof this.userBind.off === 'function') 
      this.userBind.off();

    if (user) {
      this.id = user.uid;
      this.isLoggedIn = true;
      this.chatsBind = chats.bindToFirebase(user.uid);
      this.userBind = firebaseApp.database().ref('/users/' + this.id).
                                             on('value', (snapshot) =>  
    {
        const userObj = snapshot.val();
        if(!userObj) return;
        this.name = userObj.name;
        this.avatar = userObj.avatar;
      });
    } else {
      this.id = null;
      this.isLoggedIn = false;
      this.userBind = null;
      this.name = null;
      this.avatar = null;
    }
  });
}

我们将存储聊天数据的监听器(作为this.chatsBind)和用户数据的监听器(作为this.userBind),这样我们就可以在每次auth状态更改之前删除它们(通过调用off()方法),然后附加新的监听器。

聊天存储

这个存储负责保存所有与聊天和消息相关的数据和逻辑,但它还有助于在用户登录时初始化chats存储:

/*** src/stores/chats.js ***/

import { observable, computed, map, toJS, action } from 'mobx';
import { AsyncStorage } from 'react-native'

import { firebaseApp } from '../firebase'
import notifications from '../notifications'

class Chats {
  @observable list;
  @observable selectedChatMessages;
  @observable downloadingChats = false;
  @observable downloadingChat = false;

  @action addMessages = function(chatId, contactId, messages) {
    //add a list of messages to a chat
  }

  @action selectChat = function(id) {
    //set a chat as selected and retrieve all the messages for it
  }

  @action add(user1, user2) {
    //add a new chat to the list of chats for the users in it
  }

  bindToFirebase(userId) {
    //listen for the list of chats in Firebase to update the 
    @observable list
  }
}

const chats = new Chats()
export default chats;

我们将在@observable list中存储用户拥有的所有打开聊天的列表。当用户选择一个聊天时,我们将下载并同步该聊天上的消息列表到@observable selectedChatMessages。然后,我们将有一些标志,让用户知道我们正在从 Firebase 数据库下载数据。

让我们逐个查看每个方法。我们将从addMessages开始:

@action addMessages = function(chatId, contactId, messages) {
  if(!messages || messages.length < 1) return;

  messages.forEach((message) => {
    let formattedMessage = {
      _id: message._id,
      user: {
        _id: message.user._id,
      }
    };
    if(message.text) formattedMessage.text = message.text;
    if(message.createdAt) formattedMessage.createdAt = 
      message.createdAt/1;
    if(message.user.name) formattedMessage.user.name = 
      message.user.name;
    if(message.user.avatar) formattedMessage.user.avatar = 
      message.user.avatar;
    if(message.image) formattedMessage.image = message.image;

    //add the message to the chat
    firebaseApp.database().ref('/messages/' + 
      chatId).push().set(formattedMessage);

    //notify person on the chat room
    firebaseApp.database().ref('/users/' + contactId).once('value')
    .then(function(snapshot) {
      var notificationsToken = snapshot.val().notificationsToken;
      notifications.sendNotification(notificationsToken, {
        sender: message.user.name,
        text: message.text,
        image: message.user.image,
        chatId
      });
    });
  });
}

此方法接收三个参数:

  • chatId:要添加消息的聊天的 ID。

  • contactId:我们要发送消息的用户的 ID。这将用于向用户的联系人发送通知。

  • messages:这是我们想要添加到聊天中的所有消息的数组。

我们将循环遍历消息列表,按照我们想要存储的方式格式化消息。然后,我们将在数据库引用上调用set()方法,将新消息保存在 Firebase 的数据库中。最后,我们需要向我们的联系人发送通知,所以我们通过查询users集合的contactId来检索他们的通知令牌。

通常由后端处理发送通知,但由于我们正在将所有逻辑设置在应用程序本身上,因此我们需要构建一个发送通知的函数。我们已经在我们的通知module: notifications.sendNotification(notificationsToken, data);中完成了这个。

让我们看看当我们选择一个聊天来显示它的消息时会发生什么:

@action selectChat = function(id) {
  this.downloadingChat = true;
  if(this.chatBind && typeof this.chatBind.off === 'function') 
  this.chatBind.off();
  this.chatBind = firebaseApp.database().ref('/messages/' + id)
  .on('value', (snapshot) => {
    this.selectedChatMessages = [];
    this.downloadingChat = false;
    const messagesObj = snapshot.val();
    for(var id in messagesObj) {
      this.selectedChatMessages.push({
        _id: id,
        text: messagesObj[id].text,
        createdAt: messagesObj[id].createdAt,
        user: {
          _id: messagesObj[id].user._id,
          name: messagesObj[id].user.name,
          avatar: messagesObj[id].user.avatar
        },
        image: messagesObj[id].image
      });
    }
  });
}

这里的主要功能是将监听器附加到消息/聊天 ID 集合,它将使用数据库中所选聊天的消息列表与this.selectedChatMessages observable 同步。这意味着每当新消息存储在 Firebase 中时,this.selectedChatMessages将同步反映出来。这就是 Firebase SDK 中on()方法的工作原理:我们传递一个回调,我们可以使用它来将实时数据库与我们应用程序的状态同步。

使用add()方法将添加新的聊天:

@action add(user1, user2) {
  return new Promise(function(resolve, reject) {
    firebaseApp.database().ref('/chats/' + user1.id + '/' + user1.id + 
    user2.id).set({
      name: user2.name,
      image: user2.avatar,
      contactId: user2.id
    }).then(() => {
      firebaseApp.database().ref('/chats/' + user2.id + '/'
                                 + user1.id + 
      user2.id).set({
        name: user1.name,
        image: user1.avatar,
        contactId: user1.id
      }).then(() => {
        resolve();
      })
    })
  });
}

在这里,我们正在构建并返回一个承诺,当两个聊天(每个用户参与聊天一个)更新时将解决。这两个数据库更新可以看作是数据的复制,但它也将减少数据结构的复杂性,因此减少我们代码库的可读性。

这个存储中的最后一个方法是bindToFirebase()

bindToFirebase(userId) {
  this.downloadingChats = true;
  return firebaseApp.database().ref('/chats/' + userId).
                                on('value', (snapshot) => {
    this.downloadingChats = false;
    const chatsObj = snapshot.val();
    this.list = [];
    for(var id in chatsObj) {
      this.list.push({
        id,
        name: chatsObj[id].name,
        image: chatsObj[id].image,
        contactId: chatsObj[id].contactId
      });
    }
  });
}

正如我们在users存储中看到的,当用户登录并将监听器附加到chats/<userId>数据快照时,将调用此方法,以便将所有聊天数据与this.list属性上的数据库同步。

为了方便起见,我们将两个存储都分组在src/stores/index.js中,这样我们可以在一行代码中导入它们。

/*** src/stores/index.js ***/

import users from './users';
import chats from './chats';

export {
  users,
  chats
};

这就是我们将要使用的存储。正如我们所看到的,大部分业务逻辑都在这里处理,因此可以进行彻底的测试。现在让我们转到我们将用于通知的辅助程序。

使用 Firebase 进行推送通知

Firebase 集成了 iOS 和 Android 的推送通知服务,但不幸的是,它没有提供任何 JavaScript SDK 来使用它。为此,创建了一个开源库,将 Objective-C 和 Java SDK 桥接到 React Native 模块中:react-native-fcm

我们不会在本书中涵盖此模块的安装,因为这是一个不断变化的过程,最好在其存储库上进行跟踪github.com/evollu/react-native-fcm.

我们决定将此模块的逻辑抽象到我们的src/notifications.js文件中,以便在保持可维护性的同时为每个组件提供可用性。让我们来看一下这个文件:

/*** src/notifications.js ***/

import {Platform} from 'react-native';
import FCM, {FCMEvent, RemoteNotificationResult, WillPresentNotificationResult, NotificationType} from 'react-native-fcm';

let notificationListener = null;
let refreshTokenListener = null;
const API_URL = 'https://fcm.googleapis.com/fcm/send';
const FirebaseServerKey = '<Your Firebase Server Key>';

const init = (cb) => {
  FCM.requestPermissions();
  FCM.getFCMToken().then(token => {
    cb(token)
  });
  refreshTokenListener = FCM.on(FCMEvent.RefreshToken, (token) => {
    cb(token);
  });
}

const onNotification = (cb) => {
  notificationListener = FCM.on(FCMEvent.Notification, (notif) => {
      cb(notif);

      if(Platform.OS ==='ios'){
        switch(notif._notificationType){
          case NotificationType.Remote:
            notif.finish(RemoteNotificationResult.NewData)
            break;
          case NotificationType.NotificationResponse:
            notif.finish();
            break;
          case NotificationType.WillPresent:
            notif.finish(WillPresentNotificationResult.All)
            break;
        }
      }
  })
}

const unbind = () => {
  if(notificationListener) notificationListener.remove();
  if(refreshTokenListener) refreshTokenListener.remove();
}

const sendNotification = (token, data) => {
  let body = JSON.stringify({
    "to": token,
    "notification": {
                "title": data.sender || '',
                "body": data. text || '',
                "sound": "default"
        },
    "data": {
      "name": data.sender,
      "chatId": data.chatId,
      "image": data.image
    },
        "priority": 10
  });

  let headers = new Headers({
                "Content-Type": "application/json",
                "Content-Length": parseInt(body.length),
                "Authorization": "key=" + FirebaseServerKey
  });

  fetch(API_URL, { method: "POST", headers, body })
        .then(response => console.log("Send response", response))
        .catch(error => console.log("Error sending ", error));
}

export default { init, onNotification, sendNotification, unbind }

此模块中公开了四个函数:

  • init: 请求接收推送通知的权限(如果尚未授予),并请求设备令牌或在更改时刷新它。

  • onNotification: 当收到通知时,调用提供的回调函数。在 iOS 中,它还调用通知上的适当方法来关闭循环。

  • unbind: 停止监听推送通知。

  • sendNotification: 这将格式化并发送推送通知到特定设备,使用提供的通知令牌。

在 Firebase 中发送通知可以使用他们的 HTTP API,所以我们将使用fetch来发送带有适当标头和主体数据的POST请求。

现在,我们拥有了构建屏幕和组件所需的所有逻辑。

登录

<Login />组件在逻辑上严重依赖于users存储,因为它主要用于呈现登录和注册两个表单。所有表单的验证都由 Firebase 完成,所以我们只需要专注于呈现 UI 元素和调用适当的存储方法。

在这个屏幕中,我们将使用react-native-keyboard-aware-scroll视图,这是一个提供自动滚动<Scrollview />的模块,它会对任何聚焦的<TextInput />做出反应,以便在键盘弹出时它们不会被隐藏。

让我们来看一下代码:

/*** src/screens/Login.js ***/

import React, { PropTypes } from 'react'
import {
  ScrollView,
  TextInput,
  Button,
  Text,
  View,
  Image,
  ActivityIndicator
} from 'react-native';
import { observer, inject } from 'mobx-react/native'
import Icon from 'react-native-vector-icons/FontAwesome'
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'

import LoginForm from '../components/LoginForm'
import RegistrationForm from '../components/RegistrationForm'

@inject('users') @observer
class Login extends React.Component {
  onLogin(email, password) {
    this.props.users.login(email, password);
  }

  onPressRegister(email, password, name) {
    this.props.users.register(email, password, name);
  }

  render() {
    return (
      <KeyboardAwareScrollView style={{padding: 20, marginTop: 20, 
        backgroundColor: '#eee'}}>
        <Icon name="comments" size={60} color='#ccc' 
          style={{alignSelf: 'center', paddingBottom: 20}}/>
        <View style={{alignItems: 'center', marginBottom: 20}}>
          <Text>- please, login to continue -</Text>
        </View>
        <LoginForm
          onPress={this.onLogin.bind(this)}
          busy={this.props.users.loggingIn}
          loggingError={this.props.users.loggingError}
        />
        <View style={{alignItems: 'center', marginTop: 20, 
                      marginBottom: 20}}>
          <Text>- or register -</Text>
        </View>
        <RegistrationForm
          onPress={this.onPressRegister.bind(this)}
          busy={this.props.users.registering}
          registeringError={this.props.users.registeringError}
        />
      </KeyboardAwareScrollView>
    )
  }
}

export default Login;

我们将登录屏幕分为两个表单:<LoginForm /><RegistrationForm />。这两个组件都需要传递三个 props:

  • onPress: 当按下“发送”按钮时组件需要执行的操作。

  • busy: 我们是否在等待远程数据?

  • loginError/registrationError: 登录/注册时发生的错误的描述(如果发生了)。

我们将整个屏幕包裹在<KeyboardAwareScrollView />中,以确保焦点时没有<TextInput />被键盘隐藏。现在让我们来看一下LoginForm

/*** src/components/LoginForm.js ***/

import React, { PropTypes } from 'react'
import {
  TextInput,
  Button,
  Text,
  View,
  Image,
  ActivityIndicator
} from 'react-native';

class LoginForm extends React.Component {
  state= {
    loginEmail: '',
    loginPassword: ''
  }

  onPressLogin() {
    this.props.onPress(this.state.loginEmail, 
    this.state.loginPassword);
  }

  render() {
    return (
        <View style={{backgroundColor: 'white', padding: 15, 
                      borderRadius: 10}}>
          {
            this.props.loggingError &&
            <View style={{backgroundColor: '#fcc', borderRadius: 5, 
              alignItems: 'center', marginBottom: 10}}>
              <Text>{this.props.loggingError}</Text>
            </View>
          }
          <TextInput
            autoCapitalize='none'
            autoCorrect={false}
            keyboardType='email-address'
            returnKeyType='next'
            style={{height: 40}}
            onChangeText={(loginEmail) => this.setState({loginEmail})}
            value={this.state.loginEmail}
            placeholder='email'
            onSubmitEditing={(event) => {
              this.refs.loginPassword.focus();
            }}
          />
          <TextInput
            ref='loginPassword'
            style={{height: 40}}
            onChangeText={(loginPassword) => 
            this.setState({loginPassword})}
            value={this.state.loginPassword}
            secureTextEntry={true}
            placeholder='password'
          />
          {
            this.props.busy ?
            <ActivityIndicator/>
            :
            <Button
              onPress={this.onPressLogin.bind(this)}
              title='Login'
            />
          }
        </View>
      )
  }
}

export default LoginForm;

对于包含电子邮件的<TextInput />元素,我们设置了keyboardType='email-address'属性,以便@符号在软键盘上易于访问。还有其他选项,比如数字键盘,但我们只会在这个应用中使用'email-address'

<TextInput />的另一个有用的属性是returnKeyType。我们为那些不是最后一个的表单输入设置returnKeyType='next',以便在键盘中显示Next按钮,这样用户就知道他们可以通过点击该按钮进入下一个输入。这个属性与以下属性一起使用:

onSubmitEditing={(event) => {
  this.refs.loginPassword.focus();
}}

onSubmitEditing是一个<TextInput />属性,当用户在键盘上按下ReturnNext按钮时将被调用。我们使用它来聚焦到下一个<TextInput />,在处理表单时非常用户友好。为了获取下一个<TextInput />的引用,我们使用ref,这并不是最安全的方式,但对于简单的表单来说已经足够好了。为了使其工作,我们需要将相应的ref分配给下一个<TextInput />ref='loginPassword'

RegistrationForm是一个非常类似的表单:

/*** src/components/RegistrationForm ***/

import React, { PropTypes } from 'react'
import {
  ScrollView,
  TextInput,
  Button,
  Text,
  View,
  Image,
  ActivityIndicator
} from 'react-native';

class RegisterForm extends React.Component {
  state= {
    registerEmail: '',
    registerPassword: '',
    registerName: ''
  }

  onPressRegister() {
    this.props.onPress(this.state.registerEmail, 
    this.state.registerPassword, this.state.registerName);
  }

  render() {
    return (
      <View style={{backgroundColor: 'white', padding: 15, 
                    borderRadius: 10}}>
        {
          this.props.registeringError &&
          <View style={{backgroundColor: '#fcc', borderRadius: 5, 
            alignItems: 'center', marginBottom: 10}}>
            <Text>{this.props.registeringError}</Text>
          </View>
        }
        <TextInput
          autoCapitalize='none'
          autoCorrect={false}
          keyboardType='email-address'
          returnKeyType='next'
          style={{height: 40}}
          onChangeText={(registerEmail) => 
          this.setState({registerEmail})}
          value={this.state.registerEmail}
          placeholder='email'
          onSubmitEditing={(event) => {
            this.refs.registerName.focus();
          }}
        />
        <TextInput
          ref='registerName'
          style={{height: 40}}
          onChangeText={(registerName) => 
          this.setState({registerName})}
          returnKeyType='next'
          value={this.state.registerName}
          placeholder='name'
          onSubmitEditing={(event) => {
            this.refs.registerPassword.focus();
          }}
        />
        <TextInput
          ref='registerPassword'
          style={{height: 40}}
          onChangeText={(registerPassword) => 
          this.setState({registerPassword})}
          value={this.state.registerPassword}
          secureTextEntry={true}
          placeholder='password'
        />
        {
          this.props.busy ?
          <ActivityIndicator/>
          :
          <Button
            onPress={this.onPressRegister.bind(this)}
            title='Register'
          />
        }
      </View>
    )
  }
}

export default RegisterForm;

聊天

这是显示打开聊天列表的屏幕。这里需要注意的特殊之处是,我们使用第二个导航器在聊天列表的顶部显示选定的聊天。这意味着我们的Chats组件中需要一个StackNavigator,其中包含两个屏幕:ChatListChat。当用户从ChatList中点击一个聊天时,StackNavigator将在ChatList的顶部显示选定的聊天,通过标题栏中的标准< back按钮使聊天列表可用。

为了列出聊天,我们将使用<FlatList />,这是一个用于呈现简单、平面列表的高性能界面,支持大多数<ListView />的功能:

/*** src/screens/Chats.js ***/

import React, { PropTypes } from 'react'
import { View, Text, FlatList, ActivityIndicator } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import { StackNavigator } from 'react-navigation'
import Icon from 'react-native-vector-icons/FontAwesome'
import notifications from '../notifications'

import ListItem from '../components/ListItem'
import Chat from './Chat'

@inject('chats') @observer
class ChatList extends React.Component {
  imgPlaceholder = 
  'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
                    2160923_960_720.png'

  componentWillMount() {
    notifications.onNotification((notif)=>{
      this.props.navigation.goBack();
      this.props.navigation.navigate('Chat', {
        id: notif.chatId,
        name: notif.name || '',
        image: notif.image || this.imgPlaceholder
      })
    });
  }

  render () {
    return (
      <View>
        {
          this.props.chats.list &&
          <FlatList
            data={this.props.chats.list.toJS()}
            keyExtractor={(item, index) => item.id}
            renderItem={({item}) => {
              return (
                <ListItem
                  text={item.name}
                  image={item.image || this.imgPlaceholder}
                  onPress={() => this.props.navigation.navigate('Chat', 
                  {
                    id: item.id,
                    name: item.name,
                    image: item.image || this.imgPlaceholder,
                    contactId: item.contactId
                  })}
                />
              )
            }}
          />
        }
        {
          this.props.chats.downloadingChats &&
          <ActivityIndicator style={{marginTop: 20}}/>
        }
      </View>
    )
  }
}

const Navigator = StackNavigator({
  Chats: {
    screen: ChatList,
    navigationOptions: ({navigation}) => ({
      title: 'Chats',
    }),
  },
  Chat: {
    screen: Chat
  }
});

export default class Chats extends React.Component {
  static navigationOptions = {
    tabBarLabel: 'Chats',
    tabBarIcon: ({ tintColor }) => (
      <Icon name="comment-o" size={30} color={tintColor}/>
    )
  };

  render() {
      return <Navigator />
  }
}

我们注意到的第一件事是,我们正在注入chats存储,其中保存了聊天列表:@inject('chats') @observer。我们需要这样做来构建我们的<FlatList />,基于this.props.chats.list,但由于聊天列表是一个可观察的 MobX 对象,我们需要使用它的toJS()方法来将其转换为 JavaScript 数组。

componentWillMount()函数中,我们将在通知模块上调用onNotification,以便在用户每次按下设备上的推送通知时打开相应的聊天。因此,我们将在导航器上使用navigate()方法来打开适当的聊天屏幕,包括联系人的姓名和头像。

ListItem

聊天列表依赖于<ListItem />来呈现列表中的每个特定聊天。这个组件是我们创建的一个自定义 UI 类,用于减少ChatList组件的复杂性:

/*** src/components/ListItem.js ***/

import React, { PropTypes } from 'react'
import { View, Image, Text, TouchableOpacity } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'

const ListItem = (props) => {
  return (
    <TouchableOpacity onPress={props.onPress}>
      <View style={{height: 60, borderColor: '#ccc', 
                    borderBottomWidth: 1, 
        marginLeft: 10, flexDirection: 'row'}}>
        <View style={{padding: 15, paddingTop: 10}}>
          <Image source={{uri: props.image}} style={{width: 40, 
                                                     height: 40, 
            borderRadius: 20, resizeMode: 'cover'}}/>
        </View>
        <View style={{padding: 15, paddingTop: 20}}>
          <Text style={{fontSize: 15}}>{ props.text }</Text>
        </View>
        <Icon name="angle-right" size={20} color="#aaa" 
          style={{position: 'absolute', right: 20, top: 20}}/>
      </View>
    </TouchableOpacity>
  )
}

export default ListItem

这个组件上有很少的逻辑,它只接收一个名为onPress()的 prop,当<ListItem />被按下时将被调用,正如我们在这个组件的父组件中看到的,它将打开聊天屏幕,显示特定聊天中的消息列表。让我们来看看chat屏幕,那里渲染了特定聊天的所有消息。

Chat

为了保持我们的代码简洁和可维护,我们将使用GiftedChat来渲染聊天中的所有消息,但是我们仍然需要做一些工作来正确渲染这个屏幕:

/*** src/screens/Chat.js ***/

import React, { PropTypes } from 'react'
import { View, Image, ActivityIndicator } from 'react-native';
import { observer, inject } from 'mobx-react/native'
import { GiftedChat } from 'react-native-gifted-chat'

@inject('chats', 'users') @observer
class Chat extends React.Component {
  static navigationOptions = ({ navigation, screenProps }) => ({
    title: navigation.state.params.name,
    headerRight: <Image source={{uri: navigation.state.params.image}} 
    style={{
      width: 30,
      height: 30,
      borderRadius: 15,
      marginRight: 10,
      resizeMode: 'cover'
    }}/>
  })

  onSend(messages) {
    this.props.chats.addMessages(this.chatId, this.contactId, 
    messages);
  }

  componentWillMount() {
 this.contactId = this.props.navigation.state.params.contactId;
    this.chatId = this.props.navigation.state.params.id;
    this.props.chats.selectChat(this.chatId);
  }

  render () {
    var messages = this.props.chats.selectedChatMessages;
    if(this.props.chats.downloadingChat) {
      return <View><ActivityIndicator style={{marginTop: 20}}/></View>
    }

    return (
      <GiftedChat
        onSend={(messages) => this.onSend(messages)}
        messages={messages ? messages.toJS().reverse() : []}
        user={{
          _id: this.props.users.id,
          name: this.props.users.name,
          avatar: this.props.users.avatar
        }}
      />
    )
  }
}

export default Chat;

我们还需要为我们的<Chat />组件注入一些存储。这一次,我们需要userschats存储,它们将作为组件内的 props 可用。该组件还期望从导航器接收两个参数:chatId(聊天的 ID)和contactId(用户正在聊天的人的 ID)。

当组件准备挂载(onComponentWillMount())时,我们在组件内部保存chatIdcontactId到更方便的变量中,并在chats存储上调用selectChat()方法。这将触发一个请求到 Firebase 数据库,以获取所选聊天的消息,这些消息将通过chats存储进行同步,并通过this.props.chats.selectedChatMessages在组件中访问。MobX 还将更新一个downloadingChat属性,以确保我们让用户知道数据正在从 Firebase 中检索。

最后,我们需要为GiftedChat添加一个onSend()函数,它将在每次按下发送按钮时调用chats存储上的addMessages()方法,以将消息发布到 Firebase。

GiftedChat在很大程度上帮助我们减少了为了渲染聊天消息列表而需要做的工作。另一方面,我们需要按照GiftedChat的要求格式化消息,并提供一个onSend()函数,以便在需要将消息发布到我们的后端时执行。

搜索

搜索屏幕分为两部分:一个<TextInput />用于用户搜索姓名,一个<FlatList />用于显示输入姓名找到的联系人列表。

import React, { PropTypes } from 'react'
import { View, TextInput, Button, FlatList } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'
import { observer, inject } from 'mobx-react/native'

import ListItem from '../components/ListItem'

@inject('users', 'chats') @observer
class Search extends React.Component {
  imgPlaceholder = 'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
                   2160923_960_720.png'

  state = {
    name: '',
    foundUsers: null
  }

  static navigationOptions = {
    tabBarLabel: 'Search',
    tabBarIcon: ({ tintColor }) => (
      <Icon name="search" size={30} color={tintColor}/>
    )
  };

  onPressSearch() {
    this.props.users.searchUsers(this.state.name)
    .then((foundUsers) => {
      this.setState({ foundUsers });
    });
  }

  onPressUser(user) {
    //open a chat with the selected user
  }

  render () {
    return (
      <View>
        <View style={{padding: 20, marginTop: 20, 
                      backgroundColor: '#eee'}}>
          <View style={{backgroundColor: 'white', padding: 15, 
                        borderRadius: 10}}>
            <TextInput
              style={{borderColor: 'gray', borderBottomWidth: 1, 
                      height: 40}}
              onChangeText={(name) => this.setState({name})}
              value={this.state.name}
              placeholder='Name of user'
            />
            <Button
              onPress={this.onPressSearch.bind(this)}
              title='Search'
            />
          </View>
        </View>
        {
          this.state.foundUsers &&
          <FlatList
            data={this.state.foundUsers}
            keyExtractor={(item, index) => index}
            renderItem={({item}) => {
              return (
                <ListItem
                  text={item.name}
                  image={item.avatar || this.imgPlaceholder}
                  onPress={this.onPressUser.bind(this, item)}
                />
              )
            }}
          />
        }
      </View>
    )
  }
}

export default Search;

这个组件需要注入两个存储(userschats)。users存储用于在用户点击搜索按钮时调用searchUsers()方法。这个方法不会修改状态,因此我们需要提供一个回调来接收找到的用户列表,最终将该列表设置为组件的状态。

第二个存储chats将用于通过从onPressUser()函数调用add()在 Firebase 中存储打开的聊天:

onPressUser(user) {
  this.props.chats.add({
    id: this.props.users.id,
    name: this.props.users.name,
    avatar: this.props.users.avatar || this.imgPlaceholder,
    notificationsToken: this.props.users.notificationsToken || ''
  }, {
    id: user.id,
    name: user.name,
    avatar: user.avatar || this.imgPlaceholder,
    notificationsToken: user.notificationsToken || ''
  });

  this.props.navigation.navigate('Chats', {});
}

chats存储中的add()方法需要传递两个参数:每个用户在新打开的聊天中。这些数据将被正确存储在 Firebase 中,因此两个用户将在应用程序的聊天列表中看到聊天。添加新聊天后,我们将导航应用程序到聊天屏幕,以便用户可以看到添加是否成功。

个人资料

个人资料屏幕显示用户的头像、姓名和“注销”按钮以退出登录:

import React, { PropTypes } from 'react'
import { View, Image, Button, Text } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import Icon from 'react-native-vector-icons/FontAwesome'

import notifications from '../notifications'

@inject('users') @observer
class Profile extends React.Component {
  static navigationOptions = {
    tabBarLabel: 'Profile',
    tabBarIcon: ({ tintColor }) => (
      <Icon name="user" size={30} color={tintColor}/>
    ),
  };

  imgPlaceholder = 
  'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
                    2160923_960_720.png'

  onPressLogout() {
 this.props.users.logout();
  }

  render () {
    return (
        <View style={{ padding: 20 }}>
          {
              this.props.users.name &&
              <View style={{ flexDirection: 'row', alignItems: 'center' 
          }}>
                <Image
                  source={{uri: this.props.users.avatar || 
                  this.imgPlaceholder}}
                  style={{width: 100, height: 100, borderRadius: 50, 
                          margin: 20, resizeMode: 'cover'}}
                />
                <Text style={{fontSize: 25}}>{this.props.users.name}
               </Text>
              </View>
          }
          <Button
            onPress={this.onPressLogout.bind(this)}
            title="Logout"
          />
        </View>
    )
  }
}

export default Profile;

注销过程是通过在users存储上调用logout()方法来触发的。由于我们在src/main.js文件中控制了身份验证状态,当注销成功时,应用程序将自动返回到登录或注册屏幕。

摘要

我们涵盖了大多数现代企业应用程序的几个重要主题:用户管理、数据同步、复杂的应用程序状态和处理表单。这是一个完整的应用程序,我们设法用一个小的代码库和 MobX 和 Firebase 的帮助来修复它。

Firebase 非常有能力在生产中处理这个应用程序,拥有大量用户,但构建我们自己的后端系统不应该是一个复杂的任务,特别是如果我们有使用socket.io和实时数据库的经验。

这一章节中有一些方面是缺失的,比如处理安全性(可以完全在 Firebase 内完成),或者为超过两个用户创建聊天室。无论如何,这些方面都超出了 React Native 的环境,所以它们被有意地省略了。

完成本章后,我们应该能够在 Firebase 和 MobX 之上构建任何应用程序,因为我们涵盖了这两种技术的最常用的用户案例。当然,还有一些更复杂的情况被省略了,但通过对本章中解释的基础知识有很好的理解,它们可以很容易地学会。

在下一章中,我们将构建一种非常不同的应用程序:一个用 React Native 编写的游戏。

第七章:游戏

应用商店上大多数成功的应用都是游戏。它们被证明非常受欢迎,因为移动用户倾向于在通勤、候诊室、旅行或者在家休息时玩各种游戏。事实上,移动用户更倾向于为游戏付费,而不是市场上其他任何类型的应用,因为大多数时候其感知价值更高。

现代游戏通常是使用强大的游戏引擎构建的,如 Unity 或 Unreal,因为它们提供了各种工具和框架来处理精灵、动画或物理效果。但事实是,由于其本地功能,伟大的游戏也可以在 React Native 中构建。此外,React Native 已经将许多网络和移动应用程序程序员引入游戏开发,因为它为他们提供了熟悉和直观的界面。当然,在构建游戏时,非游戏开发人员可能需要理解一些游戏开发的概念,以充分利用该库。像精灵、滴答声或碰撞这样的概念是小障碍,非游戏开发人员在构建游戏之前可能需要克服这些障碍。

游戏将为 iOS 和 Android 构建,并将使用有限数量的外部库。选择了 Redux 作为状态管理库,以帮助计算每一帧上每个精灵的位置。

我们将使用一些自定义精灵,并添加声音效果以提醒每次得分增加。构建游戏时的一个主要挑战是确保精灵能够响应式地呈现,以便不同的设备以相同的比例显示游戏,提供不同屏幕尺寸下相同的游戏体验。

这款游戏将设计为仅支持竖屏模式。

概述

本章中我们将构建的游戏具有简单的机制:

  • 目标是帮助一只鹦鹉在洞穴中的岩石之间飞行

  • 点击屏幕将使鹦鹉飞得更高

  • 重力将把鹦鹉拉向地面

  • 鹦鹉与岩石或地面的任何碰撞都将导致游戏结束

  • 每次鹦鹉飞过一组岩石时,得分都会增加。

这种游戏非常适合使用 React Native 构建,因为它实际上不需要复杂的动画或物理能力。我们需要确保在正确的时间移动屏幕上的每个精灵(图形组件),以创建连续动画的感觉。

让我们来看看我们游戏的初始界面:

这个屏幕展示了关于如何启动游戏的标志和说明。在这种情况下,简单的点击将启动游戏机制,导致鹦鹉在每次点击时向前飞行和上升。

玩家必须帮助我们的鹦鹉飞过岩石。每次通过一组岩石,玩家将获得一个点。

为了增加难度,岩石的高度将会变化,迫使鹦鹉飞得更高或更低来穿过岩石。如果鹦鹉与岩石或地面发生碰撞,游戏将停止,并向用户呈现最终得分:

在这一点上,用户可以通过再次点击屏幕来重新开始游戏。

为了使游戏更加美观和易于玩,可以在屏幕的任何位置进行点击,这将导致不同的效果,取决于用户所在的屏幕:

  • 在初始屏幕上点击将启动游戏

  • 游戏中的点击会导致鹦鹉飞得更高

  • 在游戏结束屏幕上点击将重新开始游戏并重置得分

可以看到,这将是一个非常简单的游戏,但由于这个原因,它很容易扩展并且很有趣。在构建这种类型的应用程序时一个重要的方面是拥有一套漂亮的图形。为此,我们将从多个游戏资产市场之一下载我们的资产,这些市场可以在线找到(大多数游戏资产需要支付一小笔费用,尽管偶尔也可以找到免费资产)。

这个游戏的技术挑战更多地在于精灵如何随时间移动,而不是复杂的状态维护。尽管如此,我们将使用 Redux 来保持和更新应用程序的状态,因为它是一个高效且广为人知的解决方案。除了重新审视 Redux,我们还将在本章中回顾以下主题:

  • 处理动画精灵

  • 播放音效

  • 检测碰撞的精灵

  • 在不同的屏幕分辨率下进行绝对定位

精灵

精灵是游戏中使用的图形,通常分组到一个或多个图像中。许多游戏引擎包括工具来方便地拆分和管理这些图形,但在 React Native 中并非如此。由于它是设计用来处理不同类型的应用程序的,有几个库支持 React Native 处理精灵的任务,但我们的游戏将足够简单,不需要使用这些库,因此我们将把一个图形存储在每个图像中,并将它们分别加载到应用程序中。

在开始构建游戏之前,让我们熟悉一下我们将加载的图形,因为它们将是整个应用程序的构建模块。

数字

在我们的游戏中,我们将使用精灵而不是<Text/>组件来显示分数,以获得更吸引人的外观。这些是我们将用来表示用户分数的图像:

正如前面提到的,所有这些图形都将存储在单独的图像中(命名为0.png9.png),因为 React Native 缺乏精灵拆分功能。

背景

我们需要一个大背景来确保它适合所有屏幕尺寸。在本章中,我们将使用这个精灵作为静态图形,尽管它可以很容易地进行动画处理,以创建一个漂亮的视差效果:

从这个背景中,我们将取一块地面来进行动画。

地面

地面将在循环中进行动画,以创建恒定的速度感。这个图像的大小需要大于我们想要支持的最大屏幕分辨率,因为它应该从屏幕的一侧移动到另一侧。在任何时候,将显示两个地面图像,一个接一个地显示,以确保在动画期间屏幕上至少显示一个地面图像:

岩石

移动的岩石是我们的鹦鹉需要通过的障碍物。顶部和底部各有一个,它们将以与地面相同的速度进行动画。它们的高度将因每对岩石而异,但始终保持它们之间的间隙大小相同:

在我们的images文件夹中,我们将有rock-up.pngrock-down.png代表每个精灵。

鹦鹉

我们将使用两个不同的图像来表示我们的主角,这样我们就可以创建一个动画,显示用户何时点击了屏幕:

当鹦鹉向下移动时将显示第一个图像:

每当用户按下屏幕将鹦鹉向上移动时,将显示第二个图像。这些图像将被命名为parrot1.pngparrot2.png

主屏幕

对于主屏幕,我们将显示两个图像:一个标志和一些关于如何启动游戏的说明。让我们来看看它们:

开始游戏的说明只是指出轻触将启动游戏:

游戏结束屏幕

当鹦鹉撞到岩石或地面时,游戏将结束。然后,是时候显示游戏结束标志和重置按钮,以便重新开始游戏:

虽然整个屏幕都可以触摸以重新启动游戏,但我们将包括一个按钮,让用户知道轻触将导致游戏重新开始:

此图像将存储为reset.png

这是我们游戏中将拥有的所有图像的完整列表:

现在,我们知道了我们游戏中将使用的图像列表。让我们来看看整个文件夹结构。

设置文件夹结构

让我们使用 React Native 的 CLI 初始化一个 React Native 项目。 该项目将被命名为birdGame,并将适用于 iOS 和 Android 设备:

react-native init --version="0.46.4" birdGame

由于这是一个简单的游戏,我们只需要一个屏幕,我们将在其中根据游戏的状态定位所有我们的精灵移动,显示或隐藏它们,这将由 Redux 管理。因此,我们的文件夹结构将符合标准的 Redux 应用程序:

actions文件夹将只包含一个文件,因为在这个游戏中可能发生的只有三个动作(starttickbounce)。还有一个sounds文件夹,用于存储每次鹦鹉通过一对岩石时播放的音效:

对于每个精灵,我们将创建一个组件,以便可以轻松地移动,显示或隐藏它:

同样,只需要一个 reducer 来处理所有我们的动作。我们还将创建两个辅助文件:

  • constants.js:这是我们将存储用于将屏幕高度和宽度分割为设备播放游戏的辅助变量的地方

  • sprites.js:这里存储了所有将计算每帧中精灵应该定位的函数,以创建所需的动画

main.js将作为 iOS 和 Android 的入口点,并负责初始化 Redux:

其余文件由 React Native 的 CLI 生成。

现在让我们来审查一下package.json文件,我们需要在项目中设置依赖项:

/*** package.json ***/

{
  "name": "birdGame",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest"
  },
  "dependencies": {
    "react": "16.0.0-alpha.12",
    "react-native": "0.46.4",
    "react-native-sound": "⁰.10.3",
    "react-redux": "⁴.4.5",
    "redux": "³.5.2"
  },
  "devDependencies": {
    "babel-jest": "20.0.3",
    "babel-preset-react-native": "2.1.0",
    "jest": "20.0.4",
    "react-test-renderer": "16.0.0-alpha.12"
  },
  "jest": {
    "preset": "react-native"
  }
}

除了 Redux 库,我们还将导入react-native-sound,它将负责在我们的游戏中播放任何声音。

运行npm install后,我们的应用程序将准备好开始编码。与以前的应用程序一样,我们的消息应用程序的入口点将在index.ios.js(iOS)和index.android.js(Android)中是相同的代码,但两者都将把初始化逻辑委托给src/main.js

/*** index.ios.js and index.android.js ***/ 

import { AppRegistry } from 'react-native';
import App from './src/main';

AppRegistry.registerComponent('birdGame', () => App);

src/main.js负责初始化 Redux,并将GameContainer设置为应用程序中的根组件:

/*** src/main.js ***/

import React from "react";
import { createStore, combineReducers } from "redux";
import { Provider } from "react-redux";

import gameReducer from "./reducers/game";
import GameContainer from "./components/GameContainer";

let store = createStore(combineReducers({ gameReducer }));

export default class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <GameContainer />
      </Provider>
    );
  }
}

我们将GameContainer用作应用程序中组件树的根。作为一个常规的 Redux 应用程序,<Provider />组件负责向所有需要读取或修改应用程序状态的组件提供存储。

GameContainer

GameContainer负责在用户点击屏幕时启动游戏。它将使用requestAnimationFrame()来实现这一点——这是 React Native 中实现的自定义定时器之一。

requestAnimationFrame()类似于setTimeout(),但前者将在所有帧刷新后触发,而后者将尽可能快地触发(在 iPhone 5S 上每秒超过 1000 次);因此,requestAnimationFrame()更适合处理动画游戏,因为它只处理帧。

与大多数动画游戏一样,我们需要创建一个循环来计算屏幕上精灵的下一个位置,以便在每一帧中创建所需的动画。这个循环将由GameContainer内部的一个名为nextFrame()的函数创建:

nextFrame() {
    if (this.props.gameOver) return;
    var elapsedTime = new Date() - this.time;
    this.time = new Date();
    this.props.tick(elapsedTime);
    this.animationFrameId = 
      requestAnimationFrame(this.nextFrame.bind(this));
}

如果属性gameOver设置为true,则此函数将被中止。否则,它将触发tick()动作(根据经过的时间计算精灵应该在下一帧上移动的位置),最后通过requestAnimationFrame()调用自身。这将保持游戏中的循环以动画方式移动精灵。

当然,这个nextFrame()应该在开始时被调用,所以我们还将在GameContainer内创建一个start()函数来启动游戏:

start() {
    cancelAnimationFrame(this.animationFrameId);
    this.props.start();
    this.props.bounce();
    this.time = new Date();
    this.setState({ gameOver: false });
    this.animationFrameId = 
      requestAnimationFrame(this.nextFrame.bind(this));
}

start函数通过调用cancelAnimationFrame()确保没有启动任何动画。这将防止用户重置游戏时执行任何双重动画。

然后,这些函数触发start()动作,它只是在存储中设置一个标志,以通知游戏已经开始。

我们希望通过将鹦鹉向上移动来开始游戏,以便用户有时间做出反应。为此,我们还调用bounce()动作。

最后,我们通过将已知的nextFrame()函数作为requestAnimationFrame()的回调来启动动画循环。

让我们也来审查一下我们将用于这个容器的render()方法:

render() {
    const {
      rockUp,
      rockDown,
      ground,
      ground2,
      parrot,
      isStarted,
      gameOver,
      bounce,
      score
    } = this.props;

    return (
      <TouchableOpacity
        onPress={
 !isStarted || gameOver ? this.start.bind(this) : 
            bounce.bind(this)
 }
        style={styles.screen}
        activeOpacity={1}
      >
        <Image
          source={require("../../images/bg.png")}
          style={[styles.screen, styles.image]}
        />
        <RockUp
          x={rockUp.position.x * W} //W is a responsiveness factor 
                                    //explained in the 'constants' section
          y={rockUp.position.y}
          height={rockUp.size.height}
          width={rockUp.size.width}
        />
        <Ground
          x={ground.position.x * W}
          y={ground.position.y}
          height={ground.size.height}
          width={ground.size.width}
        />
        <Ground
          x={ground2.position.x * W}
          y={ground2.position.y}
          height={ground2.size.height}
          width={ground2.size.width}
        />
        <RockDown
          x={rockDown.position.x * W}
          y={rockDown.position.y * H} //H is a responsiveness factor  
                                      //explained in the 'constants' 
                                      //section
          height={rockDown.size.height}
          width={rockDown.size.width}
        />
        <Parrot
          x={parrot.position.x * W}
          y={parrot.position.y * H}
          height={parrot.size.height}
          width={parrot.size.width}
        />
        <Score score={score} />
        {!isStarted && <Start />}
        {gameOver && <GameOver />}
        {gameOver && isStarted && <StartAgain />}
      </TouchableOpacity>
    );
  }

可能会有些冗长,但实际上只是将所有可见元素在屏幕上进行简单的定位,同时将它们包裹在<TouchableOpacity />组件中,以便捕捉用户在屏幕的任何部分的点击。这个<TouchableOpacity />组件实际上在用户点击屏幕时不会向用户发送任何反馈(我们通过传递activeOpacity={1}作为属性来禁用它),因为鹦鹉在每次点击时已经提供了这种反馈。

我们本可以使用 React Native 的<TouchableWithoutFeedback />来处理这个问题,但它有一些限制,可能会影响我们的性能。

提供的onPress属性只是定义了用户在屏幕上点击时应用程序应该执行的操作:

  • 如果游戏处于活动状态,它将使鹦鹉精灵弹跳

  • 如果用户在游戏结束画面,将通过调用start()动作重新开始游戏

render()方法中的所有其他子元素都是我们游戏中的图形元素,为它们指定位置和大小。还有几点需要注意:

  • 有两个<Ground />组件,因为我们需要在x轴上不断地对其进行动画处理。它们将水平排列在一起,以便一起进行动画处理,因此当第一个<Ground />组件的末端显示在屏幕上时,第二个的开头将跟随其后,从而产生连续感。

  • 背景不包含在任何自定义组件中,而是包含在<Image />中。这是因为作为静态元素,它不需要任何特殊的逻辑。

  • 一些位置被因子变量(WH)相乘。我们将在常量部分更深入地研究这些变量。在这一点上,我们只需要知道它们是帮助绝对定位元素的变量,考虑到所有屏幕尺寸。

现在让我们将所有这些函数组合起来构建我们的<GameContainer />

/*** src/components/GameContainer.js ***/

import React, { Component } from "react";
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import { TouchableOpacity, Image, StyleSheet } from "react-native";

import * as Actions from "../actions";
import { W, H } from "../constants";
import Parrot from "./Parrot";
import Ground from "./Ground";
import RockUp from "./RockUp";
import RockDown from "./RockDown";
import Score from "./Score";
import Start from "./Start";
import StartAgain from "./StartAgain";
import GameOver from "./GameOver";

class Game extends Component {
  constructor() {
    super();
    this.animationFrameId = null;
    this.time = new Date();
  }

  nextFrame() {
     ...
  }

  start() {
     ...
  }

  componentWillUpdate(nextProps, nextState) {
    if (nextProps.gameOver) {
      this.setState({ gameOver: true });
      cancelAnimationFrame(this.animationFrameId);
    }
  }

  shouldComponentUpdate(nextProps, nextState) {
    return !nextState.gameOver;
  }

  render() {

     ...

  }
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    alignSelf: "stretch",
    width: null
  },
  image: {
    resizeMode: "cover"
  }
});

function mapStateToProps(state) {
  const sprites = state.gameReducer.sprites;
  return {
    parrot: sprites[0],
 rockUp: sprites[1],
 rockDown: sprites[2],
 gap: sprites[3],
 ground: sprites[4],
 ground2: sprites[5],
 score: state.gameReducer.score,
 gameOver: state.gameReducer.gameOver,
 isStarted: state.gameReducer.isStarted
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(Actions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(Game);

我们在这个组件中添加了三个 ES6 和 React 生命周期方法:

  • super(): 构造函数将保存一个名为animationFrameId的属性,以捕获nextFrame函数将运行的动画帧的 ID,还将保存另一个名为time的属性,该属性将存储游戏初始化的确切时间。这个time属性将被tick()函数用来计算精灵应该移动多少。

  • componentWillUpdate(): 每当传递新的 props(游戏中精灵的位置和大小)时,将调用此函数。它将检测游戏是否因碰撞而必须停止,因此游戏结束屏幕将被显示。

  • shouldComponentUpdate(): 如果游戏已经结束,这将执行另一个检查以避免重新渲染游戏容器。

其余的函数与 Redux 相关。它们负责通过注入操作和属性将组件连接到存储中:

  • mapStateToProps(): 这会获取存储中所有精灵的数据,并将它们注入到组件中作为 props。精灵将被存储在一个数组中,因此它们将通过索引访问。除此之外,Score,标记当前游戏是否结束的标志,以及标记游戏是否正在进行的标志也将从状态中检索并注入到组件中。

  • mapStateActionsToProps(): 这将把三个可用的操作(tickbouncestart)注入到组件中,以便它们可以被使用。

通过索引访问精灵数据并不是一种推荐的做法,因为如果精灵数量增加,索引可能会改变,但出于简单起见,我们将在此应用程序中这样使用。

操作

正如我们之前提到的,只有三个 Redux 操作可用:

  • tick(): 计算屏幕上精灵的下一个位置

  • bounce(): 让鹦鹉飞起来

  • start(): 初始化游戏变量

这意味着我们的src/actions/index.js文件应该非常简单:

/*** src/actions/index.js ***/

export function start() {
  return { type: "START" };
}

export function tick(elapsedTime) {
  return { type: "TICK", elapsedTime };
}

export function bounce() {
  return { type: "BOUNCE" };
}

只有tick()操作需要传递一个有效负载:自上一帧以来经过的时间。

Reducer

由于我们的动作非常有限,我们的 reducer 也会非常简单,并且将大部分功能委托给src/sprites.js文件中的精灵辅助函数:

/*** src/reducers/index.js ***/

import {
  sprites,
  moveSprites,
  checkForCollision,
  getUpdatedScore,
  bounceParrot
} from "../sprites";

const initialState = {
  score: 0,
  gameOver: false,
  isStarted: false,
  sprites
};

export default (state = initialState, action) => {
  switch (action.type) {
    case "TICK":
      return {
        ...state,
        sprites: moveSprites(state.sprites, action.elapsedTime),
        gameOver: checkForCollision(state.sprites[0], 
        state.sprites.slice(1)),
        score: getUpdatedScore(state.sprites, state.score)
      };
    case "BOUNCE":
      return {
        ...state,
        sprites: bounceParrot(state.sprites)
      };
    case "START":
      return {
        ...initialState,
        isStarted: true
      };
    default:
      return state;
  }
};

start()函数只需要将isStarted标志设置为true,因为初始状态默认为false。我们将在每次游戏结束时重用这个初始状态。

bounce()将使用精灵模块中的bounceParrot()函数来为主角设置新的方向。

最重要的变化将发生在tick()函数被触发时,因为它需要计算所有移动元素的位置(通过moveSprites()函数),检测鹦鹉是否与任何静态元素发生碰撞(通过checkForCollision()函数),并在存储中更新得分(通过getUpdatedScore()函数)。

正如我们所看到的,大部分游戏功能都委托给了精灵模块内的辅助函数,因此让我们深入了解一下src/sprites.js文件。

精灵模块

精灵模块的结构由一个精灵数组和几个导出函数组成:

/*** src/sprites.js ***/

import sound from "react-native-sound";

const coinSound = new sound("coin.wav", sound.MAIN_BUNDLE);
let heightOfRockUp = 25;
let heightOfRockDown = 25;
let heightOfGap = 30;
let heightOfGround = 20;

export const sprites = [
   ...
];

function prepareNewRockSizes() {
  ...
}

function getRockProps(type) {
  ...
}

export function moveSprites(sprites, elapsedTime = 1000 / 60) {
  ...
}

export function bounceParrot(sprites) {
  ...
}

function hasCollided(mainSprite, sprite) {
  ...
}

export function checkForCollision(mainSprite, sprites) {
  ...
}

export function getUpdatedScore(sprites, score) {
  ...
}

该模块首先加载我们在鹦鹉通过一组岩石时将播放的音效,以向用户反馈其得分增加。

然后,我们为几个精灵定义一些高度:

  • heightOfRockUp:这是将出现在屏幕上部的岩石的高度。

  • heightOfRockDown:这是将显示在屏幕下部的岩石的高度。

  • heightOfGap:我们将在上下岩石之间创建一个不可见的视图,以便检测鹦鹉何时通过每组岩石,从而更新得分。这是缝隙的高度。

  • heightOfGround:这是地面的高度的静态值。

此模块中的每个其他项目都在屏幕上移动或定位精灵。

精灵数组

这是负责在给定时间存储所有精灵位置和大小的数组。为什么我们使用数组来存储精灵而不是哈希映射(对象)?主要是为了可扩展性;虽然哈希映射会使我们的代码更易读,但如果我们想要添加现有类型的新精灵(就像在这个应用程序中的ground精灵一样),我们需要为它们每个使用人工键,尽管它们是相同类型的。使用精灵数组是游戏开发中的一种常见模式,它允许将实现与精灵列表解耦。

每当我们想要移动一个精灵时,我们将在这个数组中更新它的位置:

export const sprites = [
  {
    type: "parrot",
    position: { x: 50, y: 55 },
    velocity: { x: 0, y: 0 },
    size: { width: 10, height: 8 }
  },
  {
    type: "rockUp",
    position: { x: 110, y: 0 },
    velocity: { x: -1, y: 0 },
    size: { width: 15, height: heightOfRockUp }
  },
  {
    type: "rockDown",
    position: { x: 110, y: heightOfRockUp + 30 },
    velocity: { x: -1, y: 0 },
    size: { width: 15, height: heightOfRockDown }
  },
  {
    type: "gap",
    position: { x: 110, y: heightOfRockUp },
    velocity: { x: -1, y: 0 },
    size: { width: 15, height: 30 }
  },
  {
    type: "ground",
    position: { x: 0, y: 80 },
    velocity: { x: -1, y: 0 },
    size: { width: 100, height: heightOfGround }
  },
  {
    type: "ground",
    position: { x: 100, y: 80 },
    velocity: { x: -1, y: 0 },
    size: { width: 100, height: heightOfGround }
  }
];

数组将存储游戏中所有移动精灵的定位和大小的初始值。

prepareNewRockSizes()

此函数随机计算下一个上部和下部岩石的大小以及它们之间间隙的高度:

function prepareNewRockSizes() {
  heightOfRockUp = 10 + Math.floor(Math.random() * 40);
  heightOfRockDown = 50 - heightOfRockUp;
  heightOfGap = 30;
}

重要的是要注意,此函数仅计算新一组岩石的高度,但不创建它们。这只是一个准备步骤。

getRockProps()

格式化岩石(或间隙)的位置大小属性的辅助函数:

function getRockProps(type) {
  switch (type) {
    case "rockUp":
      return { y: 0, height: heightOfRockUp };
    case "rockDown":
      return { y: heightOfRockUp + heightOfGap, 
               height: heightOfRockDown };
    case "gap":
      return { y: heightOfRockUp, height: heightOfGap };
  }
}

moveSprites()

这是主要函数,因为它计算了存储在精灵数组中的每个精灵的新位置。游戏开发依赖于物理学来计算每个帧中每个精灵的位置。

例如,如果我们想要将一个对象移动到屏幕的右侧,我们将需要更新它的x位置一定数量的像素。我们为下一帧添加到对象的x属性的像素越多,它就移动得越快(sprite.x = sprite.x + 5;sprite.x = sprite.x + 1;移动得快五倍)。

正如我们在下面的例子中所看到的,我们计算每个精灵的新位置的方式基于三个因素:精灵的当前位置,自上一帧以来经过的时间(elapsedTime),以及精灵的重力/速度(例如,sprite.velocity.y + elapsedTime * gravity)。

此外,我们将使用辅助函数getRockProps来获取岩石的新大小和位置。让我们看一下moveSprites函数是什么样子的:

export function moveSprites(sprites, elapsedTime = 1000 / 60) {
  const gravity = 0.0001;
  let newSprites = [];

  sprites.forEach(sprite => {
    if (sprite.type === "parrot") {
      var newParrot = {
        ...sprite,
        position: {
          x: sprite.position.x,
          y:
            sprite.position.y +
            sprite.velocity.y * elapsedTime +
            0.5 * gravity * elapsedTime * elapsedTime
        },
        velocity: {
          x: sprite.velocity.x,
          y: sprite.velocity.y + elapsedTime * gravity
        }
      };
      newSprites.push(newParrot);
    } else if (
      sprite.type === "rockUp" ||
      sprite.type === "rockDown" ||
      sprite.type === "gap"
    ) {
      let rockPosition,
        rockSize = sprite.size;
      if (sprite.position.x > 0 - sprite.size.width) {
        rockPosition = {
          x: sprite.position.x + sprite.velocity.x,
          y: sprite.position.y
        };
      } else {
        rockPosition = { x: 100, y: getRockProps(sprite.type).y };
        rockSize = { width: 15, 
                     height: getRockProps(sprite.type).height };
      }
      var newRock = {
        ...sprite,
        position: rockPosition,
        size: rockSize
      };
      newSprites.push(newRock);
    } else if (sprite.type === "ground") {
      let groundPosition;
      if (sprite.position.x > -97) {
        groundPosition = { x: sprite.position.x + sprite.velocity.x,
                           y: 80 };
      } else {
        groundPosition = { x: 100, y: 80 };
      }
      var newGround = { ...sprite, position: groundPosition };
      newSprites.push(newGround);
    }
  });
  return newSprites;
}

计算精灵的下一个位置,大多数情况下是基本的加法(或减法)。让我们以鹦鹉应该如何移动为例:

var newParrot = {
        ...sprite,
        position: {
          x: sprite.position.x,
          y:
            sprite.position.y +
            sprite.velocity.y * elapsedTime +
            0.5 * gravity * elapsedTime * elapsedTime
        },
        velocity: {
          x: sprite.velocity.x,
          y: sprite.velocity.y + elapsedTime * gravity
        }
     }

鹦鹉只会垂直移动,其速度基于重力,因此x属性将始终保持固定,而y属性将根据函数sprite.position.y + sprite.velocity.y * elapsedTime + 0.5 * gravity * elapsedTime * elapsedTime进行更改,该函数总结了经过的时间和重力的不同因素。

岩石的移动计算要复杂一些,因为我们需要考虑每次岩石从屏幕上消失时(if (sprite.position.x > 0 - sprite.size.width))。因为它们已经通过,我们需要以不同的高度重新创建它们(rockPosition = { x: 100, y: getRockProps(sprite.type).y })。

地面的行为与之相同,需要在完全离开屏幕时重新创建它(if (sprite.position.x > -97))。

bounceParrot()

该功能的唯一任务是改变主角的速度,使其向上飞行,逆转重力的影响。每当用户在游戏开始时点击屏幕时,将调用此功能:

export function bounceParrot(sprites) {
  var newSprites = [];
  var sprite = sprites[0];
  var newParrot = { ...sprite, velocity: { x: sprite.velocity.x,
                    y: -0.05 } };
  newSprites.push(newParrot);
  return newSprites.concat(sprites.slice(1));
}

这是一个简单的操作,我们从sprites数组中获取鹦鹉的精灵数据;我们将其在y轴上的速度更改为负值,以使鹦鹉向上移动。

checkForCollision()

checkForCollision()负责识别任何刚性精灵是否与鹦鹉精灵发生碰撞,以便停止游戏。它将使用hasCollided()作为支持函数来执行对每个特定精灵所需的计算:

function hasCollided(mainSprite, sprite) {
  /*** 
   *** we will check if 'mainSprite' has entered in the
   *** space occupied by 'sprite' by comparing their
   *** position, width and height 
   ***/

  var mainX = mainSprite.position.x;
  var mainY = mainSprite.position.y;
  var mainWidth = mainSprite.size.width;
  var mainHeight = mainSprite.size.height;

  var spriteX = sprite.position.x;
  var spriteY = sprite.position.y;
  var spriteWidth = sprite.size.width;
  var spriteHeight = sprite.size.height;

  /*** 
   *** this if statement checks if any border of mainSprite
   *** sits within the area covered by sprite 
   ***/

  if (
    mainX < spriteX + spriteWidth &&
    mainX + mainWidth > spriteX &&
    mainY < spriteY + spriteHeight &&
    mainHeight + mainY > spriteY
  ) {
    return true;
  }
}

export function checkForCollision(mainSprite, sprites) {
  /*** 
   *** loop through all sprites in the sprites array
   *** checking, for each of them, if there is a
   *** collision with the mainSprite (parrot)
   ***/

  return sprites.filter(sprite => sprite.type !== "gap").find(sprite => {
    return hasCollided(mainSprite, sprite);
  });
}

为简单起见,我们假设所有精灵都具有矩形形状(尽管岩石朝末端变得更薄),因为如果考虑不同的形状,计算将会更加复杂。

总之,checkForCollision()只是循环遍历sprites数组,以查找任何发生碰撞的精灵,hasCollided()根据精灵的大小和位置检查碰撞。在一个if语句中,我们比较精灵和鹦鹉精灵的边界,以查看是否有任何边界占据了屏幕的相同区域。

getUpdatedScore()

精灵模块中的最后一个功能将检查得分是否需要根据鹦鹉位置相对于间隙位置(上下岩石之间的间隙也被视为一个精灵)进行更新:

export function getUpdatedScore(sprites, score) {
  var parrot = sprites[0];
  var gap = sprites[3];

  var parrotXPostion = parrot.position.x;
  var gapXPosition = gap.position.x;
  var gapWidth = gap.size.width;

  if (parrotXPostion === gapXPosition + gapWidth) {
    coinSound.play();
    score++;
    prepareNewRockSizes();
  }

  return score;
}

一个if语句检查鹦鹉在x轴上的位置是否超过了间隙(gapXPosition + gapWidth)。当这种情况发生时,我们通过调用其play()方法来播放模块头部中创建的声音(const coinSound = new sound("coin.wav", sound.MAIN_BUNDLE);)。此外,我们将增加score变量,并在当前的石头离开屏幕时准备渲染新的石头组。

常量

我们已经看到了变量WH。它们代表屏幕的一部分,如果我们将其分成 100 部分。让我们看一下constants.js文件,以更好地理解这一点:

/*** src/constants.js ***/

import { Dimensions } from "react-native";

var { width, height } = Dimensions.get("window");

export const W = width / 100;
export const H = height / 100;

W可以通过将设备屏幕的总宽度除以100单位来计算(因为百分比在定位我们的精灵时更容易推理)。H也是如此;它可以通过将总高度除以100来计算。使用这两个常量,我们可以相对于屏幕的大小来定位和调整精灵的大小,因此所有屏幕尺寸将显示相同的位置和大小比例。

这些常量将用于所有需要响应能力的视觉组件,因此它们将根据屏幕大小显示和移动不同。这种技术将确保即使在小屏幕上,游戏也是可玩的,因为精灵将相应地调整大小。

现在让我们继续讨论将显示在<GameContainer />内的组件。

鹦鹉

主角将由这个组件表示,它将由<GameContainer />传递的Y位置属性驱动的两个不同的图像(翅膀上下的相同鹦鹉)组成:

/*** src/components/parrot.js ***/

import React from "react";
import { Image } from "react-native";
import { W, H } from "../constants";

export default class Parrot extends React.Component {
  constructor() {
    super();
    this.state = { wings: "down" };
  }

  componentWillUpdate(nextProps, nextState) {
    if (this.props.y < nextProps.y) {
      this.setState({ wings: "up" });
    } else if (this.props.y > nextProps.y) {
      this.setState({ wings: "down" });
    }
  }

  render() {
    let parrotImage;
    if (this.state.wings === "up") {
      parrotImage = require("../../images/parrot1.png");
    } else {
      parrotImage = require("../../images/parrot2.png");
    }
    return (
      <Image
        source={parrotImage}
        style={{
          position: "absolute",
          resizeMode: "contain",
          left: this.props.x,
          top: this.props.y,
 width: 12 * W,
 height: 12 * W
        }}
      />
    );
  }
}

我们使用一个名为wings的状态变量来选择鹦鹉将会是哪个图像--当它飞行时,翅膀向下的图像将被显示,而向上飞行时将显示翅膀向上的图像。这将根据从容器传递的鸟在y轴上的位置来计算:

  • 如果Y位置低于先前的Y位置意味着鸟正在下降,因此翅膀应该向上

  • 如果Y位置高于先前的Y位置意味着鸟正在上升,因此翅膀应该向下

鹦鹉的大小固定为12 * W,对于heightwidth都是如此,因为精灵是一个正方形,我们希望它相对于每个屏幕设备的宽度进行调整大小。

上升和下降

岩石的精灵上没有逻辑,基本上是由父组件定位和调整大小的<Image />组件。这是<RockUp />的代码:

/*** src/components/RockUp.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class RockUp extends Component {
  render() {
    return (
      <Image
        resizeMode="stretch"
        source={require("../../images/rock-down.png")}
        style={{
          position: "absolute",
          left: this.props.x,
          top: this.props.y,
          width: this.props.width * W,
          height: this.props.height * H
        }}
      />
    );
  }
}

高度和宽度将由以下公式计算:this.props.width * Wthis.props.height * H。这将使岩石相对于设备屏幕和提供的高度和宽度进行调整。

<RockDown />的代码非常相似:

/*** src/components/RockDown.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class RockDown extends Component {
  render() {
    return (
      <Image
        resizeMode="stretch"
        source={require("../../images/rock-up.png")}
        style={{
          position: "absolute",
          left: this.props.x,
          top: this.props.y,
          width: this.props.width * W,
          height: this.props.height * H
        }}
      />
    );
  }
}

地面

构建地面组件类似于岩石精灵。在适当的位置和大小上渲染的图像将足以满足此组件的需求:

/*** src/components/Ground.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class Ground extends Component {
  render() {
    return (
      <Image
        resizeMode="stretch"
        source={require("../../images/ground.png")}
        style={{
          position: "absolute",
          left: this.props.x,
          top: this.props.y * H,
          width: this.props.width * W,
          height: this.props.height * H
        }}
      />
    );
  }
}

在这种情况下,我们将使用H来相对定位地面图像。

得分

我们决定使用数字图像来渲染分数,因此我们需要加载它们并根据用户的分数选择适当的数字:

/*** src/components/Score.js ***/

import React, { Component } from "react";
import { View, Image } from "react-native";

import { W, H } from "../constants";

export default class Score extends Component {
  getSource(num) {
    switch (num) {
      case "0":
        return require("../../images/0.png");
      case "1":
        return require("../../images/1.png");
      case "2":
        return require("../../images/2.png");
      case "3":
        return require("../../images/3.png");
      case "4":
        return require("../../images/4.png");
      case "5":
        return require("../../images/5.png");
      case "6":
        return require("../../images/6.png");
      case "7":
        return require("../../images/7.png");
      case "8":
        return require("../../images/8.png");
      case "9":
        return require("../../images/9.png");
      default:
        return require("../../images/0.png");
    }
  }

  render() {
    var scoreString = this.props.score.toString();
    var scoreArray = [];
    for (var index = 0; index < scoreString.length; index++) {
      scoreArray.push(scoreString[index]);
    }

    return (
      <View
        style={{
          position: "absolute",
          left: 47 * W,
          top: 10 * H,
          flexDirection: "row"
        }}
      >
        {scoreArray.map(
          function(item, i) {
            return (
              <Image
                style={{ width: 10 * W }}
                key={i}
                resizeMode="contain"
                source={this.getSource(item)}
              />
            );
          }.bind(this)
        )}
      </View>
    );
  }
}

我们在render方法中做以下操作:

  • 将分数转换为字符串

  • 将字符串转换为数字列表

  • 使用支持的getSource()函数将数字列表转换为图像列表

React Native <Image />中的一个限制是其源不能作为变量被要求。因此,我们使用这个小技巧从我们的getSource()方法中检索源,该方法实际上获取所有可能的图像并通过switch/case子句返回正确的图像。

开始

开始屏幕包括两个图像:

  • 一个标志

  • 一个开始按钮,解释如何启动游戏(在屏幕上任何地方轻触)

/*** src/components/Start.js ***/

import React, { Component } from "react";
import { Text, View, StyleSheet, Image } from "react-native";

import { W, H } from "../constants";

export default class Start extends Component {
  render() {
    return (
      <View style={{ position: "absolute", left: 20 * W, top: 3 * H }}>
        <Image
          resizeMode="contain"
          source={require("../../images/logo.png")}
          style={{ width: 60 * W }}
        />
        <Image
          resizeMode="contain"
          style={{ marginTop: 15, width: 60 * W }}
          source={require("../../images/tap.png")}
        />
      </View>
    );
  }
}

我们再次使用我们的HW常量来确保元素在每个设备屏幕上的位置正确。

游戏结束

当鹦鹉与岩石或地面发生碰撞时,我们应该显示游戏结束屏幕。这个屏幕只包含两个图像:

  • 游戏结束标志

  • 重新开始游戏的按钮

让我们首先看一下游戏结束标志:

/*** src/components/GameOver.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class GameOver extends Component {
  render() {
    return (
      <Image
        style={{
          position: "absolute",
          left: 15 * W,
          top: 30 * H
        }}
        resizeMode="stretch"
        source={require("../../images/game-over.png")}
      />
    );
  }
}

现在,让我们继续重置游戏按钮。

重新开始

实际上,重置按钮只是一个标志,因为用户不仅可以在按钮上轻触,还可以在屏幕上的任何地方开始游戏。无论如何,我们将使用HW常量在每个屏幕上正确定位此按钮:

/*** src/components/StartAgain.js ***/

import React, { Component } from "react";
import { Text, View, StyleSheet, TouchableOpacity, Image } 
from "react-native";

import { W, H } from "../constants";

export default class StartAgain extends Component {
  render() {
    return (
      <Image
        style={{ position: "absolute", left: 35 * W, top: 40 * H }}
        resizeMode="contain"
        source={require("../../images/reset.png")}
      />
    );
  }
}

摘要

游戏是一种非常特殊的应用程序。它们基于根据时间和用户交互在屏幕上显示和移动精灵。这就是为什么我们在本章大部分时间都在解释如何以最高效的方式轻松显示所有图像以及如何定位和调整它们的大小。

我们还回顾了一种常见的技巧,相对于设备屏幕的高度和宽度来定位和调整精灵的大小。

尽管 Redux 并非专门为游戏设计,但我们在应用程序中使用 Redux 来存储和分发精灵的数据。

在一般水平上,我们证明了 React Native 可以用来构建高性能的游戏,尽管它缺乏游戏特定的工具,但我们可以生成非常易读的代码,这意味着它应该很容易扩展和维护。事实上,在这个阶段可以创建一些非常简单的扩展,使游戏更有趣和可玩性:在通过一定数量的障碍物后增加速度,减少或增加间隙大小,在屏幕上显示多组岩石等。

下一章将回顾更常规类型应用程序的蓝图:电子商务应用程序。

第八章:电子商务应用

在线购物是大多数零售商采用的一种方式,但用户正在从网站慢慢迁移到移动应用。这就是为什么电子商务对响应式网站进行了强调,这些网站可以从台式电脑或移动浏览器无缝访问。除此之外,用户还要求更高的质量标准,这些标准甚至响应最高的网站也不能总是满足。加载时间长、动画卡顿、非本地组件或缺乏本地功能可能会影响用户体验,导致低转化率。

在 React Native 中构建我们的电子商务应用可以减少开发工作量,因为可以重用一些已经为 Web 设计的 Web 组件(使用 React.js)。除此之外,我们可以减少上市时间和开发成本,使 React Native 成为中小型企业愿意在线销售其产品或服务的非常有吸引力的工具。

在本章中,我们将重点关注构建一个书店,用于 iOS 和 Android,重复使用我们 100%的代码。尽管专注于书店,但同一代码库可以通过替换产品列表来重复使用以销售任何类型的产品。

为了使我们摆脱为此应用程序构建 API 的负担,我们将模拟所有数据在一个虚假的 API 服务后面。我们将为此应用程序使用 Redux 及其中间件redux-thunk来处理异步调用的状态管理库。

异步调用和 redux-thunk 已经在第四章 图像分享应用中进行了解释。在进入本章的操作部分之前,回顾一下在该章节中的使用可能是有用的,以加强主要概念。

导航将由react-navigation处理,因为它是迄今为止在 React Native 中开发的最完整和性能最佳的导航库。最后,我们将使用一些非常有用的库,特别是对于电子商务应用,比如react-native-credit-card-input,它处理信用卡输入。

在构建此应用程序时,我们将强调几个质量方面,以确保应用程序在本章结束时已经准备投入生产。例如,我们将广泛使用类型验证来验证属性和代码清理。

概述

与之前的章节不同,我们不会花太多精力在应用程序的外观和感觉上,而是专注于功能和代码质量。尽管如此,我们将以一种方式构建它,以便任何开发人员都可以在以后轻松地为其添加样式。有了这个想法,让我们来看看完成后应用程序的样子。

让我们从主屏幕开始,显示所有的书籍:

在 Android 中,我们将添加一个抽屉导航模式,而不是选项卡模式,因为 Android 用户更习惯于它:

抽屉可以通过从左边缘向右滑动屏幕来打开:

现在,让我们看看当用户点击主屏幕上的一本书时会发生什么(可用书籍列表):

这个屏幕的 Android 版本将是类似的,因为只有一些本地组件会根据应用程序执行的平台不同而采用不同的样式:

只有登录用户才能从我们的应用程序购买书籍。这意味着我们需要在某个时候弹出登录/注册屏幕,点击“购买!”按钮似乎是一个合适的时机:

在这种情况下,Android 版本将与 iOS 版本看起来不同,因为每个平台上本机按钮的样式不同:

为了测试目的,在这个应用程序中我们创建了一个测试帐户,具有以下凭据:

  • 电子邮件:test@test.com

  • 密码:test

如果用户还没有帐户,她将能够点击“或注册”按钮来创建一个:

此表单将包括以下验证:

  • 电子邮件和重复电子邮件字段的值匹配

  • 所有字段都已输入

如果这些验证中有任何失败,我们将在此屏幕底部显示错误消息:

注册后,用户将自动登录,并可以通过查看购物车来继续购买旅程:

同样,Android 版本将在此屏幕的外观上显示细微差异:

通过点击此屏幕上的“继续购买”按钮,用户将被发送回主屏幕,所有可用的书籍都会显示出来,供她继续添加到购物车中。

如果她决定确认购买,应用程序将显示一个付款屏幕,用户可以在其中输入信用卡详细信息:

只有当所有数据都输入正确时,“立即付款”按钮才会激活:

为了测试目的,开发人员可以使用以下信用卡数据:

  • 卡号:4111 1111 1111 1111

  • 到期日期:未来的任何日期

  • CVC/CVV:123

一旦付款完成,用户将收到一份确认购买的确认,详细列出将发送到她地址的所有物品:

这个屏幕将完成购买旅程。在这个阶段,用户可以点击“继续购物”按钮,返回到可用产品列表。

通过选项卡/抽屉导航还有两个可用的旅程。第一个是到“我的个人资料”部分,以查看她的账户详细信息或注销:

如果用户还没有登录,应用程序将在此屏幕上显示登录/注册表单。

最后一个旅程是通过销售选项卡/菜单项访问的:

通过点击“添加到购物车”按钮,用户将直接进入购买旅程,在那里她可以添加更多的物品到购物车,或者直接确认购买,输入她的登录(如果不存在)和付款详细信息。

最后,每当我们需要从后端 API 接收数据时,我们将显示一个旋转器,让用户知道后台有一些活动正在进行:

由于我们将模拟所有的 API 调用,我们需要在它们的响应中添加一些小的延迟,以便看到旋转器,这样开发人员就可以像用户一样拥有类似的体验,当我们用真实的 API 请求替换模拟的调用时。

设置文件夹结构

这个应用将使用 Redux 作为其状态管理库,这将定义我们在本章中将使用的文件夹结构。让我们通过 React Native 的 CLI 来初始化项目:

react-native init --version="0.48.3" ecommerce 

正如我们在之前的章节中看到的,我们使用 Redux,我们需要我们的文件夹结构来容纳不同的模块类型:reducersactionscomponentsscreensapi调用。我们将在以下文件夹结构中完成这一点:

除了 React Native 的 CLI 创建的文件夹结构外,我们还添加了以下文件夹和文件:

  • src/components:这将保存可重用的视觉组件。

  • src/reducers:这将存储修改应用程序状态的 reducers,通过检测触发了哪些操作。

  • src/screens:这将存储所有不同的视觉容器,通过 Redux 将它们连接到应用程序状态。

  • src/api.js:在本章结束时,我们将在此文件中模拟所有所需的 API 调用。如果我们想要连接到真实的 API,我们只需要更改此文件以向正确的端点发出 HTTP 请求。

  • src/main.js:这是应用程序的入口点,将设置导航组件并初始化存储应用程序状态的存储。

src/components文件夹将包含以下文件:

src/reducers将保存我们应用程序中的三个不同数据领域:用户、支付和产品:

最后,screens文件夹将存储用户在应用程序中能够看到的每个屏幕的文件:

现在让我们来看看我们将用于安装此应用程序的所有必需库的package.json文件:

/*** package.json ***/

{
  "name": "ecommerce",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest",
    "ios": "react-native run-ios",
    "android": "react-native run-android"
  },
  "dependencies": {
 "native-base": "².3.1",
 "prop-types": "¹⁵.5.10",
    "react": "16.0.0-alpha.12",
    "react-native": "0.48.3",
 "react-native-credit-card-input": "⁰.3.3",
 "react-navigation": "¹.0.0-beta.11",
 "react-redux": "⁵.0.6",
 "redux": "³.7.2",
 "redux-thunk": "².2.0"
  },
  "devDependencies": {
 "babel-eslint": "⁷.2.3",
    "babel-jest": "20.0.3",
    "babel-plugin-lodash": "³.2.11",
    "babel-plugin-module-resolver": "².7.1",
    "babel-plugin-transform-builtin-extend": "¹.1.2",
    "babel-plugin-transform-react-jsx-source": "⁶.22.0",
    "babel-plugin-transform-runtime": "⁶.23.0",
    "babel-preset-env": "¹.6.0",
    "babel-preset-es2015": "⁶.24.1",
    "babel-preset-react-native": "2.0.0",
    "babel-preset-stage-0": "⁶.24.1",
 "eslint-config-airbnb": "¹⁵.1.0",
    "eslint-config-prettier": "².3.0",
    "eslint-config-rallycoding": "³.2.0",
    "eslint-import-resolver-babel-module": "³.0.0",
    "eslint-import-resolver-webpack": "⁰.8.3",
    "eslint-plugin-flowtype": "².35.0",
    "eslint-plugin-import": "².7.0",
    "eslint-plugin-jsx-a11y": "⁵.1.1",
    "eslint-plugin-prettier": "².1.2",
    "eslint-plugin-react": "⁷.2.0",
    "eslint-plugin-react-native": "³.0.1",
    "jest": "20.0.4",
 "prettier": "¹.5.3",
    "prettier-package-json": "¹.4.0",
    "react-test-renderer": "16.0.0-alpha.12"
  },
  "jest": {
    "preset": "react-native"
  }
}

我们将为我们的应用程序使用以下额外的库:

  • native-base:这是用于样式化组件。

  • prop-types:这是用于组件内属性验证。

  • react-native-credit-card-input:这是供用户输入信用卡详细信息的工具。

  • react-redux:这个和 Redux 一起用于状态管理。

  • redux-thunk:这是用于将 Redux 连接到异步调用的工具。

除了所有这些依赖项,我们还将添加一些其他dev依赖项,这将帮助我们的开发人员以非常舒适和自信的方式编写代码:

  • babel-eslint:这是用于 linting 我们的 ES6 代码。

  • eslint-config-airbnb:这是我们将使用的一组编码样式。

  • prettier:这是我们将使用的代码格式化程序,以支持 ES6 和 JSX。

有了这个package.json,我们准备通过运行来安装所有这些依赖项:

npm install

在开始编写代码之前,让我们配置我们的代码检查规则和文本编辑器,充分利用本章中将使用的代码格式化工具。

代码检查和格式化

编写干净,无错误的代码是具有挑战性的。我们可能会面临许多陷阱,例如缩进,导入/导出错误,标签未关闭等。手动克服所有这些问题是一项艰巨的工作,这可能会让我们分心,远离我们的主要目的:编写功能性代码。幸运的是,有一些非常有用的工具可以帮助我们完成这项任务。

本章中我们将使用的工具来确保我们的代码干净将是 ESLint(eslint.org/)和 Prettier(github.com/prettier/prettier)。

ESLint 将负责识别和报告 ES6 / JavaScript 代码中发现的模式,其目标是使代码更一致,避免错误。例如,ESLint 将标记任何使用未声明变量的情况,暴露错误,而不是等到编译时才发现。

另一方面,Prettier 通过解析原始样式并重新打印具有自己规则的代码来强制执行整个代码库的一致代码样式,考虑到最大行长度,在必要时换行。

我们还可以使用 ESLint 直接在浏览器中强制执行 Prettier 代码样式。我们的第一步将是配置 ESLint 以适应我们在项目中要强制执行的格式化和代码检查规则。在这个应用程序的情况下,我们将遵循 Airbnb 和 Prettier 的规则,因为我们已经将它们作为开发人员的依赖项安装在这个项目中。

为了确保 ESLint 将使用这些规则,我们将创建一个.eslintrc文件,其中包含我们在检查时要设置的所有选项:

/*** .eslintrc ***/

{
  "extends": ["airbnb", "prettier", "prettier/react", "prettier/flowtype"],
  "globals": {
    "queryTree": false
  },
 "plugins": ["react", "react-native", "flowtype", "prettier"],
  "env": { "es6": true, "jest": true },
  "parser": "babel-eslint",
  "rules": {
    "prettier/prettier": [
 "error",
 {
 "trailingComma": "all",
 "singleQuote": true,
 "bracketSpacing": true,
 "tabWidth": 2
 }
 ],

    ...

}

我们不会在本书中深入探讨如何配置 ESLint,因为它们的文档非常广泛且解释得很好。对于这个项目,我们只需要在配置文件中设置相应的插件(reactreact-nativeflowtypeprettier)来扩展 Airbnb 和 Prettier 的规则。

设置检查器的规则是一种品味问题,如果没有太多经验,最好从一套预先构建的规则(例如 Airbnb 规则)开始,并逐个修改它们。

最后,我们需要配置我们的代码编辑器来显示这些规则,标记它们,并在保存时理想地修复它们。Visual Studio Code 在集成这些 linting/code 格式化规则方面做得非常好,因为它的 ESLint 插件(github.com/Microsoft/vscode-eslint)为我们做了所有的工作。强烈建议启用eslint.autoFixOnSave选项,以确保编辑器在保存我们正在工作的文件后修复所有的代码格式问题。

现在我们已经有了我们的 linting 工具,让我们开始编写我们应用程序的代码库。

索引和主文件

iOS 和 Android 平台将使用src/main.js作为入口点共享相同的代码库。因此,我们将更改index.ios.jsindex.android.js来导入main.js并使用该组件作为根来初始化应用程序:

/*** index.ios.js and index.android.js ***/ 

import { AppRegistry } from 'react-native';
import App from './src/main';

AppRegistry.registerComponent('ecommerce', () => App);

这是我们在整本书中共享代码库的所有应用程序所使用的相同结构。我们的main.js文件现在应该初始化导航组件并设置我们将用来保存应用状态的存储:

/*** src/main.js ***/

import React from 'react';
import {
  DrawerNavigator,
  TabNavigator,
  StackNavigator,
} from 'react-navigation';
import { Platform } from 'react-native';

import { Provider } from 'react-redux';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import paymentsReducer from './reducers/payments';
import productsReducer from './reducers/products';
import userReducer from './reducers/user';

import ProductList from './screens/ProductList';
import ProductDetail from './screens/ProductDetail';
import MyCart from './screens/MyCart';
import MyProfile from './screens/MyProfile';
import Payment from './screens/Payment';
import PaymentConfirmation from './screens/PaymentConfirmation';
import Sales from './screens/Sales';

const ProductsNavigator = StackNavigator({
 ProductList: { screen: ProductList },
 ProductDetail: { screen: ProductDetail },
});

const PurchaseNavigator = StackNavigator({
 MyCart: { screen: MyCart },
 Payment: { screen: Payment },
 PaymentConfirmation: { screen: PaymentConfirmation },
});

let Navigator;
if (Platform.OS === 'ios') {
 Navigator = TabNavigator(
 {
 Home: { screen: ProductsNavigator },
 MyCart: { screen: PurchaseNavigator },
 MyProfile: { screen: MyProfile },
 Sales: { screen: Sales },
 },
 {
 tabBarOptions: {
 inactiveTintColor: '#aaa',
 activeTintColor: '#000',
 showLabel: true,
 },
 },
 );
} else {
 Navigator = DrawerNavigator({
 Home: { screen: ProductsNavigator },
 MyCart: { screen: MyCart },
 MyProfile: { screen: MyProfile },
 Sales: { screen: Sales },
 });
}

const store = createStore(
 combineReducers({ paymentsReducer, productsReducer, userReducer }),
 applyMiddleware(thunk),
);

export default () => (
  <Provider store={store}>
 <Navigator />
  </Provider>
);

我们的主导航器(Navigator)将在 iOS 上是一个选项卡导航,在 Android 上是一个抽屉导航。这个导航器将是应用程序的根,并将使用两个嵌套的堆叠导航器(ProductsNavigatorPurchaseNavigator),它们将涵盖以下旅程:

  • ProductsNavigator:ProductList | ProductDetail

  • PurchaseNavigator:MyCart | Payment | PaymentConfirmation

每个旅程中的每一步都是应用程序中的特定屏幕。

登录和注册不是这些旅程中的步骤,因为它们将被视为弹出屏幕,只在需要时显示。

这个文件的最后一步是设置 Redux,应用所有的 reducers 和中间件(在我们的情况下只有redux-thunk),这将为这个项目做好准备:

const store = createStore(
  combineReducers({ paymentsReducer, productsReducer, userReducer }),
  applyMiddleware(thunk),
);

一旦store被创建,我们将它传递给应用程序的根提供程序,以确保状态将在所有屏幕之间共享。在进入每个单独的屏幕之前,让我们创建我们的 reducers 和 actions,以便在构建屏幕时可以使用它们。

Reducers

在之前的章节中,我们按照 Redux 文档中记录的标准方式拆分了我们的 Redux 特定代码(reducers、actions 和 action creators)。为了便于将来的维护,我们将为这个应用使用不同的方法:Redux Ducks(github.com/erikras/ducks-modular-redux)。

Redux Ducks 是在使用 Redux 时捆绑在一起的减速器、操作类型和操作的提案。它们不是创建减速器和操作的单独文件夹,而是根据它们处理的功能类型将它们放在一起的文件中,从而减少了在实现新功能时需要处理的文件数量。

让我们从products减速器开始。

/*** src/reducers/products.js ***/

import { get } from '../api';

// Actions
const FETCH = 'products/FETCH';
const FETCH_SUCCESS = 'products/FETCH_SUCCESS';
const FETCH_ERROR = 'products/FETCH_ERROR';
const ADD_TO_CART = 'products/ADD_TO_CART';
const REMOVE_FROM_CART = 'products/REMOVE_FROM_CART';
const RESET_CART = 'products/RESET_CART';

// Reducer
const initialState = {
  loading: false,
  cart: [],
  products: [],
};
export default function reducer(state = initialState, action = {}) {
  let product;
  let i;
  switch (action.type) {
 case FETCH:
      return { ...state, loading: true };
 case FETCH_SUCCESS:
      return {
        ...state,
        products: action.payload.products,
        loading: false,
        error: null,
      };
 case FETCH_ERROR:
      return { ...state, error: action.payload.error, loading: false };
 case ADD_TO_CART:
      product = state.cart.find(p => p.id === 
                action.payload.product.id);
      if (product) {
        product.quantity += 1;
        return {
          ...state,
          cart: state.cart.slice(),
        };
      }
      product = action.payload.product;
      product.quantity = 1;
      return {
        ...state,
        cart: state.cart.slice().concat([action.payload.product]),
      };
 case REMOVE_FROM_CART:
      i = state.cart.findIndex(p => p.id === 
          action.payload.product.id);
      if (state.cart[i].quantity === 1) {
        state.cart.splice(i, 1);
      } else {
        state.cart[i].quantity -= 1;
      }
      return {
        ...state,
        cart: state.cart.slice(),
      };
 case RESET_CART:
      return {
        ...state,
        cart: [],
      };
 default:
      return state;
  }
}

// Action Creators
export function addProductToCart(product) {
  return { type: ADD_TO_CART, payload: { product } };
}

export function removeProductFromCart(product) {
  return { type: REMOVE_FROM_CART, payload: { product } };
}

export function fetchProducts() {
  return dispatch => {
    dispatch({ type: FETCH });
    get('/products')
      .then(products =>
        dispatch({ type: FETCH_SUCCESS, payload: { products } }),
      )
      .catch(error => dispatch({ type: FETCH_ERROR, payload: { error } }));
  };
}

export function resetCart() {
  return { type: RESET_CART };
}

这个文件处理了我们应用程序中与产品相关的所有业务逻辑。让我们审查每个操作创建者以及它们在被减速器处理时如何修改状态:

  • addProductToCart(): 这将分派ADD_TO_CART操作,减速器将捕获该操作。如果提供的产品已经存在于状态中的购物车中,它将增加一个项目的数量。否则,它将把产品插入购物车并将其数量设置为一。

  • removeProductFromCart(): 这个操作与上一个操作相反。如果购物车中已经存在该产品,则减少该产品的数量。如果该产品的数量为一,减速器将从购物车中删除该产品。

  • fetchProducts(): 这是一个异步操作,因此将返回一个函数供redux-thunk使用。它将向 API 的/products端点发出GET请求(由api.json文件中的get()函数实现)。它还将处理来自该端点的响应,在请求成功完成时分派一个FETCH_SUCCESS操作,或者在请求出错时分派一个FETCH_ERROR操作。

  • resetCart(): 这将分派一个RESET_CART操作,减速器将使用它来从状态中清除所有购物车详细信息。

由于我们遵循 Redux Ducks 的建议,所有这些操作都放在同一个文件中,这样很容易确定操作的作用以及它们对应用状态造成的影响。

现在让我们转到下一个减速器:user减速器:

/*** src/reducers/user.js ***/

import { post } from '../api';

// Actions
const LOGIN = 'user/LOGIN';
const LOGIN_SUCCESS = 'user/LOGIN_SUCCESS';
const LOGIN_ERROR = 'user/LOGIN_ERROR';
const REGISTER = 'user/REGISTER';
const REGISTER_SUCCESS = 'user/REGISTER_SUCCESS';
const REGISTER_ERROR = 'user/REGISTER_ERROR';
const LOGOUT = 'user/LOGOUT';

// Reducer
export default function reducer(state = {}, action = {}) {
  switch (action.type) {
 case LOGIN:
 case REGISTER:
      return { ...state, user: null, loading: true, error: null };
 case LOGIN_SUCCESS:
 case REGISTER_SUCCESS:
      return {
        ...state,
        user: action.payload.user,
        loading: false,
        error: null,
      };
 case LOGIN_ERROR:
 case REGISTER_ERROR:
      return {
        ...state,
        user: null,
        loading: false,
        error: action.payload.error,
      };
 case LOGOUT:
      return {
        ...state,
        user: null,
      };
    default:
      return state;
  }
}

// Action Creators
export function login({ email, password }) {
  return dispatch => {
    dispatch({ type: LOGIN });
    post('/login', { email, password })
      .then(user => dispatch({ type: LOGIN_SUCCESS, 
       payload: { user } }))
      .catch(error => dispatch({ type: LOGIN_ERROR,
       payload: { error } }));
  };
}

export function register({
  email,
  repeatEmail,
  name,
  password,
  address,
  postcode,
  city,
}) {
  if (
    !email ||
    !repeatEmail ||
    !name ||
    !password ||
    !name ||
    !address ||
    !postcode ||
    !city
  ) {
    return {
      type: REGISTER_ERROR,
      payload: { error: 'All fields are mandatory' },
    };
  }
  if (email !== repeatEmail) {
    return {
      type: REGISTER_ERROR,
      payload: { error: "Email fields don't match" },
    };
  }
  return dispatch => {
    dispatch({ type: REGISTER });
    post('/register', {
      email,
      name,
      password,
      address,
      postcode,
      city,
    })
      .then(user => dispatch({ type: REGISTER_SUCCESS, payload: 
                    { user } }))
      .catch(error => dispatch({ type: REGISTER_ERROR, payload: 
                    { error } }));
  };
}

export function logout() {
  return { type: LOGOUT };
}

这个减速器中的操作创建者非常直接:

  • login(): 这需要一个emailpassword来分派LOGIN操作,然后向/login端点发出POST请求以验证凭据。如果 API 调用成功,操作创建者将分派LOGIN_SUCCESS操作以登录用户。如果请求失败,它将分派一个LOGIN_ERROR操作,以便用户知道发生了什么。

  • register(): 这类似于login()操作创建者;它将分派一个REGISTER动作,然后根据 API 调用的返回方式分派一个REGISTER_SUCCESSREGISTER_ERROR。如果注册成功,用户数据将存储在应用程序状态中,标记用户已登录。

  • logout(): 这将分派一个LOGOUT动作,这将使减速器清除应用程序状态中的user对象。

最后一个减速器处理支付数据:

/*** src/reducers/payments.js ***/

import { post } from '../api';

// Actions
const PAY = 'products/PAY';
const PAY_SUCCESS = 'products/PAY_SUCCESS';
const PAY_ERROR = 'products/PAY_ERROR';
const RESET_PAYMENT = 'products/RESET_PAYMENT';

// Reducer
export default function reducer(state = {}, action = {}) {
  switch (action.type) {
    case PAY:
      return { ...state, loading: true, paymentConfirmed: false, 
               error: null };
    case PAY_SUCCESS:
      return {
        ...state,
        paymentConfirmed: true,
        loading: false,
        error: null,
      };
    case PAY_ERROR:
      return {
        ...state,
        loading: false,
        paymentConfirmed: false,
        error: action.payload.error,
      };
    case RESET_PAYMENT:
      return { loading: false, paymentConfirmed: false, error: null };
    default:
      return state;
  }
}

// Action Creators
export function pay(user, cart, card) {
  return dispatch => {
    dispatch({ type: PAY });
    post('/pay', { user, cart, card })
      .then(() => dispatch({ type: PAY_SUCCESS }))
      .catch(error => dispatch({ type: PAY_ERROR, 
             payload: { error } }));
  };
}

export function resetPayment() {
  return { type: RESET_PAYMENT };
}

在这个减速器中只有两个操作创建者:

  • pay(): 这需要一个用户、一个购物车和一个信用卡,并调用 API 中的/pay端点来进行付款。如果付款成功,它会触发一个PAY_SUCCESS动作,否则,它会触发一个PAY_ERROR动作来通知用户。

  • resetPayment(): 通过触发RESET_PAYMENT动作来清除任何支付数据。

我们已经看到这些操作创建者以几种方式联系 API。现在让我们创建一些 API 方法,这样操作创建者就可以与应用程序的后端进行交互。

API

我们将使用的 API 服务将使用两种 HTTP 方法(GETPOST)和四个端点(/products/login/register/pay)。出于测试和开发原因,我们将模拟此服务,但将在以后的阶段留下实现插入外部端点的可能性:

/*** src/api.js ***/

export const get = uri =>
  new Promise(resolve => {
    let response;

    switch (uri) {
      case '/products':
        response = [
          {
            id: 1,
            name: 'Mastering Docker - Second Edition',
            author: 'James Cameron',
            img:
              'https://d1ldz4te4covpm.cloudfront.net/sites/default
              /files/imagecache/ppv4_main_book_cover
              /B06565_MockupCover_0.png',
            price: 39.58,
          },

         ...

        ];
        break;
      default:
        return null;
    }

    setTimeout(() => resolve(response), 1000);
    return null;
  });

export const post = (uri, data) =>
  new Promise((resolve, reject) => {
    let response;

    switch (uri) {
      case '/login':
        if (data.email === 'test@test.com' && data.password === 'test')  
        {
          response = {
            email: 'test@test.com',
            name: 'Test Testson',
            address: '123 test street',
            postcode: '2761XZ',
            city: 'Testington',
          };
        } else {
          setTimeout(() => reject('Unauthorised'), 1000);
          return null;
        }
        break;
      case '/pay':
        if (data.card.cvc === '123') {
          response = true;
        } else {
          setTimeout(() => reject('Payment not authorised'), 1000);
          return null;
        }
        break;
      case '/register':
        response = data;
        break;
      default:
        return null;
    }

    setTimeout(() => resolve(response), 1000);
    return null;
  });

export const put = () => {};

所有调用都包装在一个setTimeout()函数中,间隔 1 秒,以模拟网络活动,以便进行测试。只有当凭据为test@test.com/test时,服务才会成功回复。另一方面,pay()服务只有在 CVC/CVV 代码为123时才会返回成功的响应。注册调用只是将提供的数据作为成功注册的用户数据返回。

这种 setTimeout()技巧用于模拟异步调用,就像在真实后端发生的情况一样。这是在后端或测试环境准备好之前开发前端解决方案的有用方式。

现在让我们继续应用程序中的屏幕。

产品列表

我们的主屏幕显示了可供购买的产品列表:

/*** src/screens/ProductList.js ***/

import React from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import PropTypes from 'prop-types';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import {
  Spinner,
  Icon,
  List,
  ListItem,
  Thumbnail,
  Body,
  Text,
} from 'native-base';
import * as ProductActions from '../reducers/products';

class ProductList extends React.Component {
  static navigationOptions = {
    drawerLabel: 'Home',
    tabBarIcon: () => <Icon name="home" />,
  };

  componentWillMount() {
 this.props.fetchProducts();
  }

 onProductPress(product) {
 this.props.navigation.navigate('ProductDetail', { product });
 }

  render() {
    return (
      <ScrollView>
 {this.props.loading && <Spinner />}
        <List>
          {this.props.products.map(p => (
            <ListItem key={p.id}>
              <Thumbnail square height={80} source={{ uri: p.img }} />
              <Body>
                <TouchableOpacity onPress={() => 
                 this.onProductPress(p)}>
                  <Text>{p.name}</Text>
                  <Text note>${p.price}</Text>
                </TouchableOpacity>
              </Body>
            </ListItem>
          ))}
        </List>
      </ScrollView>
    );
  }
}

ProductList.propTypes = {
 fetchProducts: PropTypes.func.isRequired,
 products: PropTypes.array.isRequired,
 loading: PropTypes.bool.isRequired,
 navigation: PropTypes.any.isRequired,
};

function mapStateToProps(state) {
 return {
 products: state.productsReducer.products || [],
 loading: state.productsReducer.loading,
 };
}

function mapStateActionsToProps(dispatch) {
 return bindActionCreators(ProductActions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(ProductList);

在此屏幕挂载后,它将通过调用this.props.fetchProducts();来检索最新的可用产品列表。这将触发屏幕重新渲染,因此所有可用的书籍都会显示在屏幕上。为了实现这一点,我们依赖于 Redux 更新状态(通过产品 reducer)并通过调用connect方法将新状态注入到此屏幕中,我们需要传递mapStateToPropsmapStateActionsToProps函数。

mapStateToProps将负责从state中提取产品列表,而mapStateActionsToProps将把每个操作与dispatch()函数连接起来,该函数将这些操作与 Redux 状态连接起来,将每个触发的操作应用于所有 reducer。在此屏幕上,我们只对与产品相关的操作感兴趣,因此我们将只通过bindActionCreators Redux 函数将ProductActionsdispatch函数绑定在一起。

render方法中,我们使用map函数将检索到的产品列表转换为多个<ListItem/>组件,这些组件将显示在<List/>内。在此列表上方,我们将显示<Spinner/>,同时等待网络请求的完成:{this.props.loading && <Spinner />}

我们还通过prop-types库添加了属性验证。

ProductList.propTypes = {
  fetchProducts: PropTypes.func.isRequired,
  products: PropTypes.array.isRequired,
  loading: PropTypes.bool.isRequired,
  navigation: PropTypes.any.isRequired,
};

这意味着每当此组件接收到错误类型的 prop 时,或者实际上未能接收到所需的 prop 时,我们将收到警告。在这种情况下,我们期望接收到:

  • 一个名为fetchProducts的函数,它将通过 API 请求可用产品的列表。它将由 Redux 通过mapStateActionsToProps在此屏幕上定义的方式提供。

  • 一个包含可用产品列表的products数组。这将通过先前提到的mapStateToProps函数由 Redux 注入。

  • 一个用于标记网络活动的加载布尔值(也通过mapStateToProps由 Redux 提供)。

  • 一个由react-navigation自动提供的导航对象。我们将其标记为any类型,因为它是一个外部对象,可能会在我们的控制之外改变其类型。

所有这些都将可用于在我们组件的 props(this.props)中使用。

关于此容器的最后一件事是我们将如何处理用户操作。在此屏幕上,只有一个操作:用户点击产品项以查看其详细信息:

onProductPress(product) {
    this.props.navigation.navigate('ProductDetail', { product });
}

当用户点击特定产品时,这个屏幕将调用navigation属性中的navigate函数,移动到我们的下一个屏幕ProductDetail。我们将直接使用navigation选项传递所选产品,而不是通过动作将其保存在状态中,以简化我们的存储。

ProductDetail

这个屏幕将向用户显示有关所选产品的所有详细信息,并允许她将所选产品添加到她的购物车中。

/*** src/screens/ProductDetail.js ***/

import React from 'react';
import { Image, ScrollView } from 'react-native';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Icon, Button, Text } from 'native-base';
import * as ProductsActions from '../reducers/products';

class ProductDetail extends React.Component {
  static navigationOptions = {
    drawerLabel: 'Home',
    tabBarIcon: () => <Icon name="home" />,
  };

  onBuyPress(product) {
 this.props.addProductToCart(product);
 this.props.navigation.goBack();
 setTimeout(() => this.props.navigation.navigate('MyCart',
                     { product }), 0);
 }

  render() {
    const { navigation } = this.props;
    const { state } = navigation;
    const { params } = state;
    const { product } = params;
    return (
      <ScrollView>
        <Image
 style={{
 height: 200,
 width: 160,
 alignSelf: 'center',
 marginTop: 20,
 }}
 source={{ uri: product.img }}
 />
 <Text
 style={{
 alignSelf: 'center',
 marginTop: 20,
 fontSize: 30,
 fontWeight: 'bold',
 }}
 >
 ${product.price}
        </Text>
        <Text
          style={{
            alignSelf: 'center',
            margin: 20,
          }}
        >
          Lorem ipsum dolor sit amet, consectetur 
          adipiscing elit. Nullam nec
          eros quis magna vehicula blandit at nec velit. 
          Mauris porta risus non
          lectus ultricies lacinia. Phasellus molestie metus ac 
          metus dapibus,
          nec maximus arcu interdum. In hac habitasse platea dictumst.
          Suspendisse fermentum iaculis ex, faucibus semper turpis 
          vestibulum quis.
        </Text>
        <Button
 block
 style={{ margin: 20 }}
 onPress={() => this.onBuyPress(product)}
 >
 <Text>Buy!</Text>
 </Button>
      </ScrollView>
    );
  }
}

ProductDetail.propTypes = {
  navigation: PropTypes.any.isRequired,
  addProductToCart: PropTypes.func.isRequired,
};

ProductDetail.navigationOptions = props => {
  const { navigation } = props;
  const { state } = navigation;
  const { params } = state;
  return {
    tabBarIcon: () => <Icon name="home" />,
    headerTitle: params.product.name,
  };
};

function mapStateToProps(state) {
 return {
 user: state.userReducer.user,
 };
}
function mapStateActionsToProps(dispatch) {
 return bindActionCreators(ProductsActions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(ProductDetail);

ProductDetail需要 Redux 提供存储在state中的用户详细信息。这是通过调用connect方法实现的,传递一个mapStateToProps函数,该函数将从指定的state中提取用户并将其返回为屏幕中的prop注入。它还需要来自 Redux 的动作:addProductToCart。当用户表示希望购买时,此动作只是将所选产品存储在存储中。

在这个屏幕中,render() 方法显示了<ScrollView />包裹着书籍图片、价格、描述(我们现在将显示一个虚假的lorem ipsum描述),以及一个Buy!按钮,它将连接到 Redux 提供的addProductToCart动作。

onBuyPress(product) {
    this.props.addProductToCart(product);
    this.props.navigation.goBack();
    setTimeout(() => this.props.navigation.navigate('MyCart', 
                     { product }), 0);
}

onBuyPress()方法调用了上述动作,并在之后进行了一个小的导航技巧。它通过在navigation对象上调用goBack()方法来返回,从导航堆栈中移除ProductDetail屏幕,因为用户在将产品添加到购物车后不再需要它。在这之后,onBuyPress()方法将在navigation对象上调用navigate方法,以移动并在MyCart屏幕中显示用户购物车的状态。我们在这里使用setTimeout来确保我们等待前一个调用(this.props.navigation.goBack();)完成所有导航任务,并且对象再次准备好供我们使用。等待0秒应该足够,因为我们只是想等待调用堆栈被清除。

让我们看看MyCart屏幕现在是什么样子的。

MyCart

这个屏幕期望 Redux 注入存储在状态中的购物车,以便它可以渲染用户在确认购买之前审查的所有购物车中的物品:

/*** src/screens/MyCart.js ***/

import React from 'react';
import { ScrollView, View } from 'react-native';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import {
  ListItem,
  Text,
  Icon,
  Button,
  Badge,
  Header,
  Title,
} from 'native-base';

import * as ProductActions from '../reducers/products';

class MyCart extends React.Component {
  static navigationOptions = {
    drawerLabel: 'My Cart',
    tabBarIcon: () => <Icon name="cart" />,
  };

  onTrashPress(product) {
 this.props.removeProductFromCart(product);
 }

  render() {
    return (
      <View>
        <ScrollView>
          {this.props.cart.map((p, i) => (
            <ListItem key={i} style={{ justifyContent: 
                              'space-between' }}>
              <Badge primary>
                <Text>{p.quantity}</Text>
              </Badge>
              <Text> {p.name}</Text>
              <Button
 icon
 danger
 small
 transparent
 onPress={() => this.onTrashPress(p)}
 >
 <Icon name="trash" />
 </Button>
            </ListItem>
          ))}
          {this.props.cart.length > 0 && (
            <View>
              <Text style={{ alignSelf: 'flex-end', margin: 10 }}>
                Total: ${this.props.cart.reduce(
                  (sum, p) => sum + p.price * p.quantity,
                  0,
                )}
              </Text>
              <View style={{ flexDirection: 'row', 
               justifyContent: 'center' }}>
 <Button
 style={{ margin: 10 }}
 onPress={() =>  
                  this.props.navigation.navigate('Home')}
 >
 <Text>Keep buying</Text>
 </Button>
 <Button
 style={{ margin: 10 }}
 onPress={() => 
                  this.props.navigation.navigate('Payment')}
 >
 <Text>Confirm purchase</Text>
 </Button>
              </View>
            </View>
          )}
          {this.props.cart.length == 0 && (
            <Text style={{ alignSelf: 'center', margin: 30 }}>
              There are no products in the cart
            </Text>
          )}
        </ScrollView>
      </View>
    );
  }
}

MyCart.propTypes = {
  cart: PropTypes.array.isRequired,
  navigation: PropTypes.object.isRequired,
  removeProductFromCart: PropTypes.func.isRequired,
};

function mapStateToProps(state) {
  return {
    user: state.userReducer.user,
    cart: state.productsReducer.cart || [],
    loading: state.userReducer.loading,
    error: state.userReducer.error,
    paying: state.paymentsReducer.loading,
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(ProductActions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(MyCart);

除了购物车本身,正如我们在propTypes定义中所看到的,这个屏幕需要来自ProductActionsremoveProductFromCart动作,以及提供给navigation对象的navigation对象,以便在用户准备确认购买时导航到Payment屏幕。

总之,用户可以从这里执行三个操作:

  • 通过点击每个产品行上的垃圾桶图标从购物车中移除商品(调用this.onTrashPress()

  • 导航到“支付”屏幕以完成她的购买(调用this.props.navigation.navigate('Payment')

  • 导航到主屏幕以继续购买产品(调用this.props.navigation.navigate('Home')

让我们继续购买之旅,查看“支付”屏幕。

支付

我们将使用react-native-credit-card-input库来捕获用户的信用卡详细信息。为了使此屏幕正常工作,我们将从 Redux 请求购物车、用户和几个重要操作:

/*** src/screens/Payment.js ***/

import React from 'react';
import { View } from 'react-native';

import { CreditCardInput } from 'react-native-credit-card-input';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Icon, Button, Text, Spinner, Title } from 'native-base';
import PropTypes from 'prop-types';
import * as PaymentsActions from '../reducers/payments';
import * as UserActions from '../reducers/user';
import LoginOrRegister from '../components/LoginOrRegister';

class Payment extends React.Component {
  static navigationOptions = {
    drawerLabel: 'MyCart',
    tabBarIcon: () => <Icon name="cart" />,
  };
  state = {
 validCardDetails: false,
 cardDetails: null,
 };
  onCardInputChange(creditCardForm) {
 this.setState({
 validCardDetails: creditCardForm.valid,
 cardDetails: creditCardForm.values,
 });
 }

  componentWillReceiveProps(newProps) {
 if (this.props.paying && newProps.paymentConfirmed) {
 this.props.navigation.navigate('PaymentConfirmation');
 }
 }

  render() {
    return (
      <View
        style={{
          flex: 1,
          alignSelf: 'stretch',
          paddingTop: 10,
        }}
      >
        {this.props.cart.length > 0 &&
 !this.props.user && (
 <LoginOrRegister
 login={this.props.login}
 register={this.props.register}
 logout={this.props.logout}
 loading={this.props.loading}
 error={this.props.error}
 />
 )}
        {this.props.cart.length > 0 &&
 this.props.user && (
          <View>
            <Title style={{ margin: 10 }}>
              Paying: $
              {this.props.cart.reduce(
                (sum, p) => sum + p.price * p.quantity,
                0,
              )}
            </Title>
            <CreditCardInput onChange=
            {this.onCardInputChange.bind(this)} />
            <Button
 block
 style={{ margin: 20 }}
 onPress={() =>
 this.props.pay(
 this.props.user,
 this.props.cart,
 this.state.cardDetails,
 )}
 disabled={!this.state.validCardDetails}
 >
 <Text>Pay now</Text>
 </Button>
            {this.props.paying && <Spinner />}
          </View>
        )}
        {this.props.cart.length > 0 &&
        this.props.error && (
          <Text
 style={{
 alignSelf: 'center',
 color: 'red',
 position: 'absolute',
 bottom: 10,
 }}
 >
 {this.props.error}
 </Text>
        )}
        {this.props.cart.length === 0 && (
 <Text style={{ alignSelf: 'center', margin: 30 }}>
 There are no products in the cart
 </Text>
 )}
      </View>
    );
  }
}

Payment.propTypes = {
 user: PropTypes.object,
 cart: PropTypes.array,
 login: PropTypes.func.isRequired,
 register: PropTypes.func.isRequired,
 logout: PropTypes.func.isRequired,
 pay: PropTypes.func.isRequired,
 loading: PropTypes.bool,
 paying: PropTypes.bool,
 error: PropTypes.string,
 paymentConfirmed: PropTypes.bool,
 navigation: PropTypes.object.isRequired,
};

function mapStateToProps(state) {
  return {
    user: state.userReducer.user,
    cart: state.productsReducer.cart,
    loading: state.userReducer.loading,
    paying: state.paymentsReducer.loading,
    paymentConfirmed: state.paymentsReducer.paymentConfirmed,
    error: state.paymentsReducer.error || state.userReducer.error,
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(
    Object.assign({}, PaymentsActions, UserActions),
    dispatch,
  );
}

export default connect(mapStateToProps, mapStateActionsToProps)(Payment);

这是一个复杂的组件。让我们看一下 props 验证,以了解其签名:

Payment.propTypes = {
  user: PropTypes.object,
  cart: PropTypes.array,
  login: PropTypes.func.isRequired,
  register: PropTypes.func.isRequired,
  logout: PropTypes.func.isRequired,
  pay: PropTypes.func.isRequired,
  loading: PropTypes.bool,
  paying: PropTypes.bool,
  error: PropTypes.string,
  paymentConfirmed: PropTypes.bool,
  navigation: PropTypes.object.isRequired,
};

需要传递以下 props 才能使组件正常工作:

  • user: 我们需要用户来检查她是否已登录。如果她没有登录,我们将显示登录/注册组件,而不是信用卡输入。

  • cart: 我们需要它来计算并显示要向信用卡收取的总费用。

  • login: 如果用户决定从此屏幕登录,将调用此操作。

  • register: 如果用户决定从此屏幕注册,将调用此操作。

  • logout: 此操作对于<LoginOrRegister />组件的工作是必需的,因此需要从 Redux 提供,以便可以将其注入到子<LoginOrRegister />组件中。

  • pay: 当用户输入有效的信用卡详细信息并按下“立即支付”按钮时,将触发此操作。

  • loading: 这是用于使子<LoginOrRegister />组件正常工作的标志。

  • paying: 此标志将用于在确认付款时显示旋转器。

  • error: 这是在尝试支付或登录/注册时发生的最后一个错误的描述。

  • paymentConfirmed: 当/如果支付正确通过时,此标志将通知组件。

  • navigation: 用于导航到其他屏幕的navigation对象。

此组件还有其自己的状态:

state = {
    validCardDetails: false,
    cardDetails: null,
};

此状态中的两个属性将由<CreditCardInput />react-native-credit-card-input主要组件表单)提供,并将保存用户的信用卡详细信息及其有效性。

为了检测支付是否已确认,我们将使用 React 方法componentWillReceiveProps

componentWillReceiveProps(newProps) {
    if (this.props.paying && newProps.paymentConfirmed) {
      this.props.navigation.navigate('PaymentConfirmation');
    }
}

这个方法只是检测当paymentConfirmed属性从false变为true时,以便导航到PaymentConfirmation屏幕。

支付确认

一个简单的屏幕显示了刚确认的购买的摘要:

/*** src/screens/PaymentConfirmation ***/

import React from 'react';
import { View } from 'react-native';
import PropTypes from 'prop-types';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { NavigationActions } from 'react-navigation';
import { Icon, Title, Text, ListItem, Badge, Button } from 'native-base';

import * as UserActions from '../reducers/user';
import * as ProductActions from '../reducers/products';
import * as PaymentsActions from '../reducers/payments';

class PaymentConfirmation extends React.Component {
  static navigationOptions = {
    drawerLabel: 'MyCart',
    tabBarIcon: () => <Icon name="cart" />,
  };

  componentWillMount() {
 this.setState({ cart: this.props.cart }, () => {
 this.props.resetCart();
 this.props.resetPayment();
 });
 }

 continueShopping() {
 const resetAction = NavigationActions.reset({
 index: 0,
 actions: [NavigationActions.navigate({ routeName: 'MyCart' })],
 });
 this.props.navigation.dispatch(resetAction);
 }

  render() {
    return (
      <View>
        <Title style={{ marginTop: 20 }}>Your purchase is complete!
        </Title>
        <Text style={{ margin: 20 }}>
          Thank you for buying with us. We sent you an email with the
          confirmation details and an invoice. 
          Here you can find a summary of
          your purchase:{' '}
        </Text>
        {this.state.cart.map((p, i) => (
          <ListItem key={i} style={{ justifyContent: 
          'space-between' }}>
            <Badge primary>
              <Text>{p.quantity}</Text>
            </Badge>
            <Text> {p.name}</Text>
            <Text> {p.price * p.quantity}</Text>
          </ListItem>
        ))}
        <Text style={{ alignSelf: 'flex-end', margin: 10 }}>
          Total: ${this.state.cart.reduce(
            (sum, p) => sum + p.price * p.quantity,
            0,
          )}
        </Text>
        <Button
          block
          style={{ margin: 20 }}
          onPress={this.continueShopping.bind(this)}
        >
          <Text>Continue Shopping</Text>
        </Button>
      </View>
    );
  }
}

PaymentConfirmation.propTypes = {
  cart: PropTypes.array.isRequired,
  resetCart: PropTypes.func.isRequired,
  resetPayment: PropTypes.func.isRequired,
};

function mapStateToProps(state) {
  return {
    cart: state.productsReducer.cart || [],
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(
    Object.assign({}, PaymentsActions, ProductActions, UserActions),
    dispatch,
  );
}

export default connect(mapStateToProps, mapStateActionsToProps)(
  PaymentConfirmation,
);

这个屏幕的第一件事是保存与购物车相关的应用程序状态在自己的组件状态中:

componentWillMount() {
    this.setState({ cart: this.props.cart }, () => {
      this.props.resetCart();
      this.props.resetPayment();
    });
}

这是必要的,因为我们希望在显示此屏幕后立即重置购物车和付款详情,因为在以后的任何场合都不需要它。这是通过调用 Redux 提供的resetCart()resetPayment()操作来完成的。

render方法只是将购物车中的项目(现在保存在组件状态中)映射到视图列表中,以便用户可以查看她的订单。在这些视图的底部,我们将显示一个名为“继续购物”的按钮,通过调用continueShopping方法,它将返回用户到ProductList屏幕。除了导航到ProductList屏幕,我们还需要重置导航,以便下次用户想购买一些物品时可以从头开始购买旅程。这是通过创建重置导航操作并调用this.props.navigation.dispatch(resetAction);来实现的。

continueShopping方法调用NavigationActions.reset来清除导航堆栈并返回到主屏幕。这个方法通常在用户旅程结束时调用。

这个屏幕完成了购买过程,所以现在让我们专注于应用程序的另一个部分:用户个人资料。

MyProfile

正如我们之前所看到的,只有登录用户才能完成购买,所以我们需要一种让用户登录、注销、注册和查看其帐户详细信息的方式。这将通过MyProfile屏幕和<LonginOrRegister />组件实现:

/*** src/screens/MyProfile.js ***/

import React from 'react';
import { View, Button as LinkButton } from 'react-native';
import PropTypes from 'prop-types';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import {
  Icon,
  Header,
  Title,
  Label,
  Input,
  Item,
  Form,
  Content,
} from 'native-base';

import * as UserActions from '../reducers/user';
import LoginOrRegister from '../components/LoginOrRegister';

class MyProfile extends React.Component {
  static navigationOptions = {
    drawerLabel: 'My Profile',
    tabBarIcon: () => <Icon name="person" />,
  };

  render() {
    return (
      <View
        style={{
          flex: 1,
          alignSelf: 'stretch',
        }}
      >
        <Header>
          <Title style={{ paddingTop: 10 }}>My Profile</Title>
        </Header>
        {!this.props.user && (
 <LoginOrRegister
 login={this.props.login}
 register={this.props.register}
 logout={this.props.logout}
 loading={this.props.loading}
 error={this.props.error}
 />
 )}
        {this.props.user && (
          <Content>
            <Form>
              <Item>
                <Item fixedLabel>
                  <Label>Name</Label>
                  <Input disabled placeholder={this.props.user.name} />
                </Item>
              </Item>
              <Item disabled>
                <Item fixedLabel>
                  <Label>Email</Label>
                  <Input disabled placeholder={this.props.user.email} 
                  />
                </Item>
              </Item>
              <Item disabled>
                <Item fixedLabel>
                  <Label>Address</Label>
                  <Input disabled placeholder={this.props.user.address} 
                  />
                </Item>
              </Item>
              <Item disabled>
                <Item fixedLabel&gt;
                  <Label>Postcode</Label>
                  <Input disabled placeholder=
                    {this.props.user.postcode} />
                </Item>
              </Item>
              <Item disabled>
                <Item fixedLabel>
                  <Label>City</Label>
                  <Input disabled placeholder={this.props.user.city} />
                </Item>
              </Item>
            </Form>
            <LinkButton title={'Logout'} onPress={() => 
              this.props.logout()} />
          </Content>
        )}
      </View>
    );
  }
}

MyProfile.propTypes = {
 user: PropTypes.any,
 login: PropTypes.func.isRequired,
 register: PropTypes.func.isRequired,
 logout: PropTypes.func.isRequired,
 loading: PropTypes.bool,
 error: PropTypes.string,
};

function mapStateToProps(state) {
  return {
    user: state.userReducer.user || null,
    loading: state.userReducer.loading,
    error: state.userReducer.error,
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(UserActions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(MyProfile);

这个屏幕从应用程序状态中接收用户和一些操作(登录注册注销),这些操作将被传递到<LoginOrRegister />组件中以启用登录和注册。因此,大部分逻辑将被推迟到<LoginOrRegister />组件,留下MyProfile屏幕来列出用户的帐户详细信息并显示一个注销按钮。

让我们回顾一下<LoginOrRegister />组件的功能和工作原理。

LoginOrRegister

实际上,这个组件由两个子组件组成:<Login /><Register /><LoginOrRegister />的唯一任务是保存应该显示哪个组件(<Login /><Register />)的状态,并相应地显示它。

/*** src/components/LoginOrRegister.js ***/

import React from 'react';
import { View } from 'react-native';
import PropTypes from 'prop-types';

import Login from './Login';
import Register from './Register';

export default class LoginOrRegister extends React.Component {
  state = {
 display: 'login',
 };

  render() {
    return (
      <View
        style={{
          flex: 1,
          justifyContent: 'center',
          alignSelf: 'stretch',
        }}
      >
        {this.state.display === 'login' && (
 <Login
 login={this.props.login}
 changeToRegister={() => this.setState({ display: 
            'register' })}
 loading={this.props.loading}
 error={this.props.error}
 />
 )}
 {this.state.display === 'register' && (
 <Register
 register={this.props.register}
 changeToLogin={() => this.setState({ display: 'login' })}
 loading={this.props.loading}
 error={this.props.error}
 />
 )}
      </View>
    );
  }
}

LoginOrRegister.propTypes = {
  error: PropTypes.string,
  login: PropTypes.func.isRequired,
  register: PropTypes.func.isRequired,
  loading: PropTypes.bool,
};

这个组件中的状态可以被它们的子组件改变,因为它向每个子组件传递了一个函数来做到这一点:

changeToRegister={() => this.setState({ display: 'register' })}

...

changeToLogin={() => this.setState({ display: 'login' })}

现在让我们看看<Login /><Register />组件将如何使用这些 props 来更新它们父组件的状态,从一个视图切换到另一个视图。

登录

登录视图将默认显示在父组件上。它的任务是捕获登录信息,并在用户按下登录按钮时调用登录操作:

/*** src/components/Login.js ***/

import React from 'react';
import { View, Button as LinkButton } from 'react-native';
import { Form, Item, Input, Content, Button, Text, Spinner } from 'native-base';
import PropTypes from 'prop-types';

class Login extends React.Component {
  state = { email: null, password: null };

  render() {
    return (
      <View style={{ flex: 1 }}>
        <Content>
          <Form>
            <Item>
              <Input
 placeholder="e-mail"
 keyboardType={'email-address'}
 autoCapitalize={'none'}
 onChangeText={email => this.setState({ email })}
 />
            </Item>
            <Item last>
              <Input
 placeholder="password"
 secureTextEntry
 onChangeText={password => this.setState({ password })}
 />
            </Item>
            <Button
 block
 disabled={this.props.loading}
 style={{ margin: 20 }}
 onPress={() =>
 this.props.login({
 email: this.state.email,
 password: this.state.password,
 })}
 >
 <Text>Login</Text>
 </Button>
          </Form>

          <LinkButton
 title={'or Register'}
 onPress={() => this.props.changeToRegister()}
 />
 {this.props.loading && <Spinner />}
        </Content>
        {this.props.error && (
          <Text
            style={{
              alignSelf: 'center',
              color: 'red',
              position: 'absolute',
              bottom: 10,
            }}
          >
            {this.props.error}
          </Text>
        )}
      </View>
    );
  }
}

Login.propTypes = {
  error: PropTypes.string,
  loading: PropTypes.bool,
  login: PropTypes.func.isRequired,
  changeToRegister: PropTypes.func.isRequired,
};

export default Login;

两个输入框捕获电子邮件和密码,并在输入更改时将它们保存到组件状态中。一旦用户完成输入她的凭据,她将按下登录按钮并触发登录操作,从组件状态传递电子邮件和密码。

还有一个标有或注册<LinkButton />,当按下时将调用其父组件<LoginOrRegister />传递的this.props.changeToRegister()函数。

注册

与登录表单类似,<Register />组件也是一个保存其更改到组件状态的输入字段列表,直到用户有足够的信心按下注册按钮:

import React from 'react';
import { View, Button as LinkButton } from 'react-native';
import { Form, Item, Input, Content, Button, Text, Spinner } from 'native-base';
import PropTypes from 'prop-types';

class Register extends React.Component {
  state = {
 email: null,
 repeatEmail: null,
 name: null,
 password: null,
 address: null,
 postcode: null,
 city: null,
 };

  render() {
    return (
      <View style={{ flex: 1 }}>
        <Content>
          <Form>
            <Item>
              <Input
                placeholder="e-mail"
                keyboardType={'email-address'}
                autoCapitalize={'none'}
                onChangeText={email => this.setState({ email })}
              />
            </Item>
            <Item>
              <Input
                placeholder="repeat e-mail"
                autoCapitalize={'none'}
                keyboardType={'email-address'}
                onChangeText={repeatEmail => this.setState({ 
                                             repeatEmail })}
              />
            </Item>
            <Item>
              <Input
                placeholder="name"
                onChangeText={name => this.setState({ name })}
              />
            </Item>
            <Item>
              <Input
                placeholder="password"
                secureTextEntry
                onChangeText={password => this.setState({ password })}
              />
            </Item>
            <Item>
              <Input
                placeholder="address"
                onChangeText={address => this.setState({ address })}
              />
            </Item>
            <Item>
              <Input
                placeholder="postcode"
                onChangeText={postcode => this.setState({ postcode })}
              />
            </Item>
            <Item>
              <Input
                placeholder="city"
                onChangeText={city => this.setState({ city })}
              />
            </Item>
            <Button
 block
 style={{ margin: 20 }}
 onPress={() =>
 this.props.register({
 email: this.state.email,
 repeatEmail: this.state.repeatEmail,
 name: this.state.name,
 password: this.state.password,
 address: this.state.address,
 postcode: this.state.postcode,
 city: this.state.city,
 })}
 >
 <Text>Register</Text>
 </Button>
          </Form>
          <LinkButton
 title={'or Login'}
 onPress={() => this.props.changeToLogin()}
 />
 {this.props.loading && <Spinner />}
        </Content>
        {this.props.error && (
          <Text
            style={{
              alignSelf: 'center',
              color: 'red',
              position: 'absolute',
              bottom: 10,
            }}
          >
            {this.props.error}
          </Text>
        )}
      </View>
    );
  }
}

Register.propTypes = {
  register: PropTypes.func.isRequired,
  changeToLogin: PropTypes.func.isRequired,
  error: PropTypes.string,
  loading: PropTypes.bool,
};

export default Register;

在这种情况下,视图底部的<LinkButton />在按下时将调用this.props.changeToLogin()以切换到登录视图。

销售

我们添加了最后一个屏幕来演示如何将不同的旅程链接在一起,重用屏幕和组件。在这种情况下,我们将创建一个产品列表,其价格已经降低,可以直接添加到购物车进行快速购买:

/*** src/screens/Sales.js ***/

import React from 'react';
import { ScrollView, Image } from 'react-native';

import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';

import {
  Icon,
  Card,
  CardItem,
  Left,
  Body,
  Text,
  Button,
  Right,
  Title,
} from 'native-base';
import * as ProductActions from '../reducers/products';

class Sales extends React.Component {
  static navigationOptions = {
    drawerLabel: 'Sales',
    tabBarIcon: () => <Icon name="home" />,
  };

  onBuyPress(product) {
 this.props.addProductToCart(product);
 setTimeout(() => this.props.navigation.navigate
    ('MyCart', { product }), 0);
 }

  render() {
    return (
      <ScrollView style={{ padding: 20 }}>
        {this.props.products.filter(p => p.discount).map(product => (
          <Card key={product.id}>
            <CardItem>
              <Left>
                <Body>
                  <Text>{product.name}</Text>
                  <Text note>{product.author}</Text>
                </Body>
              </Left>
            </CardItem>
            <CardItem cardBody>
              <Image
                source={{ uri: product.img }}
                style={{ height: 200, width: null, flex: 1 }}
              />
            </CardItem>
            <CardItem>
              <Left>
                <Title>${product.price}</Title>
              </Left>
              <Body>
                <Button transparent onPress={() => 
                 this.onBuyPress(product)}>
 <Text>Add to cart</Text>
 </Button>
              </Body>
              <Right>
                <Text style={{ color: 'red' }}>
                 {product.discount} off!</Text>
              </Right>
            </CardItem>
          </Card>
        ))}
      </ScrollView>
    );
  }
}

Sales.propTypes = {
 products: PropTypes.array.isRequired,
 addProductToCart: PropTypes.func.isRequired,
 navigation: PropTypes.any.isRequired,
};

function mapStateToProps(state) {
  return {
    products: state.productsReducer.products || [],
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(ProductActions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(Sales);

我们将使用 Redux 状态中已经存储的可用产品的完整列表,通过降价来筛选并映射成一个吸引人的列表项,通过触发onBuyPress()方法将其添加到购物车,这将触发addProductToCart()

onBuyPress(product) {
    this.props.addProductToCart(product);
    setTimeout(() => this.props.navigation.navigate('MyCart',
                                                    { product }), 0);
}

除了触发这个 Redux 动作,onBuyPress()还导航到MyCart屏幕,但是在清除调用堆栈后才这样做,以确保产品已正确添加到购物车中。

在这个阶段,购买旅程将再次开始,允许用户登录(如果尚未登录),支付商品,并确认购买。

总结

在本章中,我们开发了大多数电子商务应用程序中常见的几个功能,例如用户登录和注册、从 API 检索数据、购买流程和支付。

我们通过 Redux 将所有屏幕与一个共同的应用程序状态绑定在一起,这使得该应用程序具有可扩展性和易于维护性。

考虑到可维护性,我们为所有组件和屏幕添加了属性验证。此外,我们使用 ESLint 强制执行标准代码格式和代码检查,以便应用程序可以轻松地为各种团队成员对齐和开发新功能或维护当前功能。

最后,我们还为开发人员添加了 API 模拟,以便他们在构建移动应用程序时可以在本地工作,而无需后端支持。

posted @ 2024-05-16 14:49  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报