Skip to content

Latest commit

 

History

History
1233 lines (951 loc) · 52.1 KB

File metadata and controls

1233 lines (951 loc) · 52.1 KB

十一、使用 React Native 和 GraphQL 构建全栈社交媒体应用

现在,您几乎可以称自己是 React-Native 的专家,因为您即将开始处理 React-Native 部分中最复杂的应用。移动应用的一大优势是,您可以直接向安装了应用的用户发送通知。这样,当应用中发生重要事件或有人已经有一段时间没有使用该应用时,您可以将目标用户锁定。此外,移动应用可以直接使用其运行设备的摄像头拍照和视频

在上一章中,您创建了一个移动消息传递应用,该应用具有身份验证流和实时数据,并使用带有 React Native 的 GraphQL。本章还将使用这些模式和技术创建一个移动社交媒体应用,该应用允许您将图像发布到社交订阅,并允许您在这些帖子上进行点播和评论。在本章中,使用相机不仅是一个重要的部分,而且您还可以通过 Expo 向用户发送通知。

本章将介绍以下主题:

  • 使用带有 React Native 和 Expo 的摄像头
  • 使用 React Native 和 GraphQL 刷新数据
  • 使用 Expo 发送移动通知

项目概述

一种移动社交媒体应用,使用本地 GraphQL 服务器向社交订阅请求和添加帖子,包括使用移动设备上的摄像头。基本身份验证是使用本地 GraphQL 服务器和 React 导航添加的,而 Expo 用于访问摄像头(滚动)以及在向帖子添加新评论时发送通知。

构建时间为 2 小时。

开始

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

您需要在移动 iOS 或 Android 设备上安装 application Expo 客户端,才能在物理设备上运行项目。

It's highly recommended to use the Expo Client application to run the project from this chapter on a physical device. Receiving notifications is currently only supported on physical devices, and running the project on either the iOS simulator or Android Studio emulator will result in error messages.

或者,您可以在计算机上安装 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 并进入首选项****外观&行为系统设置【T10 Android SDK】找到。该路径列在说明 Android SDK 位置*的框中,如下所示:/Users/myuser/Library/Android/sdk*

**This application was created using Expo SDK version 33.0.0, and so, you need to ensure that the version of Expo you're using on your local machine is similar. As React Native and Expo are frequently updated, make sure that you're working with this version so that the patterns described in this chapter behave as expected. In case your application doesn’t start or if you encounter errors, refer to the Expo documentation to learn more about updating the Expo SDK.

检查初始项目

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

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

npm install && npm start

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

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

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

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

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

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

运行此命令后,返回您机器的本地Ipv4 Address,如下图:

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 }) => {

  ...

无论您是从虚拟设备还是从物理设备打开应用,此时的应用应如下所示:

This application was created using Expo SDK version 33.0.0 and therefore you need to make sure the version of Expo you're using on your local machine is similar. As React Native and Expo are updated frequently, make sure that you're working with this version to ensure the patterns described in this chapter are behaving as expected. If your application won't start or you're receiving errors, make sure to check the Expo documentation to learn more about updating the Expo SDK.

初始应用由七个屏幕组成:AddPostAuthLoadingLoginNotificationsPostPostsSettingsLogin屏幕将是您首次启动应用时看到的第一个屏幕,您可以使用以下凭据登录:

  • 用户名test
  • 密码test

Posts屏幕将是登录时的初始屏幕,显示一个帖子列表,您可以点击该列表继续进入Post屏幕,而Settings屏幕显示一个不起作用的注销按钮。目前,AddPostNotification屏幕尚不可见,因为您将在本章后面的部分中向这些屏幕添加路由。

directory客户端中此 React 本机应用的项目结构如下所示,其中的结构类似于您在本书中之前创建的项目:

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.json中,而babel.config.js保存特定的巴别塔配置

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

可以在http://localhost:4000/graphqlURL 找到 GraphQL 服务器,GraphQL 游乐场将在此处可见。在这个平台上,您可以查看 GraphQL 服务器的模式,并检查所有可用的查询、变体和订阅。虽然您不会对服务器进行任何代码更改,但了解模式及其工作原理很重要。

服务器有两个查询,通过使用userName参数作为标识符来检索帖子列表或单个帖子。这些查询将返回具有iduserNameimage、计数值为starscommentsPost类型、具有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 的推送令牌或添加帖子。

If you're receiving an error stating Please provide (valid) authentication details, you'll need to log in to the application again. Probably, the JWT from the previous application is still available in AsyncStorage of Expo, and this will not validate on the GraphQL server for this chapter.

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

您将在本章中构建的应用将使用本地 GraphQL 服务器来检索和修改应用中可用的数据。此应用将显示来自社交媒体订阅的数据,并允许您回复这些社交媒体帖子。

使用带有 React Native 和 Expo 的摄像头

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

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

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

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

export const GET_POSTS = gql`
  ...
`;

+ export const ADD_POST = gql`
+   mutation addPost($image: String!) {
+     addPost(image: $image) {
+       image
+     }
+   }
+ `;
  1. Mutation到位后,添加帖子的画面必须添加到client/AppContainer.js文件的SwitchNavigator中。AddPost屏幕组件可在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. 当然,用户必须能够从应用的某个地方打开此模式,例如,从屏幕底部或标题的选项卡导航器。在这种情况下,您可以将导航链接添加到标题中的AddPost屏幕,这样,用户只需点击标题中的链接即可从Posts屏幕添加新帖子。可通过在client/Screens/Posts.js文件中设置navigationOptions添加此链接:
...

+ 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权限。permissions 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组件,它是一个样式化的TouchableOpacity元素,可以使用onPress函数。此组件必须添加到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 设备上打开一个弹出窗口,请求访问摄像机卷。如果不接受请求,则无法从相机卷中选择照片。

You can't ask the user for permission a second time; instead, you'd need to manually grant the permission to the camera roll. To set this permission again, you should go to the setting screen from iOS and select the Expo application. On the next screen, you're able to add the permission to access the camera.

  1. 当用户授予访问相机卷的权限时,您可以从 Expo 调用 ImagePicker API 打开相机卷。与 permissions API 一样,它过去是 Expo 核心的一部分,但现在已移动到一个单独的软件包中,您可以使用 Expo CLI 安装该软件包:
expo install expo-image-picker

这也是一个异步函数,它接受一些配置字段,例如纵横比。如果用户选择了图像,ImagePicker API 将返回一个包含字段 URI 的对象,该字段 URI 是用户设备上可用于Image组件的图像的 URL。通过使用useState钩子创建一个本地状态,可以将该结果存储在本地状态,以便稍后将其发送到 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 emulator 或运行 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组件可从世博会的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;

react-native-action-sheet导入connectActionSheet函数,在client/Screens/AddPost.js中创建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。选项有CameraCamera rollCancel,其中选择第一个选项调用带参数的addImageAsync函数,选择第二个选项调用不带参数的addImageAsync函数,选择最后一个选项关闭ActionSheet。打开ActionSheet的函数必须添加到getPermissionsAsync函数中,并在授予CameraCamera roll权限时调用:
...

+  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 (
    ...

点击图像占位符,用户可以选择使用CameraCamera rollAddPost组件添加图像。这可以通过ActionSheet完成,在 iOS 和 Android 上看起来会有所不同。在下面的屏幕截图中,您可以看到使用 iOS 模拟器或在 iOS 上运行的设备时的情况:

  1. 然而,这并不是全部,因为图像仍然必须发送到服务器才能出现在应用的订阅中,方法是从@apollo/react-hooks添加一个useMutation钩子,并使用返回的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;

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

  1. 通过将对refetchQueries变量的查询设置为useMutation钩子,可以重新加载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钩子检索一次查询,从而节省设置订阅的复杂性

轮询可以添加到useQuery钩子中,就像client/Screens/Posts.js中的这个钩子一样。通过在useQuery钩子的 object 参数上设置pollInterval值,可以指定钩子重新发送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 秒(2000 毫秒)发送一个带有GET_POSTS查询的文档,并且由于 GraphQL 服务器返回模拟数据,每次重新获取时显示的帖子都会不同。与订阅相比,轮询将重新发送文档以检索帖子,即使没有新数据——这对于显示模拟数据或经常更改的数据的应用来说不是很有用。

除了在useQuery钩子上设置pollInterval变量外,您还可以手动调用refetch函数,通过查询发送文档。社交媒体订阅的一种常见交互方式是能够下拉显示的组件以刷新屏幕上的数据。

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

  1. pollInterval道具可以设置为0,暂时禁用轮询。除了loadingdata变量外,还可以从useQuery钩子中检索更多变量。其中一个变量是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 本地组件来创建 pull-to-refresh 交互,它被称为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组件进行包装,这是一个样式化的FlatList组件,它迭代 GraphQL 服务器创建的帖子。作为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挂钩创建的值来控制是否显示装载指示器。除了一个refreshing道具外,刷新开始时应该调用的函数可以传递给onRefresh道具。您应该将refetch函数传递给这个函数,这个函数应该将refreshing状态变量设置为true并调用useQuery钩子返回的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钩子返回的加载消息干扰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 发送通知。

Testing notifications can only be done by using the Expo application on your mobile device. iOS and Android simulators cannot receive push notifications, as they don't run on an actual device.

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

  1. 为了能够发送通知,用户应该允许应用推送这些通知。若要请求此权限,应使用相同的权限 API 获取相机的权限。可以在名为registerForPushNotificationsAsync.js的新文件中添加请求此权限的功能。此文件必须在新的client/utils目录中创建,您可以在其中粘贴以下代码,这些代码还可以使用 Notifications 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 push 令牌或启动弹出窗口以请求 iOS 上的权限。由于您只想向注册用户索要他们的令牌,因此可以在client/Screens/Posts.js文件中使用useEffect挂钩:
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();
+ });

...

If you see this error, Error: The Expo push notification service is supported only for Expo projects. Ensure you are logged in to your Expo developer account on the computer from which you are loading your project., it means you need to make sure you're logged in to your Expo developer account. By running expo login from the Terminal, you can check whether you're logged in and otherwise, it will prompt you to log in again.

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

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

但是,当应用预先固定时,在运行 iOS 的设备上看不到此通知。因此,当您在苹果设备上使用 Expo 应用时,请确保 Expo 应用在后台运行。

本节的下一部分将展示当应用在前台运行时,如何接收通知。

处理前台通知

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

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

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

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

 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钩子中使用:
...

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中,文本显示为空!因为没有任何要显示的通知。要添加已发送给用户的任何通知,需要为 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查询请求当前处于本地状态的通知。通过调用 GraphQLclient常量上的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. 最后,来自NotificationsAPI 的侦听器被添加到client/App.js文件中,当应用前景化时,该文件将查找新的通知。使用前面来自client/constants.js的突变将新通知添加到本地状态。客户端调用的mutate函数将使用世博会通知中的信息,并将其添加到数据库中;突变将通过将此信息写入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 (

    ...

In the previous code block, you cannot use the useMutation Hook to send the ADD_NOTIFICATION mutation in a document, as React Apollo Hooks can only be used from a component nested within ApolloProvider. Therefore, the mutate function on the client object is used, which also provides the functionality to send documents with queries and mutations without using a Query or Mutation component.

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

通过提交表单,当应用前景化时,以及当应用在后台运行时,将向用户发送标题为Test和正文为This is a test的通知。

在生产环境中运行的移动应用中,您希望从 GraphQL 服务器而不是 Expo 仪表板发送通知。处理此应用数据流的本地 GraphQL 服务器已配置为向用户发送通知,但需要用户的 Expo push 令牌才能发送。此令牌应存储在服务器中并链接到当前用户,因为此令牌对此设备是唯一的。该令牌应以文档形式从将获取该令牌的变体发送到 GraphQL 服务器,并可从变体中的标头获取有关用户的信息:

  1. 首先,将在 GraphQL 服务器上存储 Expo push 令牌的变体必须与其他查询和变体一起创建在client/constants.js文件中。此变异采用的唯一变量是推送令牌,因为随每个文档一起发送到 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. 使用 Expo push 令牌发送带有此变异的文档必须从client/Posts.js文件中完成,在该文件中通过调用registerForPushNotificationsAsync函数检索令牌。此函数将返回推送令牌,您可以将其与变异的文档一起发送。要发送此文档,可以使用@apollo/react-hooks中的useMutation钩子,您必须将其与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';

...

Before React Apollo Hooks were available, it was complicated to use a mutation without the usage of a Mutation component, as sending mutations was only possible from the client object or the Mutation component. Accessing the client object from a React component is possible by importing an ApolloConsumer component that can read the client value from ApolloProvider that wraps your application.

  1. 现在可以使用来自registerForPushNotificationsAsyncexpoToken作为参数的STORE_EXPO_TOKEN突变调用useMutation钩子,该参数返回一个函数来存储名为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 } }); +   });
  }, []);

...

每当安装Posts屏幕时,此 Expo push 令牌将被发送到 GraphQL 服务器,例如,您可以通过在AddPostsPosts屏幕之间切换来强制执行此操作。当 GraphQL 服务器请求Posts屏幕的内容时,服务器将向您的应用发送随机通知,您可以从Notifications屏幕查看该通知。此外,无论应用处于前台还是后台,您都可以从 Expo 仪表板发送任何通知。

总结

在本章中,您使用 React Native 和 Expo 创建了一个移动社交媒体应用,该应用使用 GraphQL 服务器发送和接收数据并进行身份验证。使用 Expo,您已经了解了如何让应用请求访问权限使用设备的相机或相机卷向帖子添加新照片。此外,Expo 还用于接收来自 Expo 仪表板或 GraphQL 服务器的通知。无论应用是在后台运行还是在前台运行,用户都会收到这些通知。

在完成此社交媒体应用时,您已经完成了本书的最后一章 React Native,现在可以从最后一章开始。在最后一章中,您将探索 React 的另一个用例,即 React 360。使用 React 360,您可以通过编写 React 组件创建 360 度二维和三维体验

进一步阅读