React-项目-四-

70 阅读58分钟

React 项目(四)

原文:zh.annas-archive.org/md5/67d21690ff58712c68c8d6f205c8e0a0

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:使用 React Native 和 Expo 创建实时消息应用程序

与服务器建立实时连接在开发实时消息应用程序时至关重要,因为您希望用户在发送消息后尽快收到消息。您可能在前两章中经历过的是,移动应用程序比 Web 应用程序更直观。当您希望用户来回发送消息时,最好的方法是构建一个移动应用程序,这就是本章将要做的事情。

在这一章中,您将使用 React Native 和 Expo 创建一个实时移动消息应用程序,该应用程序与 GraphQL 服务器连接。通过使用 WebSockets,您可以为 Web 和移动应用程序与服务器创建实时连接,并在应用程序和 GraphQL 服务器之间实现双向数据流。这种连接也可以用于身份验证,使用 OAuth 和 JWT 令牌,这就是您在第七章中所做的事情,使用 React Native 和 GraphQL 构建全栈电子商务应用程序

本章将涵盖以下主题:

  • 使用 Apollo 的 React Native 中的 GraphQL

  • React Native 中的身份验证流程

  • GraphQL 订阅

项目概述

在本章中,我们将使用 React Native 和 Expo 创建一个移动消息应用程序,该应用程序使用 GraphQL 服务器进行身份验证并发送和接收消息。通过使用 Apollo 创建的 WebSocket,可以实时接收消息,因为使用了 GraphQL 订阅。用户需要登录才能通过应用程序发送消息,为此使用了 React Navigation 和 AsyncStorage 构建了身份验证流程,以将身份验证详细信息存储在持久存储中。

构建时间为 2 小时。

入门

我们将在本章中创建的项目是在初始版本的基础上构建的,您可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch10-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch10

您需要在移动 iOS 或 Android 设备上安装应用程序 Expo Client,以在物理设备上运行该项目。或者,您可以在计算机上安装 Xcode 或 Android Studio,以在虚拟设备上运行应用程序:

export ANDROID_SDK=**ANDROID_SDK_LOCATION**export PATH=**ANDROID_SDK_LOCATION**/platform-tools:$PATH export PATH=**ANDROID_SDK_LOCATION**/tools:$PATH

ANDROID_SDK_LOCATION的值是您本地计算机上 Android SDK 的路径,可以通过打开 Android Studio 并转到首选项|外观和行为|系统设置|Android SDK来找到。路径在声明 Android SDK 位置的框中列出,看起来像这样:/Users/myuser/Library/Android/sdk

该应用程序是使用 Expo SDK 版本 33.0.0 创建的,因此,您需要确保您在本地机器上使用的 Expo 版本是相似的。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以便本章描述的模式表现如预期。如果您的应用程序无法启动或遇到错误,请参考 Expo 文档以了解有关更新 Expo SDK 的更多信息。

检查初始项目

该项目由两部分组成:一个样板 React Native 应用程序和一个 GraphQL 服务器。React Native 应用程序可以在client目录中找到,而 GraphQL 服务器可以在server目录中找到。对于本章,您需要始终同时运行应用程序和服务器,您只会对client目录中的应用程序进行代码更改。

要开始本章,您需要在clientserver目录中运行以下命令,以安装所有依赖项并启动服务器和应用程序:

npm install && npm start

对于移动应用程序,此命令将在安装依赖项后启动 Expo,并使您能够从终端或浏览器启动项目。在终端中,您可以使用 QR 码在移动设备上打开应用程序,也可以在虚拟设备上打开应用程序。

无论您是使用物理设备还是虚拟 iOS 或 Android 设备打开应用程序,应用程序应该看起来像这样:

初始应用程序包括五个屏幕:AuthLoadingConversationsConversationLoginSettingsConversations屏幕将是初始屏幕,并显示加载消息,而Settings屏幕包含一个不起作用的注销按钮。目前,AuthLoadingConversationLogin屏幕尚不可见,因为您将在本章后面为这些屏幕添加路由。

client目录中,此 React Native 应用程序的项目结构如下,结构类似于您在本书中之前创建的项目:

messaging
|-- client
    |-- .expo
    |-- assets
        |-- icon.png
        |-- splash.png
    |-- Components
        |-- // ...
    |-- node_modules
    |-- Screens
        |-- AuthLoading.js
        |-- Conversation.js
        |-- Conversations.js
        |-- Login.js
        |-- Settings.js
    |-- .watchmanconfig
    |-- App.js
    |-- AppContainer.js
    |-- app.json
    |-- babel.config.js
    |-- package.json

assets目录中,您可以找到用于主屏幕应用程序图标的图像。一旦您在移动设备上安装了此应用程序,启动应用程序时将显示用作启动画面的图像。有关应用程序的详细信息,如名称、描述和版本,都放在app.json中,而babel.config.js包含特定的 Babel 配置。

App.js文件是您的应用程序的实际入口点,其中导入并返回AppContainer.js文件。在AppContainer中,定义了此应用程序的所有路由,并且AppContext将包含应该在整个应用程序中可用的信息。

此应用程序的所有组件都位于ScreensComponents目录中,其中第一个包含由屏幕呈现的组件。这些屏幕的子组件可以在Components目录中找到,其结构如下:

|-- Components
    |-- Button
        |-- Button.js
    |-- Conversation
        |-- ConversationActions.js
        |-- ConversationItem.js
    |-- Message
        |-- Message.js
    |-- TextInput
        |-- TextInput.js

GraphQL 服务器位于:http://localhost:4000/graphql,GraphQL Playground 将在此处可见。通过这个 Playground,您可以查看 GraphQL 服务器的模式,并审查所有可用的查询、变异和订阅。虽然您不会对服务器进行任何代码更改,但了解模式及其工作原理是很重要的。

服务器有两个查询,一个是通过使用userName参数作为标识符来检索对话列表,另一个是检索单个对话。这些查询将返回Conversation类型,其中包括iduserNameMessage类型的消息列表。

在这个 GraphQL 服务器上,可以找到两个变异,一个是登录用户,另一个是发送消息。用户可以通过以下方式登录:

  • 用户名test

  • 密码test

最后,有一个订阅将检索添加到对话中的消息。这个订阅将增强查询,并可以发送到一个文档中以检索单个对话。

使用 React Native 和 Expo 创建实时消息应用程序

移动应用程序受欢迎的原因之一是它们通常提供实时数据,例如更新和通知。使用 React Native 和 Expo,您可以创建能够使用 WebSockets 处理实时数据的移动应用程序,例如与 GraphQL 服务器同步。在本章中,您将向 React Native 应用程序添加 GraphQL,并为该应用程序添加额外功能,使其能够处理实时数据。

使用 Apollo 在 React Native 中使用 GraphQL

第七章中,使用 React Native 和 GraphQL 构建全栈电子商务应用程序,您已经为 Web 应用程序建立了与 GraphQL 服务器的连接;同样,在本章中,您将为移动应用程序中的数据使用 GraphQL 服务器。要在 React Native 应用程序中使用 GraphQL,您可以使用 Apollo 来使开发人员的体验更加顺畅。

在 React Native 中设置 Apollo

react-apollo包,你已经在 React web 应用程序中使用过 Apollo,也可以在 React Native 移动应用程序中使用。这与 React 和 React Native 的标语“学一次,随处编写”完美契合。但在将 Apollo 添加到应用程序之前,重要的是要知道,当你在移动设备上使用 Expo 应用程序运行应用程序时,不支持本地主机请求。该项目的本地 GraphQL 服务器正在运行在http://localhost:4000/graphql,但为了能够在 React Native 应用程序中使用这个端点,你需要找到你的机器的本地 IP 地址。

要找到你的本地 IP 地址,你需要根据你的操作系统做以下操作:

  • 对于 Windows:打开终端(或命令提示符)并运行这个命令:
ipconfig

这将返回一个列表,如下所示,其中包含来自本地机器的数据。在这个列表中,你需要查找IPv4 Address字段:

  • 对于 macOS:打开终端并运行这个命令:
ipconfig getifaddr en0

运行这个命令后,你的机器的本地Ipv4 Address将被返回,看起来像这样:

192.168.1.107

获取本地 IP 地址后,你可以使用这个地址来为 React Native 应用程序设置 Apollo 客户端。为了能够使用 Apollo 和 GraphQL,你需要使用以下命令从npm安装npm中的几个包。你需要在一个单独的终端标签中从client目录中执行这个命令:

cd client && npm install graphql apollo-client apollo-link-http apollo-cache-inmemory react-apollo

App.js文件中,你现在可以使用apollo-client来创建你的 GraphQL 客户端,使用apollo-link-http来设置与本地 GraphQL 服务器的连接,并使用apollo-cache-inmemory来缓存你的 GraphQL 请求。此外,ApolloProvider组件将使用你创建的客户端,使 GraphQL 服务器对所有嵌套在此提供程序中的组件可用。必须使用本地 IP 地址来创建API_URL的值,前缀为http://,后缀为:4000/graphql,指向正确的端口和端点,使其看起来像http://192.168.1.107:4000/graphql

为了做到这一点,将以下行添加到App.js中:

import React from 'react';
import AppContainer from './AppContainer';
+ import { ApolloClient } from 'apollo-client';
+ import { InMemoryCache } from 'apollo-cache-inmemory';
+ import { HttpLink } from 'apollo-link-http';
+ import { ApolloProvider } from 'react-apollo';

+ const API_URL = 'http://192.168.1.107:4000/graphql';

+ const cache = new InMemoryCache();
+ const client = new ApolloClient({
+   link: new HttpLink({
+     uri: API_URL,
+   }),
+   cache
+ });

- const App = () => <AppContainer />;

+ const App = () => (
+  <ApolloProvider client={client}>
+     <AppContainer />
+  </ApolloProvider>
+ );

export default App;

现在,您可以从ApolloProvider中的任何嵌套组件发送带有查询和变异的文档,但是您还不能在文档中发送订阅。订阅的支持并不是开箱即用的,需要为客户端 React Native 应用程序和 GraphQL 服务器之间的实时双向连接设置 WebSocket。这将在本章后面完成,之后您将为应用程序添加认证。

在本节的下一部分中,您将使用 Apollo 从 GraphQL 服务器获取数据,您刚刚在本节中将其链接到 Apollo Client。

在 React Native 中使用 Apollo

如果您查看应用程序,您会看到有两个选项卡;一个显示Conversations屏幕,另一个显示Settings屏幕。Conversations屏幕现在显示文本Loading...,应该显示从 GraphQL 服务器返回的对话。用于显示对话的组件已经创建,可以在client/Components/Conversation目录中找到,而请求对话的逻辑仍需要创建。

要添加 Apollo,请按照以下步骤:

  1. 第一步是从react-apollo中导入Query组件到client/Screens/Conversations.js文件中,您将使用它向 GraphQL 服务器发送文档。这个Query组件将使用GET_CONVERSATIONS查询,ConversationItem组件也必须被导入:
import  React  from 'react'; import { FlatList, Text, View } from 'react-native'; import  styled  from 'styled-components/native'; + import { Query } from 'react-apollo';  + import { GET_CONVERSATIONS } from '../constants'; + import  ConversationItem  from '../Components/Conversations/ConversationItem'; ... const  Conversations  = () => (
 ...
  1. Conversations屏幕现在应该使用Query组件请求GET_CONVERSATIONS查询。当请求未解决时,将显示加载消息。当向 GraphQL 服务器的请求解决时,样式化的Flatlist将返回导入的ConversationItem组件列表。样式化的Flatlist已经创建,可以在该文件底部的ConversationsList组件中找到:
...

const  Conversations  = () => (  <ConversationsWrapper> - <ConversationsText>Loading...</ConversationsText> +   <Query query={GET_CONVERSATIONS}> +     {({ loading, data }) => { +       if (loading) { +         return <ConversationsText>Loading...</ConversationsText> +       } +       return ( +         <ConversationsList +           data={data.conversations} +           keyExtractor={item => item.userName} +           renderItem={({ item }) => <ConversationItem item={item} /> } +         /> +       ); +     }} +   </Query>  </ConversationsWrapper> ); export default Conversations;

Conversations屏幕最初显示加载消息,当发送带有查询的文档时;在查询返回数据后,将显示ConversationsList组件。该组件呈现显示查询数据的ConversationItem组件。

  1. 当您尝试点击任何对话时,除了看到一个改变不透明度的小动画之外,什么也不会发生。这是因为ConversationItem组件是一个样式化的TouchableOpacity,当您点击它时可以作为一个被调用的函数传递。用于导航到对话的函数可以从Conversations屏幕中可用的navigation属性中创建。这个属性应该作为一个属性传递给ConversationItem
...

- const  Conversations  = () => ( + const  Conversations  = ({ navigation ) => **(** <ConversationsWrapper>
  <ConversationsText>Loading...</ConversationsText>
 <Query query={GET_CONVERSATIONS}> {({ loading, data }) => { if (loading) { return <ConversationsText>Loading...</ConversationsText> } return ( <ConversationsList data={data.conversations} keyExtractor={item => item.userName} -             renderItem={({ item }) => <ConversationItem item={item} /> }
+ renderItem={({ item }) => <ConversationItem item={item} navigation={navigation} />}  /> ); }} </Query>  </ConversationsWrapper> ); export default Conversations;
  1. ConversationItem组件现在可以在点击TouchableOpacity时导航到Conversation屏幕;这个组件可以在client/Components/Conversation/ConversationItem.js文件中找到,其中应该解构并使用navigation属性来调用onPress处理程序上的navigate函数。这个项目被传递给navigate函数,以便这些数据可以在Conversation屏幕中使用:
import  React  from 'react'; import { Platform, Text, View, TouchableOpacity } from 'react-native'; import { Ionicons }  from '@expo/vector-icons'; import  styled  from 'styled-components/native';

... - const ConversationItem = ({ item }) => ( + const  ConversationItem  = ({ item, navigation }) => ( -   <ConversationItemWrapper> +   <ConversationItemWrapper +     onPress={() =>  navigation.navigate('Conversation', { item })} **+   >**
      <ThumbnailWrapper>
        ... 
  1. 这将从client/Screens/Conversation.js文件中导航到Conversation屏幕,其中应该显示完整的对话。要显示对话,您可以使用刚刚传递到此屏幕的项目数据,或者发送另一个包含检索对话的查询的文档到 GraphQL 服务器。为了确保显示最新的数据,Query组件可以用来发送一个查询,使用从navigation属性中的userName字段来检索对话。为了做到这一点,您需要导入Query组件、Query使用的GET_CONVERSATION查询,以及用于显示对话中消息的Message组件:
import  React  from 'react'; import { Dimensions, ScrollView, Text, FlatList, View } from 'react-native'; + import { Query } from 'react-apollo'; import  styled  from 'styled-components/native'; + import  Message  from '../Components/Message/Message'; + import { GET_CONVERSATION } from '../constants'**;**

... const  Conversation  = () => (  ...
  1. 在此之后,您可以将Query组件添加到Conversation屏幕,并让它使用从navigation属性中检索到的userNameGET_CONVERSATION查询。一旦查询解析,Query组件将返回一个带有名为messages的字段的data对象。这个值可以传递给FlatList组件。在这个组件中,您可以遍历这个值并返回显示对话中所有消息的Message组件。FlatList已经被样式化,并且可以在文件底部找到,命名为MessagesList
... - const  Conversation  = () => { + const  Conversation  = ({ navigation }) => { +   const  userName  =  navigation.getParam('userName', '');  + return **(** <ConversationWrapper>  -       <ConversationBodyText>Loading...</ConversationBodyText> +       <Query query={GET_CONVERSATION} variables={{ userName }}>        <ConversationBody> +         {({ loading, data }) => { +           if (loading) { +             return <ConversationBodyText>Loading...</ConversationBodyText>; +           } +           const { messages } = data.conversation;
  +           <MessagesList
+ data={messages}
+ keyExtractor={item  =>  String(item.id)}
+ renderItem={({ item }) => (
+ <Message  align={item.userName === 'me' ? 'left' : 'right'}>
+ {item.text}
+ </Message>
+ )}
+ />  +         }}        </ConversationBody>**+     </Query>**  <ConversationActions userName={userName}  />
 </ConversationWrapper>
 ); + }; export default Conversation;

现在正在显示来自这次对话的所有接收到的消息,并且可以使用屏幕底部的表单向对话中添加新消息。

根据您运行应用程序的设备,运行 iOS 设备的ConversationConversation屏幕应该看起来像这样:

然而,要发送消息,必须向 GraphQL 服务器发送带有突变的文档,并且用户必须经过身份验证。如何处理此突变的身份验证将在下一节中讨论,身份验证流程将被添加。

React Native 中的身份验证

通常,移动应用程序的身份验证类似于在 Web 应用程序中处理身份验证,尽管存在一些细微差异。在移动应用程序上对用户进行身份验证的流程如下:

  1. 用户打开您的应用程序

  2. 显示检查持久存储中的任何身份验证信息的加载屏幕

  3. 如果经过身份验证,用户将被转发到应用程序的主屏幕;否则,他们将被转发到登录屏幕,用户可以在那里登录

  4. 每当用户退出登录时,身份验证详细信息将从持久存储中删除

这种流程的最大缺点之一是移动设备不支持本地存储或会话存储,因为这些持久存储解决方案与浏览器绑定。相反,您需要使用 React Native 中的AsyncStorage库在 iOS 和 Android 上实现持久存储。在 iOS 上,它将使用本机代码块为您提供AsyncStorage提供的全局持久存储,而在运行 Android 的设备上,将使用基于 RockDB 或 SQLite 的存储。

对于更复杂的用法,建议在AsyncStorage的顶层使用抽象层,因为AsyncStorage不支持加密。此外,如果要使用AsyncStorage为应用程序存储大量信息,键值系统的使用可能会导致性能问题。iOS 和 Android 都会对每个应用程序可以使用的存储量设置限制。

使用 React Navigation 进行身份验证

要设置我们之前描述的身份验证流程,你将再次使用 React Navigation 包。之前,你使用了 React Navigation 中的不同类型的导航器,但没有使用SwitchNavigator。使用这种导航器类型,你只能一次显示一个屏幕,并且可以使用navigation属性导航到其他屏幕。SwitchNavigator应该是你的应用程序的主要导航器,其他导航器如StackNavigator可以嵌套在其中。

向 React Native 应用程序添加身份验证涉及执行以下步骤:

  1. 使用这种导航器类型的第一步是从react-navigation导入createSwitchNavigator,就像你将其他导航器导入到client/AppContainer.js文件中一样。还要导入登录屏幕的屏幕组件,可以在client/Screens/Login.js中找到:
import  React  from 'react'; import { Platform } from 'react-native'; import { Ionicons }  from '@expo/vector-icons'; import {  + createSwitchContainer,    createAppContainer  } from 'react-navigation'; import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';
import  Conversations  from './Screens/Conversations'; import  Conversation  from './Screens/Conversation'; import  Settings  from './Screens/Settings'; + import  Login  from './Screens/Login'**;** const  ConversationsStack  =  createStackNavigator({
  ... 
  1. 不要在此文件底部用createAppContainer包装TabNavigator,而是需要返回SwitchNavigator。要创建这个,你需要使用在上一步中导入的createSwitchNavigator。这个导航器包含Login屏幕和TabNavigator,后者是这个应用程序的主屏幕。为了让用户只在经过身份验证时看到主屏幕,Login屏幕需要成为初始屏幕:
...

+ const SwitchNavigator = createSwitchNavigator( +   { +     Main: TabNavigator, +     Auth: Login +   }, +   { +     initialRouteName: 'Auth', +   } + ); - export default createAppContainer(TabNavigator); + export default createAppContainer(SwitchNavigator);

现在在应用程序中显示的Login屏幕只有在填写正确的身份验证详细信息时才会切换到TabNavigator

  1. 但是,此表单首先需要连接到 GraphQL 服务器,以接收身份验证所需的 JWT 令牌。Login屏幕的组件已经有一个表单,但是提交此表单尚未调用任何函数来对用户进行身份验证。因此,你需要使用react-apollo中的Mutation组件,并让该组件向 GraphQL 服务器发送包含正确变异的文档。需要添加到此组件的变异可以在constants.js文件中找到,称为LOGIN_USER。要提交表单,应该在用户按下Button时调用Mutation组件返回的loginUser函数:
import React from 'react';
import { View, TextInput } from 'react-native';
import styled from 'styled-components/native';
+ import { Mutation } from 'react-apollo';
import Button from '../Components/Button/Button';
+ import { LOGIN_USER } from '../constants';

... const Login = () => {
 const [userName, setUserName] = React.useState('');
 const [password, setPassword] = React.useState('');

 return (
+  <Mutation mutation={LOGIN_USER}>
+    {loginUser => (
       <LoginWrapper>
          <StyledTextInput
            onChangeText={setUserName}
            value={userName}
            placeholder='Your username'
            textContentType='username'
          />
          <StyledTextInput
            onChangeText={setPassword}
            value={password}
            placeholder='Your password'
            textContentType='password'
          />
          <Button
            title='Login'
+           onPress={() => loginUser({ variables: { userName, password } })}
          />
        </LoginWrapper>
+    )}
+  </Mutation>
 );
};

export default Login;

两个TextInput组件都是受控组件,并使用useState钩子来控制它们的值。用于此变异的userNamepassword常量都使用两个变量进行身份验证,这两个变量也是userNamepassword

... export  const  LOGIN_USER  =  gql`
 mutation loginUser($userName: String!, $password: String!) {
   loginUser(userName: $userName, password: $password) {
     userName
     token
   }
 }
`;
...
  1. 除了loginUser函数之外,该函数发送了一个文档中的变化,Mutation组件还会返回由 GraphQL 服务器返回的loadingerrordata变量。loading变量可用于向用户传达文档已发送到服务器,而当 GraphQL 服务器对此文档做出响应时,将返回dataerror变量:
import React from 'react';
import { View, TextInput } from 'react-native';
import styled from 'styled-components/native';
import { Mutation } from 'react-apollo';
import Button from '../Components/Button/Button';
import { LOGIN_USER } from '../constants'; ... const Login = () => {
 const [userName, setUserName] = React.useState('');
 const [password, setPassword] = React.useState('');

 return (
  <Mutation mutation={LOGIN_USER}>
-    {loginUser => (
+    {(loginUser, { loading }) => (  <LoginWrapper>
          <StyledTextInput
            onChangeText={setUserName}
            value={userName}
            placeholder='Your username'
            textContentType='username'
          />
          <StyledTextInput
            onChangeText={setPassword}
            value={password}
            placeholder='Your password'
            textContentType='password'
          />
          <Button
-           title='Login'
+           title={loading ? 'Loading...' : 'Login'}
            onPress={() => loginUser({ variables: { userName, password } })}
          />
       </LoginWrapper>
    }}
   </Mutation>
 );
};

export default Login;

当文档发送到 GraphQL 服务器并且尚未返回响应时,这将会改变表单底部按钮的文本为Loading...

  1. 要使用error变量在填写错误凭据时显示错误消息,您不会从Mutation组件的输出中解构该变量。相反,错误变量将从loginUser函数返回的Promise中检索。为了显示错误,您将使用error变量中可用的graphQLErrors方法,该方法返回一个数组(因为可能存在多个错误),并在 React Native 的Alert组件中呈现错误:
import React from 'react';
- import { View, TextInput } from 'react-native';
+ import { Alert, View, TextInput } from 'react-native';
import styled from 'styled-components/native';
import { Mutation } from 'react-apollo';
import Button from '../Components/Button/Button';
import { LOGIN_USER } from '../constants';

...

 <Button
   title={loading ? 'Loading...' : 'Login'}
   onPress={() => {     loginUser({ variables: { userName, password } })
**+** .catch(error  => {
+ Alert.alert(
+         'Error',
+         error.graphQLErrors.map(({ message }) =>  message)[0] +        );
+    });
   }}
 />

...
  1. 当使用正确的用户名和密码组合时,应使用data变量来存储由 GraphQL 服务器返回的 JWT 令牌。就像从loginUser函数中检索的error变量一样,data变量也可以从这个Promise中检索。这个令牌可用于data变量,并且应该被安全地存储,可以使用AsyncStorage库来实现:
import  React  from 'react';  - import { Alert, View, TextInput } from 'react-native';
+ import { AsyncStorage, Alert, View, TextInput } from 'react-native';  import  styled  from 'styled-components/native';  import { Mutation } from 'react-apollo';  import  Button  from '../Components/Button/Button';  import { LOGIN_USER } from '../constants'; ... const  Login  = ({ navigation }) => {
  ... 
  <Button
    title={loading ? 'Loading...' : 'Login'}
    onPress={() => {      loginUser({ variables: { userName, password } }) +       .then(({data}) => { +         const { token } = data.loginUser; +         AsyncStorage.setItem('token', token);  +       })
        .catch(error  => {         if (error) {
            Alert.alert(
              'Error',
              error.graphQLErrors.map(({ message }) =>  message)[0], );
          }
        });
      }}
    /> 
    ...
  1. 存储令牌后,用户应被重定向到主应用程序,该应用程序可以在Main路由中找到,并表示与TabNavigator相关联的屏幕。要重定向用户,您可以使用SwitchNavigator通过传递给Login组件的navigation属性。由于使用AsyncStorage存储东西应该是异步的,因此应该从AsyncStorage返回的Promise的回调中调用导航函数:
import  React  from 'react';  import { AsyncStorage, Alert, View, TextInput } from 'react-native';  import  styled  from 'styled-components/native';  import { Mutation } from 'react-apollo';  import  Button  from '../Components/Button/Button';  import { LOGIN_USER } from '../constants'; ... - const  Login  = () => { + const  Login  = ({ navigation }) => { ... 
<Button
 title={loading ? 'Loading...' : 'Login'}
 onPress={() => { loginUser({ variables: { userName, password } })  .then(({data}) => {    const { token } = data.loginUser;
**-** AsyncStorage.setItem('token', token) +   AsyncStorage.setItem('token', token).then(value  => { +     navigation.navigate('Main'); +   });    })
  .catch(error  => { if (error) { Alert.alert( 'Error', error.graphQLErrors.map(({ message }) =>  message)[0], );
    }
  });
 }} />

...

然而,这只完成了认证流程的一部分,因为当应用程序首次渲染时,Login屏幕将始终显示。这样,用户始终必须使用他们的认证详细信息登录,即使他们的 JWT 令牌存储在持久存储中。

要检查用户以前是否已登录,必须向SwitchNavigator中添加第三个屏幕。这个屏幕将确定用户是否在持久存储中存储了令牌,如果有,用户将立即重定向到Main路由。如果用户以前没有登录,则会重定向到你刚刚创建的Login屏幕:

  1. 确定是否在持久存储中存储了身份验证令牌的中间屏幕,即AuthLoading屏幕,应该在App.js中添加到SwitchNavigator中。这个屏幕也应该成为导航器提供的初始路由:
import  React  from 'react';  import { Platform } from 'react-native';  import { Ionicons }  from '@expo/vector-icons';  import {   createSwitchNavigator,
  createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';  import  Conversations  from './Screens/Conversations';  import  Conversation  from './Screens/Conversation';  import  Settings  from './Screens/Settings';  import  Login  from './Screens/Login';  + import  AuthLoading  from './Screens/AuthLoading'; const  ConversationsStack  =  createStackNavigator({

  ...   const  SwitchNavigator  =  createSwitchNavigator(
  {
    Main:  TabNavigator,    Login,
**+   AuthLoading,**
  },
  {
-   initialRouteName: 'Login',
+   initialRouteName: 'AuthLoading',
  }
);export default createAppContainer(SwitchNavigator);
  1. 在这个AuthLoading屏幕中,应该从持久存储中检索身份验证令牌,然后处理导航到LoginMain屏幕。这个屏幕可以在client/Screens/AuthLoading.js文件中找到,那里只添加了一个简单的界面。可以使用AsyncStorage库中的getItem方法来检索令牌,并且应该从useEffect Hook 中调用,以便在首次加载AuthLoading屏幕时检索它。从callbackPromise返回的getItem中,使用navigation属性的navigate函数来实际导航到这些屏幕中的任何一个:
import  React  from 'react';  - import { Text, View } from 'react-native'; + import { AsyncStorage, Text, View } from 'react-native'; import  styled  from 'styled-components/native'; ... - const AuthLoading = () => ( + const  AuthLoading  = ({ navigation }) => { + React.useEffect(() => { + AsyncStorage.getItem('token').then(value  => { +       navigation.navigate(value  ? 'Main'  : 'Auth'); +     }); +   }, [navigation]); +   return **(** <AuthLoadingWrapper> <AuthLoadingText>Loading...</AuthLoadingText> </AuthLoadingWrapper>
 ); **+ };**

export default AuthLoading;
  1. 完成身份验证流程的最后一步是通过从持久存储中删除令牌来为用户添加注销应用的可能性。这是在client/Screens/Settings.js文件中完成的。这会呈现TabNavigator中的Settings屏幕。Settings屏幕上有一个绿色按钮,你可以在上面设置onPress事件。

AsyncStorageremoveItem方法可用于从持久存储中删除令牌,并返回Promise。在这个Promise的回调中,你可以再次处理导航,以返回到Login屏幕,因为你不希望未经身份验证的用户在你的应用中。

import  React  from 'react';  - import { Text, View } from 'react-native'; + import { AsyncStorage, Text, View } from 'react-native';  import  styled  from 'styled-components/native';  import  Button  from '../Components/Button/Button'; ... - const Settings = () => ( + const  Settings  = ({ navigation }) => **(**
      <SettingsWrapper> - <Button title='Log out' /> +       <Button +         title='Log out' +         onPress={() => { +           AsyncStorage.removeItem('token').then(() =>  navigation.navigate('AuthLoading')); +         }} **+       />**
 </SettingsWrapper>
 );

export default Settings;

通过添加注销功能,您已经完成了使用 GraphQL 服务器返回的 JWT 令牌的身份验证流程。这可以通过在“登录”屏幕上填写表单来请求。如果身份验证成功,用户将被重定向到“主”屏幕,并且通过“设置”屏幕上的“注销”按钮,用户可以注销并将被重定向回“登录”屏幕。最终的身份验证流程现在看起来可能是这样的,具体取决于您在哪个操作系统上运行此应用程序。以下屏幕截图是从运行 iOS 的设备上获取的:

然而,为了 GraphQL 服务器知道这个用户是否经过身份验证,您需要向其发送一个验证令牌。在本节的下一部分,您将学习如何通过使用 JSON Web Token(JWT)来实现这一点。

向 GraphQL 服务器发送身份验证详细信息

现在存储在持久存储中的身份验证详细信息也应该添加到 Apollo Client 中,以便在每个文档中与 GraphQL 服务器一起发送。这可以通过扩展 Apollo Client 的设置与令牌信息来完成。由于令牌是 JWT,因此应该以Bearer为前缀:

  1. 您需要安装一个 Apollo 包来处理向“上下文”添加值。setContext方法来自apollo-link-context包,您可以从npm安装该包:
npm install apollo-link-context
  1. 应该将apollo-link-context包导入到client/App.js文件中,其中创建了 Apollo 客户端。您需要分开为客户端创建HttpLink对象的构造,因为这个对象需要与创建的上下文结合使用:
import  React  from 'react';  import { ApolloClient } from 'apollo-client';  import { InMemoryCache } from 'apollo-cache-inmemory'; **+ import { setContext }  from 'apollo-link-context';** import { HttpLink } from 'apollo-link-http';  import { ApolloProvider } from 'react-apollo';  import  AppContainer  from './AppContainer'; const API_URL = '..'; + const  httpLink  =  new  HttpLink({ + uri: API_URL,**+ });** const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({ - link: new HttpLink({ -   uri: API_URL, - }), + link:  httpLink**,**
 cache, }); const  App  = () => (
 ...
  1. 之后,您可以使用setContext()方法来扩展发送到 GraphQL 服务器的标头,以便还可以包括可以从持久存储中检索的令牌。由于从AsyncStorage获取项目也是异步的,因此应该异步使用此方法。将返回的令牌必须以Bearer为前缀,因为 GraphQL 服务器期望以该格式接收 JWT 令牌:
import React from 'react';
+ import { AsyncStorage } from 'react-native';
import AppContainer from './AppContainer';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { setContext } from 'apollo-link-context';
import { HttpLink } from 'apollo-link-http';
import { ApolloProvider } from 'react-apollo';

const API_URL = '...';

const  httpLink  =  new  HttpLink({
  uri:  API_URL,  }); 
+ const  authLink  =  setContext(async (_, { headers }) => { +   const  token  =  await  AsyncStorage.getItem('token'); +   return { +     headers: { +       ...headers, +       authorization:  token  ?  `Bearer ${token}`  : '',  +     }
+   };
+ });  ...
  1. 在创建 Apollo Client 时用于link字段的httpLink现在应该与authLink结合,以便从AsyncStorage检索到的令牌在发送请求到 GraphQL 服务器时被添加到标头中:
...

const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({ - link: httpLink,
+ link:  authLink.concat(httpLink),  cache }); const  App  = () => (
  ...

现在,任何传递给 GraphQL 服务器的文档都可以使用通过应用程序登录表单检索到的令牌,这是在下一节中使用变异发送消息时所需的内容。

使用 Apollo 在 React Native 中处理订阅

在您可以继续并发送包含变异的文档到 GraphQL 服务器之前,我们需要设置 Apollo 以便处理订阅。为了处理订阅,需要为您的应用程序设置一个 WebSocket,这样可以在 GraphQL 服务器和您的应用程序之间建立实时的双向连接。这样,当您使用这个移动应用程序发送或接收消息时,您将收到即时反馈。

为 GraphQL 订阅设置 Apollo 客户端

要在您的 React Native 应用程序中使用订阅,您需要添加更多的软件包到项目中,例如,使其可能添加 WebSocket。这些软件包如下:

npm install apollo-link-ws subscriptions-transport-ws apollo-utilities

apollo-link-ws软件包帮助您创建到运行订阅的 GraphQL 服务器的链接,就像apollo-link-http为查询和变异所做的那样。subscriptions-transport-ws是运行apollo-link-ws所需的软件包,而apollo-utilities被添加以使用这些软件包上可用的方法,以便您可以将有关订阅的请求与查询或变异的请求分开。

安装这些软件包后,您需要按照以下步骤在应用程序中使用订阅:

  1. 您可以使用apollo-link-ws来添加链接到 GraphQL 服务器的创建。GraphQL 服务器的 URL 应该以ws://开头,而不是http://,因为它涉及与 WebSocket 的连接。在您的机器上运行的 GraphQL 服务器的 URL 看起来像ws://192.168.1.107/graphql,而不是http://192.168.1.107/graphql,必须添加到SOCKET_URL常量中:
import  React  from 'react'; import { AsyncStorage } from 'react-native'; import { ApolloClient } from 'apollo-client';  import { InMemoryCache } from 'apollo-cache-inmemory'; import { setContext } from 'apollo-link-context'; import { HttpLink } from 'apollo-link-http';  + import { split } from 'apollo-link';  import { ApolloProvider } from 'react-apollo';  import  AppContainer  from './AppContainer'; const API_URL = '...';
**+ const SOCKET_URL = 'ws://192.168.1.107/graphql';** ...

+ const  wsLink  =  new  WebSocketLink({ +   uri: SOCKET_URL,  +   options: { +     reconnect:  true, +   },
+ });

...
  1. 使用splitgetMainDefinition方法,可以通过将查询和变异与订阅分开来区分对 GraphQL 服务器的不同请求。这样,只有包含订阅的文档才会使用 WebSocket 发送,而查询和变异将使用默认流程:
import  React  from 'react'; import { AsyncStorage } from 'react-native'; import { ApolloClient } from 'apollo-client';  import { InMemoryCache } from 'apollo-cache-inmemory'; import { setContext } from 'apollo-link-context'; import { HttpLink } from 'apollo-link-http';  import { split } from 'apollo-link'; + import { WebSocketLink } from 'apollo-link-ws';  + import { getMainDefinition } from 'apollo-utilities';  import { ApolloProvider } from 'react-apollo';  import  AppContainer  from './AppContainer'; ... + const  link  =  split( +   ({ query }) => { +     const  definition  =  getMainDefinition(query);
+ +     return ( +       definition.kind  === 'OperationDefinition'  && definition.operation  === 'subscription' +     );
+   },
+   wsLink, +   httpLink,
+ );

const  cache  =  new  InMemoryCache(); const  client  =  new  ApolloClient({ - link: authLink.concat(httpLink),
+ link: authLink.concat(link),
 cache,
});

const  App  = () => (
 ...

现在 Apollo 的设置也支持订阅,您将在本节的下一部分中添加,其中Conversations屏幕将填充实时数据。

将订阅添加到 React Native

在您的本地 GraphQL 服务器上运行的服务器支持查询和订阅,以便您可以从特定用户返回对话。查询将返回完整的对话,而订阅将返回可能已发送或接收到的对话中的任何新消息。目前,Conversation屏幕只会发送一个带有查询的文档,如果您点击Conversations屏幕上显示的任何对话,它将返回与用户的对话。

订阅可以以多种方式添加到您的应用程序中;使用react-apollo中的Subscription组件是最简单的方法。但由于您已经使用client/Screens/Conversation.js中的Query组件检索对话,因此可以扩展Query组件以支持订阅:

  1. Conversation屏幕添加订阅的第一步是将屏幕拆分为多个组件。您可以通过在client/Components/Conversation目录中创建一个名为ConversationBody的新组件来实现这一点。该文件应该被命名为ConversationBody.js,并包含以下代码:
import  React  from 'react';  import  styled  from 'styled-components/native';  import { Dimensions, ScrollView, FlatList } from 'react-native';  import  Message  from '../Message/Message';  const  ConversationBodyWrapper  =  styled(ScrollView)`
 width: 100%; padding: 2%;
 display: flex; height: ${Dimensions.get('window').height * 0.6}; `; const  MessagesList  =  styled(FlatList)`
 width: 100%; `; const  ConversationBody  = ({  userName, messages }) => {  return ( <ConversationBodyWrapper> <MessagesList data={messages} keyExtractor={item  =>  String(item.id)} renderItem={({ item }) => ( <Message  align={item.userName === 'me' ? 'left' : 'right'}> {item.text} </Message> )} /> </ConversationBodyWrapper>
 ); };  export  default  ConversationBody;
  1. 创建了这个新组件之后,应该将其导入到client/Screens/Conversation.js文件中的Conversation屏幕中,以取代该文件中已经存在的ContainerBody组件。这也意味着一些导入变得过时,ContainerBody样式组件也可以被删除:
import  React  from 'react';  - import { Dimensions, ScrollView, Text, FlatList, View } from 'react-native';  + import { Text, View } from 'react-native';  import { Query } from 'react-apollo';  import  styled  from 'styled-components/native';  - import  Message  from '../Components/Message/Message'; + import ConversationBody from '../Components/Conversation/ConversationBody'; import { GET_CONVERSATION } from '../constants';   ... const  Conversation  = ({ navigation }) => { const  userName  =  navigation.getParam('userName', ''); return ( <ConversationWrapper> <Query query={GET_CONVERSATION} variables={{ userName }}> -       <ConversationBody>   {({ loading, data }) => { if (loading) { return <ConversationBodyText>Loading...</ConversationBodyText>; } const { messages } = data.conversation;  -           return ( -             <MessagesList
- data={messages}
- keyExtractor={item  =>  String(item.id)}
- renderItem={({ item }) => (
- <Message  align={item.userName === 'me' ? 'left' : 'right'}>
- {item.text}
- </Message>
- )}
- /> -           ); -         }} +         return <ConversationBody messages={messages} userName={userName} /> }} -     </ConversationBody>   </Query>  <ConversationActions userName={userName}  />
 </ConversationWrapper>
 ); };

export default Conversation;
  1. 现在,可以将检索订阅的逻辑添加到Query组件中,通过从中获取subscribeToMore方法。这个方法应该传递给ConversationBody组件,在那里它将被调用,从而检索发送或接收到的任何新消息:
 ...

  return ( <ConversationWrapper> <Query query={GET_CONVERSATION} variables={{ userName }}> -       {({ loading, data }) => {
+       {({ subscribeToMore, loading, data }) => {
 if (loading) { return <ConversationBodyText>Loading...</ConversationBodyText>; } const { messages } = data.conversation;  -         return <ConversationBody messages={messages} userName={userName} />
+         return (
+           <ConversationBody
+             messages={messages}
+             userName={userName}
+             subscribeToMore={subscribeToMore}
+           /> }} </Query>  <ConversationActions userName={userName}  />
 </ConversationWrapper>
 ); };
  1. ConversationBody组件中,现在可以使用subscribeToMore方法通过订阅来检索添加到对话中的任何新消息。要使用的订阅称为MESSAGES_ADDED,可以在client/constants.js文件中找到。它以userName作为变量:
import  React  from 'react';  import  styled  from 'styled-components/native';  import { Dimensions, ScrollView, FlatList } from 'react-native';  import  Message  from '../Message/Message';  + import { MESSAGE_ADDED } from '../../constants'; ... - const  ConversationBody  = ({  userName, messages }) => { + const  ConversationBody  = ({ subscribeToMore, userName, messages }) => **{**  return ( <ConversationBodyWrapper> <MessagesList data={messages} keyExtractor={item  =>  String(item.id)} renderItem={({ item }) => ( <Message  align={item.userName === 'me' ? 'left' : 'right'}> {item.text} </Message> )} /> </ConversationBodyWrapper>
 ); };

export default ConversationBody;
  1. 在导入订阅并从 props 中解构subscribeToMore方法之后,可以添加检索订阅的逻辑。应该从useEffect Hook 中调用subscribeToMore,并且仅当ConversationBody组件首次挂载时。任何新添加的消息都将导致Query组件重新渲染,这将使ConversationBody组件重新渲染,因此在useEffect Hook 中不需要检查任何更新:
... const  ConversationBody  = ({ subscribeToMore, userName, messages }) => { +  React.useEffect(() => { +    subscribeToMore({ +      document:  MESSAGE_ADDED, +      variables: { userName }, +      updateQuery: (previous, { subscriptionData }) => { +        if (!subscriptionData.data) { +          return  previous; +        }
+        const  messageAdded  =  subscriptionData.data.messageAdded;
+ +        return  Object.assign({}, previous, { +          conversation: { +            ...previous.conversation, +            messages: [...previous.conversation.messages, messageAdded] +          }
+        });
+     }
+   });
+ }, []);
   return ( <ConversationBodyWrapper>
 ...

subscribeToMore方法现在将使用MESSAGES_ADDED订阅来检查任何新消息,并将该订阅的结果添加到名为previous的对象上的Query组件中。本地 GraphQL 服务器将每隔几秒钟返回一条新消息,因此您可以通过打开对话并等待新消息出现在该对话中来查看订阅是否起作用。

除了查询,您还希望能够发送实时订阅。这将在本节的最后部分进行讨论。

使用订阅与突变

除了使用订阅来接收对话中的消息,它们还可以用于显示您自己发送的消息。以前,您可以在Mutation组件上使用refetchQueries属性来重新发送受到您执行的突变影响的任何查询的文档。通过使用订阅,您不再需要重新获取,例如,对话查询,因为订阅将获取您刚刚发送的新消息并将其添加到查询中。

在上一节中,您使用了来自react-apolloQuery组件向 GraphQL 服务器发送文档,而在本节中,将使用新的 React Apollo Hooks。

React Apollo Hooks 可以从react-apollo包中使用,但如果您只想使用 Hooks,可以通过执行npm install @apollo/react-hooks来安装@apollo/react-hooks。GraphQL 组件,如QueryMutation,在react-apollo@apollo/react-components包中都可用。使用这些包将减少捆绑包的大小,因为您只导入所需的功能。

这个包中的 Hooks 必须在ConversationActions组件中使用。这在Conversation屏幕组件中使用,该组件将包括输入消息的输入字段和发送消息的按钮。当您按下此按钮时,什么也不会发生,因为按钮未连接到变异。让我们连接这个按钮,看看订阅如何显示您发送的消息:

  1. useMutation Hook 应该被导入到client/Components/Conversation/ConversationActions.js文件中,该文件将用于将输入字段中的消息发送到 GraphQL 服务器。还必须导入将包含在您发送的文档中的变异,名为SEND_MESSAGE;这可以在client/constants.js文件中找到:
import  React  from 'react';  import { Platform, Text, View } from 'react-native';  import  styled  from 'styled-components/native';  import { Ionicons }  from '@expo/vector-icons';  + import { useMutation } from 'react-apollo'; import  TextInput  from '../TextInput/TextInput';  import  Button  from '../Button/Button';  + import { SEND_MESSAGE } from '../../constants'; ... const  ConversationActions  = ({ userName }) => {
  ...
  1. 这个useMutation Hook 现在可以用来包裹TextInputButton组件,来自 Hook 的sendMessage属性可以用来向 GraphQL 服务器发送带有消息的文档。TextInput的值由useState Hook 创建的setMessage函数控制,这个函数可以在发送变异后用来清除TextInput
...
const  ConversationActions  = ({ userName }) => { + const [sendMessage] = useMutation(SEND_MESSAGE);   const [message, setMessage] =  React.useState('');
 return ( <ConversationActionsWrapper> + **<>** <TextInput width={75} marginBottom={0} onChangeText={setMessage} placeholder='Your message' value={message} /> <Button width={20} padding={10}
**+** onPress={() => {
+ sendMessage({ variables: { to:  userName, text:  message } });
+ setMessage(''); +         }**}**
 title={ <Ionicons name={`${Platform.OS === 'ios' ? 'ios' : 'md'}-send`} size={42} color='white' /> } /> +     </>  +   </ConversationActionsWrapper**>**
 ); };

通过在文本字段中输入值并在之后按下发送按钮来发送消息,现在会更新对话,显示您刚刚发送的消息。但是,您可能会注意到,这个组件会在移动设备屏幕的大小上被键盘遮挡。通过使用react-native中的KeyboardAvoidingView组件,可以轻松避免这种行为。这个组件将确保输入字段显示在键盘区域之外。

  1. KeyboardAvoidingView组件可以从react-native中导入,并用于替换当前正在样式化为ConversationsActionsWrapper组件的View组件:
import  React  from 'react';  - import { Platform, Text, View } from 'react-native';  + import { Platform, Text, KeyboardAvoidingView } from 'react-native';  import  styled  from 'styled-components/native';  import { Ionicons }  from '@expo/vector-icons';  import { useMutation } from 'react-apollo';  import  TextInput  from '../TextInput/TextInput';  import  Button  from '../Button/Button';  import { SEND_MESSAGE } from '../../constants';  - const  ConversationActionsWrapper  =  styled(View)` + const  ConversationActionsWrapper  =  styled(KeyboardAvoidingView)**`**
    width: 100%;
    background-color: #ccc;
    padding: 2%;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-around;
`; const  ConversationActions  = ({ userName }) => {

 ... 
  1. 根据您的移动设备运行的平台,KeyboardAvoidingView组件可能仍然无法在键盘区域之外显示输入字段。但是,KeyboardAvoidingView组件可以使用keyboardVerticalOffsetbehavior属性进行自定义。对于 iOS 和 Android,这些属性的值应该不同;一般来说,Android 需要比 iOS 更小的偏移量。在这种情况下,keyboardVerticalOffset必须设置为190behavior必须设置为padding
...

const  ConversationActions  = ({ userName }) => { const [sendMessage] = useMutation(SEND_MESSAGE);
  const [message, setMessage] =  React.useState('');
 return ( -   <ConversationActionsWrapper +   <ConversationActionsWrapper +     keyboardVerticalOffset={Platform.OS === 'ios' ? 190 : 140} +     behavior=;padding' **+   >**
 <Mutation  mutation={SEND_MESSAGE}> ... 

KeyboardAvoidingView在 Android Studio 模拟器或运行 Android 的设备上可能无法按预期工作,因为可以运行 Android 操作系统的设备有许多不同的可能屏幕尺寸。

当您按下输入字段时,键盘将不再隐藏在键盘后面,您应该能够输入并发送一条消息,该消息将发送一个包含对 GraphQL 服务器的突变的文档。您的消息还将出现在先前显示的对话中。

摘要

在本章中,您构建了一个移动消息应用程序,可以用于与 GraphQL 服务器发送和接收消息。通过使用 GraphQL 订阅,消息可以实时接收,通过 WebSocket 接收消息。此外,还添加了移动身份验证流程,这意味着用户必须登录才能发送和接收消息。为此,使用AsyncStorage将 GraphQL 服务器返回的 JWT 令牌存储在持久存储中。

您在本章中构建的项目非常具有挑战性,但您将在下一章中创建的项目将更加先进。到目前为止,您已经处理了大多数 React Native 移动应用程序的核心功能,但还有更多内容。下一章将探讨如何使用 React Native 和 GraphQL 构建全栈应用程序,您将向社交媒体应用程序添加通知等功能。

进一步阅读

有关本章涵盖的更多信息,请查看以下资源:

第十一章:使用 React Native 和 GraphQL 构建全栈社交媒体应用程序

到目前为止,你几乎可以称自己是 React Native 的专家了,因为你即将开始在 React Native 部分中工作最复杂的应用程序。移动应用程序的一个巨大优势是,你可以直接向安装了你的应用程序的人发送通知。这样,你可以在应用程序中发生重要事件或有人很久没有使用应用程序时,针对用户。此外,移动应用程序可以直接使用设备的相机拍照和录像。

在上一章中,你创建了一个移动消息应用程序,具有身份验证流程和实时数据,并使用 React Native 的 GraphQL。这些模式和技术也将在本章中使用,以创建一个移动社交媒体应用程序,让你将图片发布到社交动态,并允许你对这些帖子进行点赞和评论。在本章中,使用相机不仅是一个重要的部分,还将添加使用 Expo 向用户发送通知的可能性。

本章将涵盖以下主题:

  • 使用 React Native 和 Expo 的相机

  • 使用 React Native 和 GraphQL 刷新数据

  • 使用 Expo 发送移动通知

项目概述

一个移动社交媒体应用程序,使用本地 GraphQL 服务器请求和添加帖子到社交动态,包括使用移动设备上的相机。使用本地 GraphQL 服务器和 React Navigation 添加基本身份验证,同时使用 Expo 访问相机(滚动)并在添加新评论时发送通知。

构建时间为 2 小时。

入门

我们将在本章中创建的项目基于 GitHub 上的初始版本:github.com/PacktPublishing/React-Projects/tree/ch11-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects/tree/ch11

你需要在移动 iOS 或 Android 设备上安装 Expo Client 应用程序,才能在物理设备上运行项目。

强烈建议使用 Expo Client 应用程序在物理设备上运行本章的项目。目前,仅支持在物理设备上接收通知,并且在 iOS 模拟器或 Android Studio 模拟器上运行项目将导致错误消息。

或者,您可以在计算机上安装 Xcode 或 Android Studio 来在虚拟设备上运行应用程序:

export ANDROID_SDK=**ANDROID_SDK_LOCATION** export PATH=**ANDROID_SDK_LOCATION**/platform-tools:$PATH export PATH=**ANDROID_SDK_LOCATION**/tools:$PATH

ANDROID_SDK_LOCATION的值是本地机器上 Android SDK 的路径,可以通过打开 Android Studio 并转到首选项|外观和行为|系统设置|Android SDK来找到。路径在声明 Android SDK 位置的框中列出,看起来像这样:/Users/myuser/Library/Android/sdk

该应用程序是使用Expo SDK 版本 33.0.0创建的,因此,您需要确保您在本地机器上使用的 Expo 版本类似。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以便本章中描述的模式表现如预期。如果您的应用程序无法启动或遇到错误,请参考 Expo 文档,了解有关更新 Expo SDK 的更多信息。

检出初始项目

该项目由两部分组成,一个是样板 React Native 应用程序,另一个是 GraphQL 服务器。 React Native 应用程序位于client目录中,而 GraphQL 服务器放置在server目录中。在本章中,您需要始终同时运行应用程序和服务器,而只对client目录中的应用程序进行代码更改。

要开始,您需要在clientserver目录中运行以下命令,以安装所有依赖项并启动服务器和应用程序:

npm install && npm start

对于移动应用程序,此命令将在安装依赖项后启动 Expo,并使您能够从终端或浏览器启动项目。在终端中,您现在可以使用 QR 码在移动设备上打开应用程序,或者在模拟器中打开应用程序。

此项目的本地 GraphQL 服务器正在运行http://localhost:4000/graphql/,但为了能够在 React Native 应用程序中使用此端点,您需要找到您机器的本地 IP 地址。

要查找本地 IP 地址,您需要根据您的操作系统执行以下操作:

  • 对于 Windows:打开终端(或命令提示符)并运行此命令:
ipconfig

这将返回一个类似下面所见的列表,其中包含来自您本地机器的数据。在此列表中,您需要查找字段IPv4 地址

  • 对于 macOS:打开终端并运行此命令:
ipconfig getifaddr en0

运行此命令后,将返回您机器的本地Ipv4 地址,看起来像这样:

192.168.1.107

必须使用本地 IP 地址来创建文件client/App.js中的API_URL的值,前缀为http://,后缀为/graphql,使其看起来像http://192.168.1.107/graphql

...

**- const API_URL = '';**
**+ const API_URL = 'http://192.168.1.107/graphql';**

const  httpLink  =  new  HttpLink({
 uri: API_URL,  }); const  authLink  =  setContext(async (_, { headers }) => {

  ...

无论您是从虚拟设备还是物理设备打开应用程序,此时应用程序应该看起来像这样:

此应用程序是使用Expo SDK 版本 33.0.0创建的,因此您需要确保您本地机器上使用的 Expo 版本类似。由于 React Native 和 Expo 经常更新,请确保您使用此版本,以确保本章中描述的模式表现如预期。如果您的应用程序无法启动或收到错误消息,请务必查看 Expo 文档,以了解有关更新 Expo SDK 的更多信息。

初始应用程序由七个屏幕组成:AddPostAuthLoadingLoginNotificationsPostPostsSettings。当首次启动应用程序时,您将看到Login屏幕,您可以使用以下凭据登录:

  • 用户名test

  • 密码test

Posts 屏幕将是登录后的初始屏幕,显示一个帖子列表,您可以点击继续到Post屏幕,而Settings屏幕显示一个无效的注销按钮。目前,AddPostNotification屏幕尚不可见,因为您将在本章后面添加到这些屏幕的路由。

React Native 应用程序中的项目结构在directory client 中如下,结构类似于您在本书中之前创建的项目:

messaging
|-- client
    |-- .expo
    |-- assets
        |-- icon.png
        |-- splash.png
    |-- Components
        |-- // ...
    |-- node_modules
    |-- Screens
        |-- AddPost.js
        |-- AuthLoading.js
        |-- Login.js
        |-- Notifications.js
        |-- Post.js
        |-- Posts.js
        |-- Settings.js
    |-- .watchmanconfig
    |-- App.js
    |-- AppContainer.js
    |-- app.json
    |-- babel.config.js
    |-- package.json

assets目录中,您可以找到用作应用程序图标的图像,一旦您在移动设备上安装了该应用程序,它将显示在主屏幕上,以及作为启动画面的图像,当您启动应用程序时显示。例如,应用程序名称的 App Store 配置放在app.json中,而babel.config.js包含特定的 Babel 配置。

App.js文件是您的应用程序的实际入口点,其中导入并返回AppContainer.js文件。在AppContainer中,定义了该应用程序的所有路由,AppContext将包含应该在整个应用程序中可用的信息。

该应用程序的所有组件都位于ScreensComponents目录中,其中第一个包含由屏幕呈现的组件。这些屏幕的子组件可以在Components目录中找到,其结构如下:

|-- Components
    |-- Button
        |-- Button.js
    |-- Comment
        |-- Comment.js
        |-- CommentForm.js
    |-- Notification
        |-- Notification.js
    |-- Post
        |-- PostContent.js
        |-- PostCount.js
        |-- PostItem.js
    |-- TextInput
        |-- TextInput.js

GraphQL 服务器可以在http://localhost:4000/graphql URL 找到,GraphQL Playground 将可见。从这个 playground,您可以查看 GraphQL 服务器的模式,并检查所有可用的查询、变异和订阅。虽然您不会对服务器进行任何代码更改,但了解模式及其工作原理是很重要的。

服务器有两个查询,通过使用userName参数作为标识符来检索帖子列表或单个帖子。这些查询将返回具有iduserNameimagestarscomments计数值的Post类型,stars类型的星星列表,以及具有Comment类型的comments列表。检索单个帖子的查询将如下所示:

export  const  GET_POST  =  gql`
 query getPost($userName: String!) { post(userName: $userName) { id userName image stars { userName } comments { id userName text } } } `;

之后,可以在 GraphQL 服务器中找到三个变异,用于登录用户、存储来自 Expo 的推送令牌,或添加帖子。

如果收到错误消息“请提供(有效的)身份验证详细信息”,则需要重新登录应用程序。可能,上一个应用程序的 JWT 仍然存储在 Expo 的AsyncStorage中,并且这将无法在本章的 GraphQL 服务器上验证。

使用 React Native、Apollo 和 GraphQL 构建全栈社交媒体应用程序

在本章中要构建的应用程序将使用本地 GraphQL 服务器来检索和改变应用程序中可用的数据。该应用程序将显示来自社交媒体动态的数据,并允许您对这些社交媒体帖子进行回复。

使用 React Native 和 Expo 的相机

除了显示由 GraphQL 服务器创建的帖子之外,您还可以使用 GraphQL mutation 自己添加帖子,并将文本和图像作为变量发送。将图像上传到您的 React Native 应用程序可以通过使用相机拍摄图像或从相机滚动中选择图像来完成。对于这两种用例,React Native 和 Expo 都提供了 API,或者可以从npm安装许多包。对于此项目,您将使用 Expo 的 ImagePicker API,它将这些功能合并到一个组件中。

要向您的社交媒体应用程序添加创建新帖子的功能,需要进行以下更改以创建新的添加帖子屏幕:

  1. 可以使用的 GraphQL mutation 用于向您在Main屏幕中看到的动态中添加帖子,它将图像变量发送到 GraphQL 服务器。此 mutation 具有以下形式:
mutation {
  addPost(image: String!) {
    image
  }
}

image变量是String,是此帖子的图像的绝对路径的 URL。此 GraphQL mutation 需要添加到client/constants.js文件的底部,以便稍后可以从useMutation Hook 中使用:

export  const  GET_POSTS  =  gql`
 ... `; + export  const  ADD_POST  =  gql` +   mutation addPost($image: String!) { +     addPost(image: $image) { +       image  +     } +   } + `;
  1. 有了Mutation,必须将添加帖子的屏幕添加到client/AppContainer.js文件中的SwitchNavigatorAddPost屏幕组件可以在client/Screens/AddPost.js文件中找到,并应作为导航器中的模态添加:
import  React  from 'react';  import { Platform } from 'react-native';  import { Ionicons }  from '@expo/vector-icons';  import {  createSwitchNavigator,
 createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';  import  Posts  from './Screens/Posts';  import  Post  from './Screens/Post';  import  Settings  from './Screens/Settings';  import  Login  from './Screens/Login';  import  AuthLoading  from './Screens/AuthLoading';  + import  AddPost  from './Screens/AddPost'; ... 
const  SwitchNavigator  =  createSwitchNavigator(
  {
    Main:  TabNavigator, Login, AuthLoading,
**+** **AddPost,**
  },
  {
+   mode: 'modal'**,**
    initialRouteName: 'AuthLoading',
  },
);

export  default  createAppContainer(SwitchNavigator);
  1. 当然,用户必须能够从应用程序的某个位置打开这个模态框,例如,从屏幕底部的选项卡导航器或标题栏。对于这种情况,您可以在client/Screens/Posts.js文件中设置navigationOptions来在标题栏中添加导航链接到AddPost屏幕:
...

**+ Posts**.navigationOptions  = ({ navigation}) => ({ +   headerRight: ( +     <Button  onPress={() =>  navigation.navigate('AddPost')}  title='Add Post'  /> +   ), **+ });** export  default  Posts;

通过在navigationOptions中设置headerRight字段,只会更改标题的右侧部分,而导航器设置的标题将保持不变。现在点击Add Post链接将导航到AddPost屏幕,显示标题和关闭模态框的按钮。

现在您已经添加了AddPost屏幕,Expo 的 ImagePicker API 应该被添加到这个屏幕上。要将ImagePicker添加到AddPost屏幕上,请按照以下步骤在client/Screens/AddPost.js文件中启用从相机滚动中选择照片:

  1. 在用户可以从相机滚动中选择照片之前,当用户使用 iOS 设备时,应该为应用程序设置正确的权限。要请求权限,您可以使用 Expo 的权限 API,它应该请求CAMERA_ROLL权限。权限 API 曾经直接从 Expo 可用,但现在已经移动到一个名为expo-permissions的单独包中,可以通过 Expo CLI 安装,方法是运行以下命令:
expo install expo-permissions
  1. 之后,您可以导入权限 API 并创建函数来检查是否已经为相机滚动授予了正确的权限:
import  React  from 'react';  import { Dimensions, TouchableOpacity, Text, View } from 'react-native';  + import { Dimensions, Platform, TouchableOpacity, Text, View } from 'react-native'; import  styled  from 'styled-components/native';  import  Button  from '../Components/Button/Button';  + import * as Permissions from 'expo-permissions'; ...

const AddPost = ({ navigation }) => { +  const  getPermissionAsync  =  async () => { +    if (Platform.OS  === 'ios') { +      const { status } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);
+ +      if (status  !== 'granted') { +        alert('Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.'); +      } +    } **+ };**   ...
  1. 这个getPermissionAsync函数是异步的,可以从ButtonTouchable元素中调用。在文件底部可以找到UploadImage组件,它是一个带有onPress函数的样式化TouchableOpacity元素。这个组件必须添加到AddPost的返回函数中,并在点击时调用getPermissionAsync函数:
...

const  AddPost  = ({ navigation }) => { const  getPermissionAsync  =  async () => { if (Platform.OS  === 'ios') {
 const { status } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);

 if (status  !== 'granted') {
 alert('Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.');
 } } };  return ( <AddPostWrapper>
 <AddPostText>Add Post</AddPostText> +     <UploadImage  onPress={() =>  getPermissionAsync()}> +       <AddPostText>Upload image</AddPostText> +     </UploadImage**>**
 <Button  onPress={() =>  navigation.navigate('Main')}  title='Cancel'  />
  </AddPostWrapper>
 ); };

...

在 iOS 设备上点击时,将打开一个请求访问相机滚动权限的弹出窗口。如果您不接受请求,就无法从相机滚动中选择照片。

您不能再次要求用户授予权限;相反,您需要手动授予对摄像机滚动的权限。要再次设置这个权限,您应该从 iOS 的设置屏幕进入,并选择 Expo 应用程序。在下一个屏幕上,您可以添加访问摄像机的权限。

  1. 当用户已经授予访问摄像机滚动的权限时,您可以调用 Expo 的 ImagePicker API 来打开摄像机滚动。就像权限 API 一样,这曾经是 Expo 核心的一部分,但现在已经移动到一个单独的包中,您可以使用 Expo CLI 安装:
expo install expo-image-picker

这是一个再次使用异步函数,它接受一些配置字段,比如宽高比。如果用户选择了一张图片,ImagePicker API 将返回一个包含字段 URI 的对象,该字段是用户设备上图片的 URL,可以在Image组件中使用。可以通过使用useState Hook 创建一个本地状态来存储这个结果,以便稍后将其发送到 GraphQL 服务器:

import  React  from 'react';  import { Dimensions, Platform, TouchableOpacity, Text, View } from 'react-native';  import  styled  from 'styled-components/native';  import  Button  from '../Components/Button/Button'; **+ import * as ImagePicker from 'expo-image-picker';** import * as Permissions from 'expo-permissions';  ...

const  AddPost  = ({ navigation }) => { +  const [imageUrl, setImageUrl] = React.useState(false); 
+  const  pickImageAsync  =  async () => { +    const  result  =  await  ImagePicker.launchImageLibraryAsync({ +      mediaTypes:  ImagePicker.MediaTypeOptions.All, +      allowsEditing:  true, +      aspect: [4, 4], +    });
+    if (!result.cancelled) { +      setImageUrl(result.uri); +    }
+  };

 return (
     ... 

然后可以从函数中调用pickImageAsync函数,以获取用户在摄像机滚动时授予的权限:

...

const  AddPost  = ({ navigation }) => { ...

  const  getPermissionAsync  =  async () => { if (Platform.OS  === 'ios') {
 const { status } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);

 if (status  !== 'granted') {
 alert('Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.');
**+     } else {**
**+       pickImageAsync();**
 } } };  return (
  1. 现在,由于图片的 URL 已经存储在本地状态中的imageUrl常量中,您可以在Image组件中显示这个 URL。这个Image组件以imageUrl作为源的值,并且已经设置为使用 100%的widthheight
...

  return ( <AddPostWrapper>
 <AddPostText>Add Post</AddPostText>

 <UploadImage  onPress={() =>  getPermissionAsync()}>
**+       {imageUrl ? (**
**+** <Image +           source={{ uri:  imageUrl }} +           style={{ width: '100%', height: '100%' }} +         />
+       ) : (
          <AddPostText>Upload image</AddPostText>
**+       )}**
 </UploadImage>
 <Button  onPress={() =>  navigation.navigate('Main')}  title='Cancel'  />
  </AddPostWrapper>
 ); };

...

通过这些更改,AddPost屏幕应该看起来像下面的截图,这是从运行 iOS 的设备上获取的。如果您使用 Android Studio 模拟器或运行 Android 的设备,这个屏幕的外观可能会有轻微的差异:

这些更改将使从摄像机滚动中选择照片成为可能,但您的用户还应该能够通过使用他们的摄像机上传全新的照片。使用 Expo 的 ImagePicker,您可以处理这两种情况,因为这个组件还有一个launchCameraAsync方法。这个异步函数将启动摄像机,并以与从摄像机滚动中返回图片的 URL 相同的方式返回它。

要添加直接使用用户设备上的摄像机上传图片的功能,可以进行以下更改:

  1. 由于用户需要授予您的应用程序访问相机滚动条的权限,因此用户需要做同样的事情来使用相机。可以通过使用Permissions.askAsync方法发送Permissions.CAMERA来请求使用相机的权限。必须扩展对相机滚动条的授予权限的检查,以便还检查相机权限:
...

  const  getPermissionAsync  =  async () => {  if (Platform.OS  === 'ios') { -   const { status } = await Permissions.askAsync(Permissions.CAMERA_ROLL);
-   if (status !== 'granted') {
+     const { status: statusCamera } =  await  Permissions.askAsync(Permissions.CAMERA); +     const { status: statusCameraRoll } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL); +     if (statusCamera  !== 'granted'  ||  statusCameraRoll  !== 'granted'**) {**
        alert(
          `Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.`
        );
      } else {        pickImageAsync();
      }
    }
  };

  return (
    ... 

这将在 iOS 上要求用户允许使用相机,也可以通过转到设置| Expo 手动授予权限。

  1. 在获得权限后,您可以通过调用ImagePicker中的launchCameraAsync函数来创建启动相机的功能。该功能与您创建的用于打开相机滚动条的launchCameraAsync函数相同;因此,pickImageAsync函数也可以编辑为能够启动相机:
const  AddPost  = ({ navigation }) => { const [imageUrl, setImageUrl] =  React.useState(false);
 **-  const** pickImageAsync  =  async () => {  +  const addImageAsync  =  async (camera = false) => { -    const  result  =  await  ImagePicker.launchCameraAsync({ -      mediaTypes:  ImagePicker.MediaTypeOptions.All, -      allowsEditing:  true, -      aspect: [4, 4]
-    }); +    const  result  = !camera 
+      ? await  ImagePicker.launchImageLibraryAsync({ +          mediaTypes:  ImagePicker.MediaTypeOptions.All, +          allowsEditing:  true, +          aspect: [4, 4] +        })
+      : await  ImagePicker.launchCameraAsync({  +          allowsEditing:  true, +          aspect: [4, 4] **+        })**
 if (!result.cancelled) { setImageUrl(result.uri);
 } };

如果现在向addImageAsync函数发送参数,将调用launchCameraAsync。否则,用户将被引导到其设备上的相机滚动条。

  1. 当用户点击图像占位符时,默认情况下将打开图像滚动条。但您还希望给用户选择使用他们的相机的选项。因此,必须在使用相机或相机滚动条上传图像之间进行选择,这是实现ActionSheet组件的完美用例。React Native 和 Expo 都有一个ActionSheet组件;建议使用 Expo 中的组件,因为它将在 iOS 上使用本机的UIActionSheet组件,在 Android 上使用 JavaScript 实现。ActionSheet组件可从 Expo 的react-native-action-sheet软件包中获得,您可以从npm安装。
npm install @expo/react-native-action-sheet

之后,您需要在client/App.js文件中使用来自该软件包的Provider将顶级组件包装起来,这类似于添加ApolloProvider

import React from 'react';
import { AsyncStorage } from 'react-native';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { setContext } from 'apollo-link-context';
import { HttpLink } from 'apollo-link-http';
import { ApolloProvider } from '@apollo/react-hooks';
+ import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import AppContainer from './AppContainer';

...

const  App  = () => (  <ApolloProvider  client={client}> +   <ActionSheetProvider>       <AppContainer  /> +   </ActionSheetProvider**>**
  </ApolloProvider> );

export  default  App;

client/Screens/AddPost.js中通过从react-native-action-sheet导入connectActionSheet函数来创建ActionSheet,在导出之前需要将AddPost组件包装起来。使用connectActionSheet()AddPost组件包装起来,将showActionSheetWithOptions属性添加到组件中,你将在下一步中使用它来创建ActionSheet

import  React  from 'react';  import { Dimensions,
 Image,
 Platform,
  TouchableOpacity,
  Text,
  View } from 'react-native';  import  styled  from 'styled-components/native';  import  *  as  ImagePicker  from 'expo-image-picker';  import  *  as  Permissions  from 'expo-permissions';  + import { connectActionSheet } from  '@expo/react-native-action-sheet'; import  Button  from '../Components/Button/Button'; ... - const  AddPost  = ({ navigation }) => { + const  AddPost  = ({ navigation, showActionSheetWithOptions }) => **{**

    ... 
- export default AddPost;
+ const  ConnectedApp  =  connectActionSheet(AddPost); + export  default  ConnectedApp;
  1. 要添加ActionSheet,必须添加一个打开ActionSheet的函数,并使用showActionSheetWithOptions属性和选项来构造ActionSheet。选项包括相机相机相册取消,选择第一个选项应该调用带有参数的addImageAsync函数,第二个选项应该调用不带参数的函数,最后一个选项是关闭ActionSheet。打开ActionSheet的函数必须添加到getPermissionsAsync函数中,并在相机相机相册的权限都被授予时调用:
...

+  const openActionSheet = () => { +    const  options  = ['Camera', 'Camera roll', 'Cancel']; +    const  cancelButtonIndex  =  2; + 
+    showActionSheetWithOptions( +      {
+        options, +        cancelButtonIndex
+      },
+      buttonIndex  => { +        if (buttonIndex  ===  0  ||  buttonIndex  ===  1) { +          addImageAsync(buttonIndex  ===  0); +        }
+      },
+    );
+   };

  const  getPermissionAsync  =  async () => {    if (Platform.OS  === 'ios') {
      const { status: statusCamera } =  await  Permissions.askAsync(Permissions.CAMERA);
      const { status: statusCameraRoll } =  await  Permissions.askAsync(Permissions.CAMERA_ROLL);

      if (statusCamera  !== 'granted'  ||  statusCameraRoll  !== 'granted') {
        alert(
          `Sorry, you need camera roll permissions! Go to 'Settings > Expo' to enable these.`
        );
      } else { -       pickImageAsync**();**
**+       openActionSheet();**
      }
    }
  };

  return (
    ...

点击图像占位符将给用户选择使用相机相机相册AddPost组件添加图像的选项。这可以通过ActionSheet来实现,在 iOS 和 Android 上看起来会有所不同。在下面的截图中,您可以看到在使用 iOS 模拟器或运行 iOS 的设备时的效果:

  1. 然而,这还不是全部,因为图像仍然必须发送到服务器才能出现在应用程序的动态中,通过从@apollo/react-hooks中添加useMutation Hook,并使用返回的addPost函数将imageUrl变量发送到 GraphQL 服务器的文档中。在本节的开头已经提到了添加帖子的变异,并可以从client/constants.js文件中导入:
import  React  from 'react';  import { Dimensions,
 Image,
 Platform,
  TouchableOpacity,
  Text,
  View } from 'react-native';  import  styled  from 'styled-components/native';  import  *  as  ImagePicker  from 'expo-image-picker';  import  *  as  Permissions  from 'expo-permissions';  import { connectActionSheet } from '@expo/react-native-action-sheet';
**+ import { useMutation } from '@apollo/react-hooks';** **+ import { ADD_POST } from '../constants';** import  Button  from '../Components/Button/Button';  
...

const  AddPost  = ({ navigation, showActionSheetWithOptions }) => { + const [addPost] = useMutation(ADD_POST);
  const [imageUrl, setImageUrl] =  React.useState(false); ... 
  return (    <AddPostWrapper>
      <AddPostText>Add Post</AddPostText>
        <UploadImage  onPress={() =>  getPermissionAsync()}> {imageUrl ? ( <Image source={{ uri:  imageUrl }} style={{ width: '100%', height: '100%' }} />
          ) : (
            <AddPostText>Upload image</AddPostText> )} </UploadImage> +       {imageUrl && ( +         <Button +           onPress={() => { +             addPost({ variables: { image:  imageUrl } }).then(() => 
+ navigation.navigate('Main') +             );
+           }} +           title='Submit' +         />
+       )}  <Button  onPress={() =>  navigation.navigate('Main')}  title='Cancel'  /> </AddPostWrapper>
   );
 };

export default AddPost;

点击提交按钮后,图像将作为帖子添加,并且用户将被重定向到Main屏幕。

  1. 通过将refetchQueries变量上的查询设置为useMutation Hook,可以重新加载Main屏幕上的帖子,并在此列表中显示您刚刚添加的帖子。可以通过从client/constants.js中获取GET_POSTS查询来检索帖子:
import  React  from 'react';  import { Dimensions,
 Image,
 Platform,
  TouchableOpacity,
  Text,
  View } from 'react-native';  import  styled  from 'styled-components/native';  import  *  as  ImagePicker  from 'expo-image-picker';  import  *  as  Permissions  from 'expo-permissions';  import { connectActionSheet } from '@expo/react-native-action-sheet';
import { useMutation } from '@apollo/react-hooks'; **- import { ADD_POST } from '../constants';** **+ import { ADD_POST, GET_POSTS } from '../constants';** import  Button  from '../Components/Button/Button';  
...

const  AddPost  = ({ navigation, showActionSheetWithOptions }) => { - const [addPost] = useMutation(ADD_POST);
+ const [addPost] =  useMutation(ADD_POST, { +   refetchQueries: [{ query:  GET_POSTS }] + });
  const [imageUrl, setImageUrl] =  React.useState(false);
 ... 
 return (   <AddPostWrapper>
     ...

您的帖子现在将显示在Main屏幕的顶部,这意味着您已成功添加了帖子,其他用户可以查看、点赞和评论。由于用户可能在应用程序打开时发送帖子,您希望他们能够接收这些帖子。因此,接下来的部分将探讨如何从 GraphQL 实现近实时数据。

使用 GraphQL 检索近实时数据

除了消息应用程序之外,您不希望每当您的网络中的任何人发布新帖子时,就重新加载带有帖子的信息流。除了订阅之外,还有其他方法可以使用 GraphQL 和 Apollo 实现(近乎)实时数据流,即轮询。通过轮询,您可以每隔n毫秒从useQuery Hook 中检索一个查询,而无需设置订阅的复杂性。

轮询可以添加到client/Screens/Posts.js中的useQuery Hook 中,就像这样。通过在useQuery Hook 的对象参数上设置pollInterval值,您可以指定多久应该由 Hook 重新发送带有GET_POSTS查询的文档:

...

const  Posts  = ({ navigation }) => {
**- const { loading, data } = useQuery(GET_POSTS);**
**+ const { loading, data } = useQuery(GET_POSTS, { pollInterval: 2000 });**

  return ( <PostsWrapper> {loading ? (  <PostsText>Loading...</PostsText>;
      ) : ( ...

这会导致您的Posts组件每 2 秒(2,000 毫秒)发送一个带有GET_POSTS查询的文档,由于 GraphQL 服务器返回的是模拟数据,显示的帖子在每次重新获取时都会有所不同。与订阅相比,轮询会重新发送文档以检索帖子,即使没有新数据,这对于显示模拟数据或经常更改的数据的应用程序并不是很有用。

除了在useQuery Hook 上设置pollInterval变量之外,您还可以手动调用refetch函数,该函数会发送一个带有查询的文档。社交媒体信息流的常见交互是能够下拉显示的组件以刷新屏幕上的数据。

通过对Posts屏幕组件进行以下更改,也可以将此模式添加到您的应用程序中:

  1. pollInterval属性可以设置为0,这样就暂时禁用了轮询。除了loadingdata变量之外,还可以从useQuery Hook 中检索更多变量。其中一个变量是refetch函数,您可以使用它手动将文档发送到服务器:
...

const  Posts  = ({ navigation }) => {
**- const { loading, data } = useQuery(GET_POSTS, { pollInterval: 2000 });**
**+ const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });**
  return ( <PostsWrapper> {loading ? (  <PostsText>Loading...</PostsText>;
      ) : ( ...
  1. 有一个 React Native 组件用于创建下拉刷新交互,称为RefreshControl,您应该从react-native中导入它。此外,您还应该导入一个ScrollView组件,因为RefreshControl组件只能与ScrollViewListView组件一起使用:
import  React  from 'react';  import { useQuery } from '@apollo/react-hooks';  - import { FlatList, Text, View } from 'react-native';  + import { FlatList, Text, View, ScrollView, RefreshControl } from 'react-native';  import  styled  from 'styled-components/native';  import { GET_POSTS } from '../constants';  import  PostItem  from '../Components/Post/PostItem'; ... const  Posts  = ({ navigation }) => {  ...
  1. 这个ScrollView组件应该包裹在PostsList组件周围,它是一个经过 GraphQL 服务器创建的帖子进行迭代的样式化FlatList组件。作为refreshControl属性的值,必须将RefreshControl组件传递给这个ScrollView,并且必须设置一个style属性,将宽度锁定为 100%,以确保只能垂直滚动:
const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
  return (
    <PostsWrapper>
      {loading ? (
        <PostsText>Loading...</PostsText>;
      ) : (
+       <ScrollView
+         style={{ width: '100%' }}
+         refreshControl={
+           <RefreshControl />
+         }
+       >
         <PostsList
           data={data.posts}
           keyExtractor={item => String(item.id)}
           renderItem={({ item }) => (
             <PostItem item={item} navigation={navigation} />
           )}
         />
+       </ScrollView>
      )}
    </PostsWrapper>
  );
};
  1. 如果您现在下拉Posts屏幕,屏幕顶部将显示一个不断旋转的加载指示器。通过refreshing属性,您可以通过传递由useState Hook 创建的值来控制是否应该显示加载指示器。除了refreshing属性,还可以将应该在刷新开始时调用的函数传递给onRefresh属性。您应该将refetch函数传递给此函数,该函数应将refreshing状态变量设置为true并调用useQuery Hook 返回的refetch函数。在refetch函数解析后,回调可以用于再次将refreshing状态设置为false
...
const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
+ const [refreshing, setRefreshing] = React.useState(false);

+ const handleRefresh = (refetch) => {
+   setRefreshing(true);
+
+   refetch().then(() => setRefreshing(false));
+ }

  return(
    <PostsWrapper>
    {loading ? (
      <PostsText>Loading...</PostsText>;
    ) : (
      <ScrollView
        style={{ width: '100%' }}
        refreshControl={
-         <RefreshControl />
+         <RefreshControl
+           refreshing={refreshing}
+           onRefresh={() => handleRefresh(refetch)}
+         />
        }
      >
        <PostsList
          ...
  1. 最后,当您下拉Posts屏幕时,从useQuery Hook 返回的加载消息会干扰RefreshControl的加载指示器。通过在 if-else 语句中还检查refreshing的值,可以防止这种行为:
...
const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
  const [refreshing, setRefreshing] = React.useState(false);

  const handleRefresh = (refetch) => {
    setRefreshing(true);

    refetch().then(() => setRefreshing(false));
  }

  return(
    <PostsWrapper>
-     {loading ? (
+     {loading && !refreshing ? (
        <PostsText>Loading...</PostsText>      ) : (

        ...

在最后这些更改之后,下拉刷新Posts屏幕的交互已经实现,使您的用户可以通过下拉屏幕来检索最新数据。当您将 iOS 作为运行应用程序的虚拟或物理设备的操作系统时,它将看起来像这样的截图:

在接下来的部分中,您将使用 Expo 和 GraphQL 服务器向这个社交媒体应用程序添加通知。

使用 Expo 发送通知

移动社交媒体应用程序的另一个重要功能是向用户发送重要事件的通知,例如,当他们的帖子被点赞或朋友上传了新帖子。使用 Expo 可以发送通知,并且需要添加服务器端和客户端代码,因为通知是从服务器发送的。客户端需要检索用户设备的本地标识符,称为 Expo 推送代码。这个代码是需要的,以确定哪个设备属于用户,以及通知应该如何发送到 iOS 或 Android。

测试通知只能通过在您的移动设备上使用 Expo 应用程序来完成。iOS 和 Android 模拟器无法接收推送通知,因为它们不在实际设备上运行。

检索推送代码是向用户发送通知的第一步,包括以下步骤:

  1. 为了能够发送通知,用户应该允许您的应用程序推送这些通知。要请求此权限,应该使用相同的权限 API 来获取相机的权限。请求此权限的函数可以添加到一个名为registerForPushNotificationsAsync.js的新文件中。这个文件必须创建在新的client/utils目录中,您可以在其中粘贴以下代码,该代码还使用通知 API 检索推送代码:
import { Notifications } from 'expo';  import  *  as  Permissions  from 'expo-permissions';  async  function  registerForPushNotificationsAsync() {
 const { status: existingStatus } =  await  Permissions.getAsync(
 Permissions.NOTIFICATIONS
 ); let  finalStatus  =  existingStatus;
   if (existingStatus  !== 'granted') {
  const { status } =  await  Permissions.askAsync(Permissions.NOTIFICATIONS);
 finalStatus  =  status;
 }  if (finalStatus  !== 'granted') {
 return;
 } const  token  =  await  Notifications.getExpoPushTokenAsync();
 return  token; }

export default registerForPushNotificationsAsync;
  1. 当您使用 iOS 设备时,应该在应用程序打开时调用registerForPushNotificationAsync函数,因为您应该请求权限。在 Android 设备上,用户是否希望您发送通知的请求是在安装过程中发送的。因此,当用户打开应用程序时,应该触发此函数,之后此函数将在 Android 上返回 Expo 推送令牌,或在 iOS 上启动弹出窗口以请求权限。由于您只想要向注册用户请求他们的令牌,因此在client/Screens/Posts.js文件中使用useEffect Hook 来完成。
import React from 'react';
import { useQuery } from '@apollo/react-hooks';
import {
  Button,
  FlatList,
  Text,
  View,
  ScrollView,
  RefreshControl
} from 'react-native';
import styled from 'styled-components/native';
import { GET_POSTS } from '../constants';
import PostItem from '../Components/Post/PostItem';
+ import registerForPushNotificationsAsync from '../utils/registerForPushNotificationsAsync';

... const Posts = ({ navigation }) => {
  const { loading, data, refetch } = useQuery(GET_POSTS, { pollInterval: 0 });
  const [refreshing, setRefreshing] = React.useState(false);
+ React.useEffect(() => {
+   registerForPushNotificationsAsync();
+ });

...

如果您看到此错误,“错误:Expo 推送通知服务仅支持 Expo 项目。请确保您已登录到从中加载项目的计算机上的 Expo 开发人员帐户。”,这意味着您需要确保已登录到 Expo 开发人员帐户。通过在终端中运行expo login,您可以检查是否已登录,否则它将提示您重新登录。

  1. 在终端中,现在将显示此用户的 Expo 推送令牌,看起来像ExponentPushToken[AABBCC123]。这个令牌对于这个设备是唯一的,可以用来发送通知。要测试通知的外观,您可以在浏览器中转到https://expo.io/dashboard/notifications的 URL 以找到 Expo 仪表板。在这里,您可以输入 Expo 推送令牌以及通知的消息和标题;根据移动操作系统的不同,您可以选择不同的选项,例如以下选项:

这将向您的设备发送一个标题为Test,正文为This is a test的通知,并在发送通知时尝试播放声音。

然而,当应用程序在 iOS 设备上运行并处于前台时,此通知不会显示。因此,当您在苹果设备上使用 Expo 应用程序时,请确保 Expo 应用程序在后台运行。

本节的下一部分将展示如何在应用程序在前台运行时也可以接收通知。

处理前台通知

当应用程序处于前台时处理通知更加复杂,需要我们添加一个监听器来检查新通知,然后这些通知应该被存储在某个地方。Expo 的通知 API 提供了一个可用的监听器,可以帮助您检查新通知,而通知可以使用 Apollo 来存储,通过使用本地状态。这个本地状态通过添加监听器发现的任何新通知来扩展 GraphQL 服务器返回的数据。

当通知存储在本地状态中时,可以查询这些数据并在应用程序的组件或屏幕中显示。让我们创建一个通知屏幕,显示这些在应用程序在前台加载时发送的通知。

添加对前台通知的支持需要您进行以下更改:

  1. client/App.js中 Apollo Client 的设置应该被扩展,以便您可以查询通知,并在监听器发现新通知时添加新通知。应该创建一个名为notifications的新类型Query,返回Notification类型的列表。此外,必须在cache中添加一个空数组的形式作为这个Query的初始值:
...

 const  client  =  new  ApolloClient({
  link:  authLink.concat(link),
 cache, +  typeDefs:  ` +    type Notification { +      id: Number! +      title: String! +      body: String! +    } +    extend type Query { +      notifications: [Notification]! +    } +  `
 }); + cache.writeData({ +  data: { +    notifications: [] +  } **+ });** const  App  = () => {

  ...
  1. 现在,您可以发送一个带有查询的文档,以检索包括idtitlebody字段的通知列表。这个查询也必须在client/constants.js文件中定义,以便在下一步中从useQuery Hook 中使用。
...

export  const  ADD_POST  =  gql`
 mutation addPost($image: String!) { addPost(image: $image) { image } } `; + export  const  GET_NOTIFICATIONS  =  gql` +   query getNotifications { +     notifications { +       id @client +       title @client +       body @client +     } +   } + `;
  1. client/Screens目录中,可以找到Notifications.js文件,必须将其用作用户显示通知的屏幕。此屏幕组件应该在client/AppContainer.js文件中导入,其中必须创建一个新的StackNavigator对象:
import  React  from 'react';  import { Platform } from 'react-native';  import { Ionicons }  from '@expo/vector-icons';  import {   createSwitchNavigator,
 createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';
import { createBottomTabNavigator } from 'react-navigation-tabs';  import  Posts  from './Screens/Posts';  import  Post  from './Screens/Post';  import  Settings  from './Screens/Settings';  import  Login  from './Screens/Login';  import  AuthLoading  from './Screens/AuthLoading';  import  AddPost  from './Screens/AddPost';  + import  Notifications  from './Screens/Notifications';  ...

+ const  NotificationsStack  =  createStackNavigator({ +   Notifications: { +     screen:  Notifications, +     navigationOptions: { title: 'Notifications' }, +   } **+ });**

创建Notifications屏幕的StackNavigator之后,需要将其添加到TabNavigator中,以便它将显示在PostsSettings屏幕旁边:

...

const  TabNavigator  =  createBottomTabNavigator(
 { Posts:  PostsStack, +   Notifications:  NotificationsStack,  Settings }, { initialRouteName: 'Posts',
 defaultNavigationOptions: ({ navigation }) => ({ tabBarIcon: ({ tintColor }) => { const { routeName } =  navigation.state;
  let  iconName;
  if (routeName  === 'Posts') { iconName  =  `${Platform.OS === 'ios' ? 'ios' : 'md'}-home`; } else  if (routeName  === 'Settings') {
 iconName  =  `${Platform.OS === 'ios' ? 'ios' : 'md'}-settings`; +     } else  if (routeName  === 'Notifications') { +       iconName  =  `${Platform.OS === 'ios' ? 'ios' : 'md'}-notifications`; **+     }** return  <Ionicons  name={iconName}  size={20}  color={tintColor}  />;
 },  ...
  1. Notifications屏幕现在显示在TabNavigator中,并显示文本 Empty!因为没有任何通知可显示。要添加已发送给用户的任何通知,需要为 GraphQL 客户端创建本地解析器。此本地解析器将用于创建Mutation,用于将任何新通知添加到本地状态。您可以通过将以下代码添加到client/App.js来创建本地解析器:
...

import AppContainer from './AppContainer';
**+ import { GET_NOTIFICATIONS } from './constants';**

...

const  client  =  new  ApolloClient({
 link:  authLink.concat(link),
 cache, + resolvers: { +   Mutation: { +     addNotification:  async (_, { id, title, body }) => { +       const { data } =  await  client.query({ query:  GET_NOTIFICATIONS })
+ +       cache.writeData({ +         data: { +           notifications: [ +             ...data.notifications, +             { id, title, body, __typename: 'notifications' }, +           ], +         }, +       }); +     } +   } **+ },**
 typeDefs:  `
 type Notification { id: Number! title: String! body: String! } extend type Query { notifications: [Notification]! } ` });

...

这将创建addNotification变异,该变异接受idtitlebody变量,并将这些值添加到Notification类型的数据中。当前在本地状态中的通知是使用之前创建的GET_NOTIFICATIONS查询来请求的。通过在 GraphQL client常量上调用query函数,您将向服务器发送包含此查询的文档。连同与变异一起发送的通知以及包含变异的文档,这些将通过cache.writeData写入本地状态。

  1. 这个变异必须添加到client/constants.js文件中,其他 GraphQL 查询和变异也放在那里。同样重要的是要添加client应该使用@client标签来解决这个变异:
...

export  const  GET_NOTIFICATIONS  =  gql`
 query getNotifications { notifications { id @client title @client body @client } } `; + export  const  ADD_NOTIFICATION  =  gql`
+ mutation { +     addNotification(id: $id, title: $title, body: $body) @client +   } + `;
  1. 最后,从Notifications API 中添加的监听器被添加到client/App.js文件中,当应用程序处于前台时,它将寻找新的通知。新的通知将使用client/constants.js中的前述变异添加到本地状态。在客户端上调用的mutate函数将使用来自 Expo 通知的信息并将其添加到变异;变异将确保通过将此信息写入cache将其添加到本地状态:
...

import { ActionSheetProvider } from '@expo/react-native-action-sheet';  + import { Notifications } from 'expo'; import AppContainer from './AppContainer';
- import { GET_NOTIFICATIONS } from './constants'; + import { ADD_NOTIFICATIONS, GET_NOTIFICATIONS } from './constants'; 
...

const  App  = () => { + React.useEffect(() => { +   Notifications.addListener(handleNotification); + });

+ const  handleNotification  = ({ data }) => { +   client.mutate({ +     mutation:  ADD_NOTIFICATION, +     variables: { +       id:  Math.floor(Math.random() *  500) +  1, +       title:  data.title, +       body:  data.body, +     },
+   });
+ };

  return (

    ...

在上一个代码块中,您不能使用useMutation Hook 来发送ADD_NOTIFICATION变异,因为 React Apollo Hooks 只能从嵌套在ApolloProvider中的组件中使用。因此,使用了client对象上的mutate函数,该函数还提供了发送带有查询和变异的文档的功能,而无需使用QueryMutation组件。

  1. 通过从 Expo 导入Notifications API,handleNotification函数可以访问发送的通知中的数据对象。该数据对象与您使用 Expo 仪表板发送的消息标题和消息正文不同,因此在从https://expo.io/dashboard/notifications发送通知时,您还需要添加 JSON 数据。可以通过在表单中添加正文来发送测试通知:

通过提交表单,当应用程序处于前台运行时,将向用户发送标题为Test,正文为This is a test的通知,但也会在应用程序在后台运行时发送。

在生产中运行的移动应用程序中,您期望通知是从 GraphQL 服务器而不是 Expo 仪表板发送的。处理此应用程序的数据流的本地 GraphQL 服务器已配置为向用户发送通知,但需要用户的 Expo 推送令牌才能发送。该令牌应存储在服务器上并与当前用户关联,因为该令牌对于此设备是唯一的。该令牌应在文档中从变异发送到 GraphQL 服务器,该变异将获取关于用户的信息并从变异的标头中获取:

  1. 首先,在client/constants.js文件中创建将在 GraphQL 服务器上存储 Expo 推送令牌的变异,以及其他查询和变异。此变异所需的唯一变量是推送令牌,因为发送到 GraphQL 服务器的每个文档的 OAuth 令牌用于标识用户:
import  gql  from 'graphql-tag';  export  const  LOGIN_USER  =  gql`
 mutation loginUser($userName: String!, $password: String!) { loginUser(userName: $userName, password: $password) { userName token } } `; + export  const  STORE_EXPO_TOKEN  =  gql` +   mutation storeExpoToken($expoToken: String!) { +     storeExpoToken(expoToken: $expoToken) { +       expoToken +     } +   } + `**;**

...
  1. 必须从client/Posts.js文件中发送带有 Expo 推送令牌的此变异,该文件通过调用registerForPushNotificationsAsync函数检索令牌。此函数将返回推送令牌,您可以将其与变异文档一起发送。要发送此文档,可以使用@apollo/react-hooks中的useMutation Hook,您必须与STORE_EXPO_TOKEN常量一起导入:
import  React  from 'react';  - import { useQuery } from '@apollo/react-hooks'; **+ import { useQuery, useMutation } from '@apollo/react-hooks';**  ... - import { GET_POSTS } from '../constants';  + import { GET_POSTS, STORE_EXPO_TOKEN } from '../constants';  import  PostItem  from '../Components/Post/PostItem';  import  registerForPushNotificationsAsync  from '../utils/registerForPushNotificationsAsync';  ...

在 React Apollo Hooks 可用之前,使用变异是很复杂的,因为只能从client对象或Mutation组件发送变异。通过导入ApolloConsumer组件,可以从 React 组件中访问client对象,该组件可以从包装应用程序的ApolloProvider中读取客户端值。

  1. 现在可以使用useMutation Hook 调用STORE_EXPO_TOKEN变异,并将registerForPushNotificationsAsync中的expoToken作为参数,该参数返回一个用于存储令牌的函数称为storeExpoToken。可以从异步registerForPushNotificationsAsync函数的回调中调用此函数,并将令牌作为变量传递:
...

const  Posts  = ({ client, navigation }) => {
**+ const [storeExpoToken] = useMutation(STORE_EXPO_TOKEN);** const [refreshing, setRefreshing] =  React.useState(false);

 React.useEffect(() => { -   registerForPushNotificationsAsync(); +   registerForPushNotificationsAsync().then(expoToken  => { +     return storeExpoToken({ variables: { expoToken } }); +   });  }, []);

...

每当“帖子”屏幕被挂载时,Expo 推送令牌将被发送到 GraphQL 服务器,您可以通过在“添加帖子”和“帖子”屏幕之间切换来强制执行此操作。当从 GraphQL 服务器请求“帖子”屏幕的内容时,服务器将向您的应用程序发送一个随机通知,您可以从“通知”屏幕中查看该通知。此外,您仍然可以在 Expo 仪表板上发送任何通知,无论应用程序是在前台还是后台运行。

总结

在本章中,您使用 React Native 和 Expo 创建了一个移动社交媒体应用程序,该应用程序使用 GraphQL 服务器发送和接收数据以及进行身份验证。使用 Expo,您学会了如何让应用程序请求访问设备的相机或相机滚动条,以添加新照片到帖子中。此外,Expo 还用于从 Expo 仪表板或 GraphQL 服务器接收通知。这些通知将被用户接收,无论应用程序是在后台还是前台运行。

完成了这个社交媒体应用程序,您已经完成了本书的最后一个 React Native 章节,现在准备开始最后一个章节。在这最后一个章节中,您将探索 React 的另一个用例,即 React 360。使用 React 360,您可以通过编写 React 组件创建 360 度的 2D 和 3D 体验。

进一步阅读

第十二章:使用 React 360 创建虚拟现实应用程序

您已经接近成功了——只剩下最后一个章节,然后您就可以称自己为一个在每个平台上都有 React 经验的 React 专家了。在本书中,您已经使用 React 和 React Native 构建了 11 个应用程序,而对于最后的大结局,您将使用 React 360。React 和 React Native 的“一次学习,随处编写”策略的最终部分将在本章中得到最好的展示。使用 React 360,您可以使用 React 和 React Native 的原则创建动态的 3D 和虚拟现实(VR)体验,更具体地说,使用 React Native 类似的生命周期和 UI 组件。虽然虚拟现实仍然是新兴技术,但虚拟现实的最佳用例是,例如,希望顾客体验他们的商店或在线游戏的零售商店。

在本章中,您将探索 React 360 的基础知识以及它与 React 和 React Native 的关系。您将构建的应用程序将能够渲染 360 度全景图像,并使用状态管理在屏幕之间进行渲染。使用 React 360 构建的场景中还将显示动画 3D 对象。

本章将涵盖以下主题:

  • 使用 React 360 入门

  • 使用 React 360 创建全景查看器

  • 构建可点击元素

项目概述

在本章中,您将使用 React 360 构建一个应用程序,该应用程序使用了来自 React 和 React Native 的原则。这个应用程序将添加 2D 全景图像和 3D 对象,并且可以使用 Metro 捆绑器在浏览器中运行项目。

构建时间为 1.5 小时。

入门

本章的应用程序将从头开始构建,并使用可以在 GitHub 上找到的资产。这些资产应该下载到您的计算机上,以便您稍后在本章中使用。本章的完整代码可以在 GitHub 上找到。

React 360 需要与 React 和 React Native 项目相同版本的 Node.js 和npm。如果您尚未在计算机上安装 Node.js,请转到https://nodejs.org/en/download/,在那里您可以找到 macOS、Windows 和 Linux 的下载说明。

安装 Node.js 后,您可以在命令行中运行以下命令来检查已安装的版本:

  • 对于 Node.js(应为 v10.16.3 或更高版本),请使用以下命令:
node -v
  • 对于npm(应为 v6.9.0 或更高版本),请使用以下命令:
npm -v

使用 React 360 创建 VR 应用程序

React 360 使用了来自 React 的原则,并且在很大程度上基于 React Native。React 360 允许您创建应用程序,使用 UI 组件而无需处理移动设备或 VR 设备的复杂设置,这与 React Native 的工作方式类似。

开始使用 React 360

无论您是使用 React、React Native 还是 React 360 创建项目,都有工具可以轻松帮助您开始使用这些技术。在本书中,您已经使用 Create React App 作为 React web 应用程序的起点,并使用 Expo CLI 创建 React Native 项目。此 React 360 项目将使用 React 360 CLI 启动,该 CLI 将帮助您创建和管理 React 360 应用程序。

设置 React 360

可以通过运行以下命令从npm安装 React 360 CLI:

npm install -g react-360-cli

这将从npm软件包注册表全局安装 React 360 CLI。安装过程完成后,您可以使用它通过执行以下命令来创建您的第一个 React 360 项目:

react-360 init virtual-reality

通过执行此命令,将创建一个名为virtual-reality的新 React 360 项目。将安装运行 React 360 应用程序所需的所有软件包,例如reactreact-nativereact-360react-360-webthreethree软件包安装了three.js,这是一个轻量级且易于使用的 JavaScript 3D 库,带有默认的 WebGL 渲染器。React 360 使用此渲染器来渲染 3D 图形,它通过添加一个允许您创建声明式 UI 组件的层来实现。

此外,将在具有相同名称的目录中创建构建项目所需的所有文件。该目录具有以下结构,其中以下文件很重要:

virtual-reality
|-- __tests__
    |-- index-test.js
|-- node_modules
|-- static_assets
    |-- 360_world.jpg
.babelrc
client.js
index.html
index.js
package.json

__tests__目录是您可以使用react-test-renderer包创建测试文件的地方。node_modules目录是您安装包的位置,而static_assets目录包含在开发模式中静态使用的文件,以后可能会转移到 CND。要在浏览器(或移动设备)中使用 React 360,您需要使用 Babel 来转译您的代码。其配置可以在.babelrc文件中找到。由react-360-cli创建的最重要的文件是client.jsindex.htmlindex.js,因为这些文件是您开发和提供应用程序的地方。client.js文件包含您用于执行应用程序的代码,而index.js包含实际的代码,该代码被挂载到index.html中的 DOM 中。

与 webpack 不同,React 360 使用了另一个 JavaScript 捆绑器Metro。这是由 Facebook 创建的,就像 React 一样。Metro 是 React Native 项目的捆绑器,由于 React 360 也从 React Native 中借鉴了很多原则来在 VR 设备上运行,因此 Metro 是 React 360 应用程序的首选捆绑器。与 webpack 一样,所有源代码都被捆绑成一个可供 Web 浏览器阅读的大文件。在开发应用程序时,Metro 捆绑器将运行一个本地开发服务器,允许您在浏览器中查看应用程序。文件在请求时被编译或处理,当应用程序完成时,它可以用于创建一个生产就绪的构建。您可以使用以下命令启动捆绑器来启动开发服务器:

npm start 

这将启动 Metro 捆绑器并编译您的源代码,该代码将被挂载到index.html文件中,并在http://localhost:8081/index.html上提供。

当您首次在浏览器中访问项目时,捆绑器可能需要更长的时间来加载,因为它需要读取您的文件系统以获取有关如何呈现的更多信息。如果您对项目的源代码进行更改,这些更改将更快地变得可见,以增加您的开发速度。由 React 360 CLI 创建的初始应用程序现在在http://localhost:8081/index.html上可见,显示了一个 360 度查看器,可以探索static_assets/360_world.jpg文件中的黑暗景观。它看起来如下:

React 360 应用程序可以显示 360 度(或 3D)图像或视频作为背景,并在此背景上渲染 2D 和 3D UI 组件。在client.js文件中,来自static_assets目录的图像被用作 360 度 2D 背景图像,代码如下:

function  init(bundle, parent, options  = {}) { ... **// Load the initial environment** r360.compositor.setBackground(r360.getAssetURL('360_world.jpg'**))** } window.React360  = {init};

getAssetUrl函数指向static_assets目录,并且在应用程序处于生产状态时,可以稍后用于指向 CDN 或其他 URL,其中托管了背景图像。

如果您有 3D 眼镜,可以用 3D 360 图像替换初始的 360 度 2D 图像,以创建 3D 效果。例如,NASA 的网站是寻找来自任何火星任务的 360 度 3D 图像的好来源。可以在该任务的图像网址中找到这些图像,并将下载的文件放在static_assets中。这应该在client.js文件中使用,而不是360_world.jpg文件。

通过react-360 init创建的应用程序也显示一些 UI 组件;在下一节中,我们将更详细地探讨如何在 React 360 中使用 UI 组件。

React 360 UI 组件

之前,我们提到 React 360 使用了许多 React Native 的概念之一是可以渲染的 UI 组件。React 360 默认提供了四个 UI 组件,即ViewTextEntityVrButton。首先,ViewText组件是 2D 的,并且在index.js文件中用于创建面板和问候消息,您可以在应用程序中看到。另外两个组件更复杂,可以用于渲染 3D 对象(Entity组件)或响应用户操作,例如按下a键(VrButton组件)。

client.js文件中,这些组件可以放置在index.js文件中的圆柱面上,因为这些组件是由client.js中的renderToSurface渲染的。在这里,声明的默认表面指的是显示来自index.js的 UI 组件的 2D 圆柱面:

function  init(bundle, parent, options  = {}) { ...  ** // Render your app content to the default cylinder surface** r360.renderToSurface(
 r360.createRoot('virtual_reality', { /* initial props */ }), r360.getDefaultSurface() **);** ... } window.React360  = {init};

index.js文件中,我们有ViewText组件,用于渲染默认表面,显示应用程序启动时看到的欢迎消息。index.js的默认导出称为virtual_reality,它指的是项目名称,并且与client.js中的createRoot函数使用的名称相同。

随着应用程序的增长,初始结构和命名可能会变得有点混乱。为了解决这个问题,您可以拆分组件,并在index.js中区分应用程序的入口和实际的 UI 组件。需要进行以下更改:

  1. index.js文件移动到一个名为Components的新目录中,并将该文件命名为Panel.js。在这里,您需要将此类组件的名称从virtual_reality更改为Panel

不幸的是,当前版本的 React 360 与 React 16.8+不兼容,因此您需要使用类组件来使用生命周期。

import  React  from  'react'; import { - AppRegistry,  StyleSheet,
 Text,
 View, } from  'react-360'; - export  default  class  virtual_reality  extends  React.Component {
+ export default class Panel extends React.Component {  render() {
 return ( <View  style={styles.panel}> <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to React 360</Text> </View> </View> );
   }
 }; const  styles  =  StyleSheet.create({
  ... }); - AppRegistry.registerComponent('virtual_reality', () =>  virtual_reality); 
  1. 这个新创建的Panel组件可以被导入到index.js文件中,您需要删除其中已经存在的所有代码,并用以下代码块替换它:
import { AppRegistry, } from  'react-360'; import  Panel  from  './Components/Panel';  AppRegistry.registerComponent('virtual_reality', () =>  Panel);
  1. 要查看您所做的更改,您需要在http://localhost:8081/index.html处刷新浏览器,之后 Metro bundler 将重新编译代码。由于您没有进行可见的更改,您需要查看终端中的输出来查看是否成功。要直接在浏览器中查看这些更改,您可以通过更改Panel组件中Text组件内的值来更改显示的文本:
import  React  from  'react'; import { StyleSheet,
 Text,
 View, } from  'react-360'; export default class Panel extends React.Component {
 render() { return ( <View  style={styles.panel}> <View  style={styles.greetingBox}> -         <Text  style={styles.greeting}>Welcome to React 360</Text> +         <Text  style={styles.greeting}>Welcome to this world!</Text**>** </View> </View> );
  }; }; ...

在进行此更改后刷新浏览器,将显示文本“欢迎来到这个世界!”而不是初始消息。

这些ViewText组件是简单的 2D 元素,可以使用StyleSheet进行样式设置,您在 React Native 中也使用过。通过使用这种方法来为您的 React 360 组件设置样式,React 360 的学习曲线变得不那么陡峭,并且应用了“一次学习,随处编写”的原则。ViewText组件的样式放在scr/Panel.js文件的底部。可以用于ViewText组件的样式规则是有限的,因为并非每个样式规则都适用于这些组件中的每一个。您可以对这些样式进行一些小的更改,就像我们在以下代码块中所做的那样:

...

const  styles  =  StyleSheet.create({
 panel: { // Fill the entire surface width:  1000,
 height:  600,
 backgroundColor: 'rgba(255, 255, 255, 0.4)',  justifyContent: 'center',  alignItems: 'center',
 }, greetingBox: { -   padding:  20, -   backgroundColor: '#000000',  -   borderColor: '#639dda', **+   padding: 25,**
**+   backgroundColor: 'black',**
**+   borderColor: 'green',** borderWidth:  2,
 }, greeting: { fontSize:  30,
 } });

以下截图显示了在进行这些更改后您的应用程序将会是什么样子,面板内显示欢迎消息的框已经有了一些变化:

此外,使用panel样式的第一个视图是在client.js中创建的,默认表面是圆柱形,宽度为1000px,高度为600px。还可以更改此表面的形状和大小,我们将在接下来的部分中进行。

在这一部分,您学习了如何开始使用 React 360 的基础知识。现在,我们将学习如何与 React 360 进行交互。

在 React 360 中的交互

在上一节中,您设置了 React 360 的基础知识,并对显示欢迎消息的初始表面进行了一些更改。使用 React 360,可以创建其他甚至与用户进行一些交互的表面。这些表面可以具有不同的形状和大小,例如平面或圆形,这使得可以在这些表面上添加可操作的按钮。

使用本地状态和 VrButton

在这一部分,您将在表面上添加一些按钮,以便用户可以关闭欢迎消息或切换背景图像场景。首先,让我们从创建一个按钮开始,让我们关闭欢迎消息表面:

  1. Panel组件是一个类组件,它让您可以访问生命周期和本地状态管理。由于您希望能够关闭欢迎消息,因此可以使用本地状态。在Panel组件的声明顶部,您必须添加一个constructor,其中将有初始状态:
import  React  from  'react'; import { StyleSheet,
 Text,
 View, } from  'react-360'; export default class Panel extends React.Component {
**+ constructor() {**
**+   super();**
**+   this.state = {**
**+     open: true**
**+   }**
**+ }**

 render() { return (        ...

如果您对使用类组件进行生命周期不太熟悉,可以回顾一下本书的前几章。在这些章节中,类组件用于生命周期,而不是 Hooks,您在最近的几章中主要使用了 Hooks。

  1. 现在已经设置了初始状态,您可以使用它来修改面板的样式,方法是使用一个styles数组而不是单个对象。除了在这个数组中传递一个style对象之外,您还可以通过使用条件展开直接插入样式规则。如果打开状态不为 true,则会将display: 'none'样式规则添加到面板的样式中。否则,一个空数组将被展开到style数组中:
...

export default class Panel extends React.Component {
  constructor() {
    super();
    this.state = {
      open: true,
    };
  }

 render() {
**+   const { open } = this.state;** return ( -     <View  style={styles.panel}> +     <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}>  <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to this world!</Text> </View> </View> );
  }; };
  1. 在将此state变量添加到面板的样式属性之后,您可以创建将更改打开状态的按钮。您可能还记得,React 360 具有四个默认 UI 组件之一称为VrButton。该组件类似于 React Native 中的TouchableOpacity,默认情况下没有任何样式。可以从react-360中导入VrButton,并将其放置在Text(或View)组件内。单击此VrButton将更改打开状态,因为它使用setState方法:
import  React  from  'react'; import { StyleSheet,
 Text,
 View,
**+ VrButton,** } from  'react-360'; export default class Panel extends React.Component {

  ...

 render() { return (      <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}>  <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to this world!</Text> </View> +       <VrButton +         onClick={() =>  this.setState({ open:  false })} +       >
+         <Text>Close X</Text> +       </VrButton>  </View> );
  }; };
  1. 我们还可以为VrButtonText添加一些样式。这些组件的样式可以放在与此文件中其他组件的样式相同的StyleSheet中:
 ... render() { return (      <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}>  <View  style={styles.greetingBox}> <Text  style={styles.greeting}>Welcome to this world!</Text> </View>
        <VrButton          onClick={() =>  this.setState({ open:  false })}
**+         style={styles.closeButton}**
        >
-         <Text>Close X</Text>
+         <Text style={styles.close}>Close X</Text>         </VrButton>  </View> );
  }; }; 
const  styles  =  StyleSheet.create({ 
  ... 

+   closeButton: { +     position: 'absolute',  +     top:  20, +     right:  20, +   },
+   close: { +     fontSize:  40, +     color: 'black', +   },
});

现在,当您在浏览器中刷新应用程序时,面板的右上角将有一个按钮,上面写着Close X。单击此按钮,面板将关闭,您可以自由探索整个背景表面。除了关闭面板,您还可以更改整个应用程序的风景,这将在本节的最后部分中添加。

动态更改场景

该应用程序使用默认背景显示在表面上,但也可以动态更改此背景图像。初始应用程序带有默认的 360 度背景图像。要更改此设置,您需要制作自己的 360 度全景图像,或者从互联网上下载一些图像。可以使用特殊相机或在移动设备上下载应用程序来创建自己的 360 度图像。在线图像可以在许多库存照片网站上找到。在本书的 GitHub 存储库中,在ch12-assets分支中,您可以找到一些 360 度全景图像的选择。

目前,您的应用程序只有一个默认表面,这是一个圆形表面,显示了Panel组件的欢迎面板。还可以添加平面组件,以便用户可以使用按钮更改风景。这需要您进行以下更改:

  • 创建一个显示指定按钮的组件

  • index.js导入并注册组件

  • client.js中设置新的表面

在进行这些更改之前,您必须从 GitHub 存储库下载图像,并将它们放在static_assets目录中,以便可以从应用程序内部使用它们。现在,进行以下更改以改变风景:

  1. Components目录中创建一个名为Navigation的新组件,并将以下代码块放入其中。这将返回一个具有表面基本样式的组件,稍后将在其中放置按钮:
import  React  from 'react';  import { StyleSheet, View } from 'react-360';  export  default  class  Navigation  extends  React.Component { render() {
 return  <View  style={styles.navigation} />;
 } } const  styles  =  StyleSheet.create({
 navigation: { width:  800, height:  100, backgroundColor: 'blue', justifyContent: 'space-between', alignItems: 'center',
    flexDirection: 'row',
 } });
  1. index.js文件中,您必须导入Navigation组件,并使用AppRegistry方法注册它。这将确保该组件可以呈现到一个表面上:
import { AppRegistry } from 'react-360';  import  Panel  from './Components/Panel';  + import  Navigation  from './Components/Navigation';  AppRegistry.registerComponent('Panel', () =>  Panel); + AppRegistry.registerComponent('Navigation', () =>  Navigation);
  1. client.js文件中,必须将此Navigation组件添加到一个表面上;在这种情况下,这是一个平面表面。可以使用react-360Surface方法创建一个新表面,并且必须指定组件的形状和大小。您还可以设置一个角度来定位组件:
function  init(bundle, parent, options  = {}) {  const  r360  =  new  ReactInstance(bundle, parent, {
    // Add custom options here
    fullScreen:  true,
    ...options
  });

+ const  navigationPanel  =  new  Surface(1000, 100, Surface.SurfaceShape.Flat); + navigationPanel.setAngle(0, -0.3); + r360.renderToSurface(r360.createRoot('Navigation'), navigationPanel**);**

 // Render your app content to the default cylinder surface r360.renderToSurface(
 r360.createRoot('virtual_reality', { /* initial props */ }), r360.getDefaultSurface(),
 ); ... } window.React360  = {init};

通过刷新浏览器中的项目,您将看到一个蓝色块被渲染在屏幕底部。要向此块添加按钮,您可以使用VrButton组件,并将当前选择的背景放在本地状态中。现在让我们来做这个:

  1. Components/Navigation.js文件中,您可以向Navigation组件添加必要的按钮。为此,您需要从react-360中导入VrButtonText组件,并将它们放在正在呈现的View组件中。它们将获得样式属性,因为您希望按钮在左侧或右侧都有边距:
import  React  from 'react';  - import { StyleSheet, View } from 'react-360'; **+ import {**
**+   StyleSheet,**
**+   Text,**
**+   View,**
**+   VrButton,**
**+ } from 'react-360';** export  default  class  Navigation  extends  React.Component { render() { -   return  <View  style={styles.navigation} />;
+   return (
+     <View style={styles.navigation}>
+       <VrButton style={[styles.button, styles.buttonLeft]}>
+         <Text style={styles.buttonText}>{'< Prev'}</Text>
+       </VrButton> +       <VrButton style={[styles.button, styles.buttonRight]}>
+         <Text style={styles.buttonText}>{'Next >'}</Text>
+       </VrButton> **+   );**
 } } ...
  1. 这些样式对象可以添加到此文件底部的StyleSheet方法中,就在navigation的样式下面:
... const  styles  =  StyleSheet.create({
 navigation: { width:  800, height:  100, backgroundColor: 'blue',  justifyContent: 'space-between',  alignItems: 'center',
    flexDirection: 'row', }, + button: { +   padding:  20, +   backgroundColor: 'white',  +   borderColor: 'black',  +   borderWidth:  2, + alignItems: 'center',  + width:  200, + }, + buttonLeft: { + marginLeft:  10, + }, + buttonRight: { +   marginRight:  10, + }, + buttonText: { +   fontSize:  40, +   fontWeight: 'bold',  +   color: '**blue',** **+ },** });
  1. 稍后可以使用react-360assets方法将从 GitHub 存储库下载并放置在static_assets中的不同 360 度全景背景图像导入到此文件中。为此,您需要创建一个常量,其中包含这些图像的所有文件名的数组,包括由react-360-cli添加的初始图像。此外,必须在此处导入assetsEnvironment方法,因为您需要这些来更改背景图像:
import  React  from 'react';  import {
**+ assets,**
**+ Environment,**
  StyleSheet,
  Text,
  View,
  VrButton,
} from 'react-360';

**+ const backgrounds = [** +  '360_world.jpg', +  'beach.jpg', +  'landscape.jpg', +  'mountain.jpg', +  'winter.jpg',
+ ];  export  default  class  Navigation  extends  React.Component {

  ...
  1. 就像我们为Panel组件所做的那样,我们需要创建一个初始状态,定义显示哪个背景。这将是背景数组的第一个背景,即0。此外,必须创建一个函数,可以使用setState方法来改变currentBackground。当currentBackground的状态已经改变时,将使用Environment方法更新背景图像,该方法使用assets方法从static_assets目录中选择一个背景:
...

export  default  class  Navigation  extends  React.Component {
**+ constructor() {**
**+  super();**
**+  this.state = {**
**+    currentBackground: 0**
**+  };**
**+ }** 
+ changeBackground(change) { +  const { currentBackground } =  this.state; 
+  this.setState( +    {
+      currentBackground: currentBackground  +  change +    },
+    () => { +      Environment.setBackgroundImage( +        asset(backgrounds[this.state.currentBackground], { format: '2D' }) +      );
+    }
+  );
+ }
 ...
  1. 新创建的changeBackground函数可以在Navigation组件挂载时调用,并使用第一个背景图像,但是当用户点击按钮时,也必须调用changeBackground函数。这可以通过在按钮上添加componentDidMount生命周期并使用onClick事件调用函数来实现:
...

export  default  class  Navigation  extends  React.Component {

  ...

**+ componentDidMount() {**
**+   this.changeBackground(0);**
**+ }** render() {
    return (      <View  style={styles.navigation}> +  <VrButton  style={[styles.button, styles.buttonLeft]}> +       <VrButton +         onClick={() =>  this.changeBackground(-1)} +         style={[styles.button, styles.buttonLeft]} +       >
          <Text  style={styles.buttonText}>{`< Prev`}</Text> </VrButton> +  <VrButton  style={[styles.button, styles.buttonRight]}> +       <VrButton +         onClick={() =>  this.changeBackground(1)} +         style={[styles.button, styles.buttonRight]} +       >
          <Text  style={styles.buttonText}>{`Next >`}</Text> </VrButton> </View> );
  }
}

... 
  1. 当您在浏览器中刷新项目时,您可能会注意到当您按一次左按钮或多次按右按钮时会出现错误。为了防止发生此错误,您需要限定currentBackground状态的最大和最小值。该值不能低于零或高于backgrounds数组的长度。您可以通过对changeBackground函数进行以下更改来实现这一点:
...

export  default  class  Navigation  extends  React.Component {

  ... 
  changeBackground(change) {
   const { currentBackground } =  this.state; 
   this.setState(
     {
-      currentBackground: currentBackground  +  change
+ currentBackground:
+        currentBackground  +  change  >  backgrounds.length  -  1 +          ?  0 +          :  currentBackground  +  change  <  0 +          ?  backgrounds.length  -  1 +          :  currentBackground  +  change      },
     () => {       Environment.setBackgroundImage(
         asset(backgrounds[this.state.currentBackground], { format: '2D' })       );
     }
   );
  }
 ...

currentBackground状态的值将始终是可以在backgrounds数组长度内找到的值,这使您可以在不同的背景图像之间来回导航。点击 Prev 或 Next 按钮几次后,您的应用程序将如下所示:

使用 React 360,您可以做的另一件事是添加动画组件,就像我们在学习 React Native 时所做的那样。您将在下一节学习如何添加这些动画。

动画和 3D

到目前为止,在本章中添加的所有组件都是 2D 的,并且没有动画;但是,您也可以使用 React 360 对组件进行动画处理,甚至添加 3D 对象。这些 3D 对象必须在特殊的 3D 建模软件中预先构建,或者从互联网上下载,并可以添加到应用程序的表面上。对于动画,必须导入 Animated API,这类似于我们用于 React Native 的 Animated API。

动画

在进入 React 360 中使用 3D 对象之前,让我们学习如何使用 React 360 中的 Animated API 创建动画。Animated API 使用 React Native 中的 Animated API,可用于为 UI 组件创建简单和高级动画。使用 Animated API,您可以轻松创建淡入淡出或旋转的动画,只需使用受本地状态影响的值即可。

Panel组件是可以进行动画处理的一个组件,它显示一个欢迎消息,因为这个组件有一个元素,用户可以点击关闭表面。当用户点击关闭按钮时,组件的显示样式规则将被设置为none,使组件突然消失。与此相反,您可以通过以下方式将其改变为平滑的动画:

  1. panel组件是在Components/Panel.js文件中创建的,这是必须从react-360导入AnimatedAPI 的地方:
import  React  from 'react';  - import { StyleSheet, Text, View, VrButton } from 'react-360'; **+ import {**
**+   Animated,**
**+   StyleSheet,**
**+   Text,**
**+   View,**
**+   VrButton,**
**+ } from 'react-360';** export  default  class  Panel  extends  React.Component {
  1. constructor()中,应该设置Animated值的初始值。在这种情况下称为opacity,因为您希望Panel组件的opacity值变为零以使其消失。最初,opacity应为 1,因为用户打开应用程序时必须显示欢迎消息:
...

export  default  class  Panel  extends  React.Component { constructor() {
 super();
 this.state  = { open:  true, +     opacity:  new  Animated.Value(1),  }; }

  render() {
    ... 
  1. 当用户在Panel组件中点击VrButton时,open状态将被更改,之后动画应该开始。因此,必须创建一个componentDidUpdate()生命周期方法,在其中可以检查open状态的变化并在之后开始动画。当open的值从true变为false时,动画应该开始将opacity的值从1变为0,使其消失。
export  default  class  Panel  extends  React.Component { constructor() {
 super();
 this.state  = { open:  true,
 opacity:  new  Animated.Value(1),
 }; } + componentDidUpdate() { +   const { open, opacity } =  this.state; +   Animated.timing(opacity, { +     toValue:  open  ?  1  :  0, +     duration:  800, +   }).start(); **+ }**

 render() {

    ...
  1. 最后,这个值应该传递给Animated组件的style属性,这意味着您需要将View组件更改为可以处理动画的Animated.View组件。style属性中的display样式规则可以被删除,并替换为opacity,因为这控制着组件对用户是否可见:
render() { - const { open, opacity } =  this.state**;**
**+ const { opacity } this.state;**
 return ( -   <View  style={[styles.panel, ...(!open ? [{ display: 'none' }] : [])]}> +   <Animated.View  style={[styles.panel, { opacity }]}**>**
 <View  style={styles.welcomeBox}>
 <Text  style={styles.welcome}>Welcome to this world!</Text>
 </View>
 <VrButton
 onClick={() =>  this.setState({ open:  false })}
 style={styles.closeButton}
 > <Text  style={styles.close}>Close X</Text> </VrButton> -   </View>
+   </Animated.View**>**
 ); }

现在,当您点击关闭带有欢迎消息的Panel组件的VrButton时,该组件将慢慢溶解到背景中并消失。同样的动画效果也可以添加到Navigation组件中,因为您希望确保我们的用户知道他们可以浏览不同的背景。您可以通过使其重复淡入淡出来突出显示点击选项,例如Next按钮。其中很多逻辑与Panel组件相同:

  1. Components/Navigation.js文件的顶部导入AnimatedAPI,并创建opacity状态的初始值:
import  React  from 'react';  import { + Animated,  asset,
 Environment,
  StyleSheet,
 Text,
 View,
 VrButton, } from 'react-360'; ... export  default  class  Navigation  extends  React.Component { constructor() {
 super();     this.state  = { currentBackground:  0, +     opacity:  new  Animated.Value(0),  }; } changeBackground(change) {
    ...
  1. 动画应该在组件挂载后立即开始,因此Animated.timing方法,用于改变opacity的值,必须放在componentDidMount()生命周期方法中。这将启动opacity01的动画,使按钮内的文本闪烁:
...

componentDidMount() { + const { opacity } =  this.state;  this.changeBackground(0); + Animated.timing(opacity, { +  toValue:  1, +  duration:  800 + }).start**()** } render() {

  ...
  1. VrButton中的Text组件用于按钮,以便用户可以导航到下一个背景图像,现在可以更改为Animated.Text组件,并且必须将opacity样式规则添加到style属性中。这将向该组件添加动画,使文本在应用程序挂载时闪烁一次。
render() {
**+ const { opacity } = this.state;**
  return (    <View  style={styles.navigation}>
      <VrButton
        onClick={() =>  this.changeBackground(-1)}
        style={[styles.button, styles.buttonLeft]}
      >
        <Text  style={styles.buttonText}>{`< Prev`}</Text> </VrButton>
 <VrButton
        onClick={() =>  this.changeBackground(1)}
        style={[styles.button, styles.buttonRight]}
      >
-       <Text  style={styles.buttonText}>{`Next >`}</Text> +       <Animated.Text  style={[styles.buttonText, { opacity }]}>{`Next >`}</Animated.Text> </VrButton> </View> );
}

... 
  1. 您不希望按钮文本只闪烁一次。为了使其重复闪烁,您可以使用Animatedloopsequence方法来获得此动画的多次迭代。为了使其更加平滑,我们可以给动画添加一个小延迟。这将迭代 10 次,之后按钮将停止闪烁:
...

componentDidMount() {
  const { opacity } =  this.state;
 this.changeBackground(0);

**+ Animated.loop(**
**+  Animated.sequence([**
**+    Animated.delay(400),**
 Animated.timing(opacity, {
 toValue:  1,
 duration:  800 -    }).start**()**
**+    })**
**+  ]),**
**+  {**
**+    iterations: 10**
**+  }**
**+ ).start();** } render() {

  ...

现在,当应用程序挂载时,下一个按钮将闪烁 10 次,从而强调用户可以在背景场景之间进行导航。然而,这些动画并不是您可以添加的唯一动画特性。在下一节中,您将学习如何添加动画的 3D 对象。

渲染 3D 对象

要在 React 360 中使用 3D 对象,您需要预先构建的 3D 对象,可以使用特殊的 3D 建模软件创建,也可以从互联网上下载。在本节中,我们将使用 GitHub 存储库中的 3D 对象,您可以在该章节中找到一个.obj文件,它受到 React 360 的支持。除了 OBJ,GLTF 模型也受到 React 360 支持作为 3D 对象。

OBJ 文件是 3D 模型文件的标准格式,可以被许多 3D 工具导出和导入。请记住,React 360 不支持照明,您需要包含更高级的软件包来渲染 3D 模型中的复杂纹理。因此,这个例子中使用的 3D 模型只是一个颜色,即白色。

在 React 360 中添加 3D 对象可以很容易地使用Entity对象,同时使用存储在static_assets目录中的 3D 模型。通过使用Entity,3D 模型可以转换为一个组件,您需要在index.js中注册它,以便在client.js中使用并添加到应用程序中。

添加 3D 对象,进行以下更改:

  1. 首先,确保你已经将本章的 GitHub 存储库中的helicopter.obj文件复制到static_assets目录中,并在Components目录中创建一个名为Helicoper.js的新文件。在这个文件中,可以使用asset方法导入 3D 模型,并将其添加为Entity对象的源。为此,请使用以下代码:
import  React  from 'react';  import { asset } from 'react-360';  import  Entity  from 'Entity';  export  default  class  Helicopter  extends  React.Component { render() { return ( <Entity source={{ obj:  asset('helicopter.obj'), }} style={{ transform: [
            { rotate: 90 },
            { scaleX:  0.02 }, 
            { scaleY:  0.02 }, 
            { scaleZ:  0.02 },
          ] }} /> ); } }

Entity对象在style属性中的缩放将减小 3D 模型的大小;否则,它将会太大而无法正确显示。此外,rotateY的值将在y轴上将直升机旋转 90 度。

  1. 这个Helicopter组件应该在你的应用程序中显示,但只有在index.js文件中将其注册到AppRegistry中才能实现:
import { AppRegistry } from 'react-360';
import Panel from './Components/Panel';
import Navigation from './Components/Navigation';
+ import Helicopter from './Components/Helicopter';

AppRegistry.registerComponent('Panel', () => Panel);
AppRegistry.registerComponent('Navigation', () => Navigation);
+ AppRegistry.registerComponent('Helicopter', () => Helicopter);
  1. 这个组件可以在client.js文件中使用renderToLocation方法挂载到应用程序中。之前,你使用renderToSurface方法来挂载PanelNavigation组件,但是对于 3D 对象,这种方法行不通。除了组件本身,renderToLocation方法还需要指定对象放置的位置。
- import { ReactInstance, Surface } from 'react-360-web'; + import { ReactInstance, Surface, Location } from 'react-360-web';  function  init(bundle, parent, options  = {}) {

  ... + const  location  =  new  Location([-100, 10, -2]);
+ r360.renderToLocation(r360.createRoot('Helicopter'), location**);**

 // Render your app content to the default cylinder surface r360.renderToSurface(

    ... 

现在,当你打开应用程序时,当你向左转 90 度时,将会看到一个白色的直升机。在上述代码中,Location用于在应用程序中创建一个位置,3D 模型将被挂载在这个位置上。这是通过new Location([-100, 10, -2])来实现的。这将把对象放置在用户启动应用程序时的初始位置的左侧 100 米,上方 10 米,前方 2 米处。这可以在以下截图中看到,这是在应用程序的不同场景之一中拍摄的:

然而,React 360 不仅仅局限于导入和渲染 3D 对象:你也可以像任何其他组件一样对它们进行动画处理。为此,可以再次使用 Animated API。你可以使用这个 API 与本地状态一起为 3D 直升机添加任何动画。Entitystyle属性已经具有一些样式,用于确定比例,这是可以通过使用Animated值动态地实现的。通过进一步减小直升机的比例,它看起来就像在飞行,并且会消失在远处。通过改变rotateY的值,可以添加更多效果,使直升机看起来正在转向。

要创建一个动画的 3D 对象,请对Components/Helicopter.js进行以下更改:

  1. react-360中导入Animated并创建EntityAnimated版本。由于这不是预定义的Animated组件,我们不能通过输入Animated.Entity来实现。相反,我们需要使用createAnimatedComponent方法创建一个自定义的Animated组件:
import  React  from 'react';  - import {  asset } from 'react-360';  + import { Animated, asset } from 'react-360'; import  Entity  from 'Entity';  + const  AnimatedEntity  =  Animated.createAnimatedComponent(Entity**);** export  default  class  Helicopter  extends  React.Component {

  ...
  1. 必须在Helicopter组件中添加一个constructor,在其中将scalerotateY的初始Animated值设置为本地状态值。scale的初始值为0.02,与直升机的当前比例相同,而rotateY将获得与当前值相同的值:
...

export  default  class  Helicopter  extends  React.Component { + constructor() { +   super(); +   this.state  = { +     scale:  new  Animated.Value(0.02), +     rotateY:  new  Animated.Value(90) +   }; **+ }**

  render() {

    ...
  1. 我们可以在componentDidMount()生命周期方法中创建动画序列,因为我们希望直升机转向并飞走。动画的第一部分是一个小延迟,所以动画不会在应用程序挂载后立即开始。1 秒后(1,000 毫秒),直升机将开始转向约 8 秒,并在另一个小延迟后飞走:
... + componentDidMount() { +   const { scale, rotateY } =  this.state;
+ +   Animated.sequence([ +     Animated.delay(1000), +     Animated.timing(rotateY, { +       toValue:  0, +       duration:  8000 +     }), +     Animated.delay(800), +     Animated.timing(scale, { +       toValue:  0, +       duration:  8000 +     }) +   ]).start(); **+ }** render() {

  ...
  1. Entity组件必须被AnimatedEntity组件替换,后者处理来自Animated API 的值。这些值可以从本地状态中获取,以便将它们添加到AnimatedEntity组件的style属性中:
  render() { +   const { scale, rotateY } =  this.state;  return ( -     <Entity
+     <**AnimatedEntity** source={{ obj:  asset('helicopter.obj') }} style={{ transform: [
**-           { rotateY: 90 },**
**-           { scaleX: 0.02 },**
**-           { scaleY: 0.02 },**
**-           { scaleZ: 0.02 },**
**+** { rotateY }, +           { scaleX:  scale }, +           { scaleY:  scale }, +           { scaleZ:  scale **},** ] }} /> );
 } }

现在,直升机将从 90 度转向 0 度,经过一段时间,它将飞向远处并消失。

总结

在本章的最后,您已经结合了本书中收集到的所有知识,开始使用 React 360。虽然 React 360 使用了来自 React 和 React Native 的实践,但它的用途与其他 React 技术不同且更为特定。在撰写本文时,已经使用了诸如生命周期方法和 Animated API 之类的众所周知的原则,以创建一个允许用户探索 2D 全景图像的 VR 应用程序。它具有基本的动画,以及一个飞向远处的 3D 直升机对象。

通过这最后一章,您已完成本书的所有 12 章,并使用 React、React Native 和 React 360 创建了 12 个项目。现在,您对 React 的所有可能性以及如何在不同平台上使用它有了扎实的理解。虽然 React 和 React Native 已经是成熟的库,但不断添加新功能。即使您完成了本书的阅读,可能还会有新功能可以查看,首先是并发模式。我的建议是永远不要停止学习,并在宣布新功能时密切关注文档。

进一步阅读