一对一通信是手机的主要用途,不过,短信已经很快被直接短信应用取代。在本课中,我们将在 Firebase 的支持下在 React Native 中构建一个消息传递应用程序,Firebase 是一种移动后端服务,它将使我们无需为应用程序构建整个后端。相反,我们将专注于从前端完全处理应用程序的状态。当然,这可能会带来最终需要解决的安全问题,但为了将本书的重点放在 React Native 的功能上,我们将坚持在应用程序中保留所有逻辑的方法。
Firebase 是一个基于自同步数据集的实时数据库,它与 MobX 配合得非常好,因此我们将再次使用它来控制应用程序的状态。但在本课程中,我们将深入探讨,因为我们将构建更大的数据存储,这些数据存储将通过 mobx react 连接器注入到我们的组件树中。
我们将构建一个应用程序,用于 iOS 和 Android,并具有一些特定于平台的导航代码(iOS 将使用标签导航,Android 将使用抽屉导航)。
为了减少代码的大小,在本课程中,我们将把重点放在功能上,而不是设计上。大多数用户界面都是简单明了的,但要尽量记住可用性。此外,我们将在聊天屏幕上使用react-native-gifted聊天——一个预构建的 React 本机组件,根据消息列表呈现聊天室。
消息传递应用程序比我们在前面的课程中回顾的应用程序需要更多的工作,因为它需要一个由登录、注册和注销组成的用户管理系统。我们将降低使用 Firebase 作为后端构建此系统的复杂性。与它的用户管理系统一起,我们将使用他们的推送通知系统在向用户发送新消息时通知他们。Firebase 还免费提供一个分析平台、一个 lambda 功能服务和一个存储系统,但我们将从中获得最大利润的功能是他们的实时数据库。我们将在那里存储用户的个人资料、消息和聊天数据。
让我们来看看我们的应用程序将有什么样的心理画面,我们将建立屏幕:
第一个屏幕将是登录/注册屏幕,因为我们需要用户提供一个名称和一些凭据,以便将其设备连接到特定帐户,这样他们就可以接收需要接收的每条消息的推送通知。这两种身份验证方法都使用 Firebase 的 API 进行验证,成功后会出现聊天屏幕:
按下联系人列表中的联系人时,应用程序将在聊天屏幕中显示与所选联系人的对话:
聊天屏幕将显示为登录用户启动的所有聊天。最初,此屏幕将为空,因为用户不会发起任何聊天。要开始对话,用户应转到搜索屏幕以查找一些联系人:
这是一个简单的屏幕,用户可以在其中输入联系人姓名以在数据库中搜索。联系人姓名是否匹配;用户可以点击它开始对话。从那时起,对话将显示在聊天屏幕上。
最后一个屏幕是配置文件屏幕:
此屏幕仅用于注销当前用户。在扩展应用程序时,我们可以添加更多功能,例如更改化身或用户名。
虽然这款应用在 Android 上看起来非常相似,但导航将被一个抽屉取代,所有屏幕都可以从抽屉中看到。让我们来看看 Android 版本:
登录/注册屏幕具有标准文本输入和 Android 按钮组件:
一旦用户登录,他/她可以通过滑动手指手势打开抽屉,浏览所有屏幕。登录后默认打开的屏幕是聊天屏幕,我们将在其中列出用户已打开的对话列表:
在此屏幕上,用户可以按特定对话以列出其中的消息:
下一个屏幕是搜索屏幕,用于搜索其他用户并开始与他们对话:
最后一个屏幕是配置文件屏幕,其中可以找到注销按钮:
该应用程序可在纵向和横向两种平台上运行,即开箱即用:
我们可以想象,这个应用程序需要一个强大的后端环境来存储我们的用户、消息和状态。此外,我们还需要一个推送通知平台,在用户收到任何消息时通知他们。由于本书的重点是 React Native,我们将把所有这些后端工作委托给移动世界中最流行的移动后端服务(MBaaS)之一:Firebase
在开始编码之前,我们将花一些时间设置 Firebase 的推送通知服务和实时数据库,以便更好地了解我们将在应用程序中处理的数据类型。
总之,我们将在本课中学习以下主题:
- React-Native 中的复杂 Redux
- Firebase 实时数据库
- Firebase 推送通知
- Firebase 用户管理
- 形式
让我们首先回顾一下我们将使用的数据模型,以及我们的应用程序将如何与 Firebase 连接以同步其数据。
Firebase 是一个移动后端即服务(MBaaS),这意味着它为移动开发者提供了所有后端必需品,如用户管理、无 SQL 数据库和推送通知服务器。它通过一个正式的节点包与 React Native 轻松集成,免费提供数据库连接。不幸的是,Firebase 没有为其推送通知服务提供 JavaScript SDK,但有几个 React 本机库通过使用 JavaScript 接口连接 Firebase 的 iOS 和 Java SDK 来填补这一空白。我们将使用react-native-fcm,因为它是该领域最成熟的产品。
在 Firebase MBA 上构建应用程序之前,您需要为其创建一个项目。这是一个免费流程,Firebase 网站对此进行了解释 https://firebase.google.com/ 。虽然此过程与 React Native 没有直接关系,但了解如何为我们的应用程序设置和使用 MBA 是一个很好的起点。只需遵循 Firebase 文档网站上提供的教程,即可在几分钟内完成大部分配置。设立 MBA 的好处是,这些分钟值得花时间和最初的麻烦。
要设置 Firebase 并将我们的应用程序连接到正确的项目,我们需要使用我们可以在 Firebase 项目仪表板内的设置屏幕中找到的configuration for the web片段。我们在src/firebase.js上添加了此初始化片段:
import firebase from 'firebase';
var firebaseConfig = {
apiKey: “<Your Firebase API key>",
authDomain: “<Your Firebase Auth domain>",
databaseURL: “<Your Firebase database URL>",
projectId: “<Your Firebase projectId>",
storageBucket: “<Your Firebase storageBucket>",
messagingSenderId: “<Your messaging SenderId>"
};
export const firebaseApp = firebase.initializeApp(firebaseConfig);一旦项目建立起来,我们就可以开始了解数据库的结构。
Firebase 允许移动开发人员使用云托管的 NoSQL 数据库在用户和设备之间实时存储和同步数据。更新后的数据在连接的设备上以毫秒为单位进行同步,如果应用程序脱机,数据仍然可用,无论网络连接如何,都能提供出色的用户体验。
在考虑一对一通信应用程序应处理的基本数据时,出现了三种数据模型:
users:这将存储化身、姓名和推送通知令牌。这里不需要存储身份验证数据,因为它是通过不同的 Firebase API(身份验证 API)处理的。messages:我们会将每个聊天室中的每条消息单独保存,以便使用聊天室 ID 作为密钥进行检索。chats:所有打开的聊天信息都会存储在这里。
为了了解我们将如何请求和使用应用程序中的数据,让我们看一下我们可以实际用于测试的示例数据的要点:
{
“chats" : {
“--userId1--" : {
“--userId2----userId1--" : {
“contactId" : “--userId2--",
“image" : “https://images.com/person2.jpg",
“name" : “Jason"
}
},
“--userId2--" : {
“--userId2----userId1--" : {
“contactId" : “--userId1--",
“image" : “https://images.com/person1.jpg",
“name" : “John"
}
}
},
“messages" : {
“--userId2----userId1--" : {
“-KpEwU8sr01vHSy3qvRY" : {
“_id" : “2367ad00-301d-46b5-a7b5-97cb88781489",
“createdAt" : 1500284842672,
“text" : “Hey man!",
“user" : {
“_id" : “--userId2--",
“name" : “Jason"
}
}
}
},
“users" : {
“--userId1--" : {
“name" : “John",
“notificationsToken" : “"
},
“--userId2--" : {
“name" : “Jason",
“notificationsToken" : “--notificationsId1--"
}
}
}我们以一种便于消息应用程序检索和同步的方式组织数据。我们没有规范数据结构,而是引入了一些数据复制,以提高数据检索的速度,并最大限度地简化前端代码。
users集合使用用户 ID 作为密钥保存用户数据(--user1--和--user2--。Firebase 在注册/登录期间自动检索这些用户 ID。每个用户都有一个通知令牌,它是用户使用推送通知服务登录的设备的标识符。当用户注销时,通知令牌将被删除,因此发送给该用户的消息将被存储,但不会通知任何设备。
chats集合按用户 ID 存储每个用户的聊天列表。每个聊天都有自己的 ID(两个用户 ID 的串联),并且会被复制,因为该聊天中的每个用户都应该有一份聊天数据的副本。在每个副本中,都有足够的信息供其他用户建立聊天屏幕。
messages集合存储在一个单独的集合中,该集合可由该 ID 引用。每个聊天 ID 指向一个消息列表(本例中仅一个),其中存储了聊天屏幕所需的所有数据。此集合中也存在一些重复,因为一些用户数据与每条消息一起存储,以减少构建聊天屏幕时所需的请求数。
有关如何在 Firebase 实时数据库中读取和写入数据的完整教程,请访问他们的网站(https://firebase.google.com/docs/database/ ),但我们将快速了解一下本课将使用的方法。
从 Firebase 数据库检索数据有两种方法。第一个设置了一个监听器,每次数据更改时都会调用该监听器,因此我们只需在应用程序的整个生命周期中设置一次:
firebaseApp.database().ref('/users/' + userId).on('value', (snapshot) => {
const userObj = snapshot.val();
this.name = userObj.name;
this.avatar = userObj.avatar;
});如我们所见,为了检索数据的快照,我们需要在firebaseApp对象(我们在src/firebase.js文件中创建的对象)中调用database()方法。然后,我们将有一个database对象,在这里我们可以通过 URI 调用ref('<uri>'),该 URI 存储数据。这将返回对该 URI 所指向的数据段的引用。我们可以使用on('value', callback)方法,它将附加一个通过数据快照的回调。Firebase 总是将对象作为快照返回,因此我们需要自己将它们转换为普通数据。在本例中,我们希望检索具有两个键(name和avatar的对象,因此我们只需要在快照上调用val()方法来检索包含数据的普通对象。
如果我们不需要每次更新时自动同步检索到的数据,我们可以使用once()方法而不是on():
import firebase from 'firebase';
import { firebaseApp } from '../firebase';
firebaseApp.database().ref('/users/' + userId).once('value')
.then((snapshot) => {
const userObj = snapshot.val();
this.name = userObj.name;
this.avatar = userObj.avatar;
});接收快照的回调将只调用一次。
在 Firebase 数据库中写入数据也可以通过两种不同的方式完成:
firebaseApp.database().ref('/users/' + userId).update({
name: userName
});update()方法根据作为参数传递的键和值更改所提供 URI 引用的对象。对象的其余部分保持不变。
另一方面,set()将用我们作为参数提供的对象替换数据库中的对象:
firebaseApp.database().ref('/users/' + userId).set({
name: userName,
avatar: avatarURL
});最后,如果我们想添加一个新的数据快照,但希望 Firebase 为其生成一个 ID,我们可以使用push方法:
firebaseApp.database().ref('/messages/' + chatId).push().set(message);我们将使用 Firebase 身份验证服务,因此我们不需要担心存储登录凭据、处理忘记的密码或验证我们这边的电子邮件。Firebase 身份验证服务免费提供这些和其他相关任务。
为了通过电子邮件和密码激活登录和注册,我们需要在 Firebase 仪表板中将此方法作为会话登录方法启用。有关如何执行此操作的更多信息,请访问 Firebase 的网站https://firebase.google.com/docs/auth/web/password-auth 。
在我们的应用程序中,我们只需要使用提供的 Firebase SDK 进行登录:
firebase.auth().signInWithEmailAndPassword(username, password)
.then(() => {
//user is logged in
})
.catch(() => {
//error logging in
})
})对于注册,我们可以使用以下代码:
firebase.auth().createUserWithEmailAndPassword(email, password)
.then((user) => {
//user is registered
})
.catch((error) => {
//error registering
})Firebase 将负责所有令牌处理,我们只需添加一个侦听器,以确保在身份验证状态更改时更新我们的应用程序:
firebase.auth().onAuthStateChanged((user) => {
//user has logged in or out
}让我们使用 React-Native 的 CLI 初始化 React-Native 项目。该项目将命名为messagingApp,可用于 iOS 和 Android 设备:
react-native init --version="0.45.1" messagingApp我们将使用 MobX 来管理我们应用程序中的状态,因此我们需要为我们的商店设置一个文件夹。文件夹结构的其余部分是大多数 React 应用程序的标准。
我们需要五个屏幕(Chats、Chat、Login、Profile和Search),一个组件(ListItem)和两个存储区(chats和users),它们将通过stores/index.js文件提供。我们还将使用两个助手来支持我们的应用程序:
notifications.js:推送通知相关的所有逻辑都将存储在此文件中firebase.js:包括 Firebase SDK 的配置和初始化
由于我们将使用 MUBX 和其他几个依赖项,让我们看看我们的 To.t0.file,了解我们将使用的包:
/*** package.json ***/
{
“name": “messagingApp",
“version": “0.0.1",
“private": true,
“scripts": {
“start": “node node_modules/react-native/local-cli
/cli.js start",
“test": “jest"
},
“dependencies": {
“firebase": “^4.1.3",
“mobx": “^3.2.0",
“mobx-react": “^4.2.2",
“react": “16.0.0-alpha.12",
“react-native": “0.45.1",
“react-native-fcm": “^7.1.0",
“react-native-gifted-chat": “^0.2.0",
“react-native-keyboard-aware-scroll-view": “^0.2.9",
“react-native-vector-icons": “^4.2.0",
“react-navigation": “^1.0.0-beta.11"
},
“devDependencies": {
“babel-jest": “20.0.3",
“babel-plugin-transform-decorators-legacy": “^1.3.4",
“babel-preset-react-native": “2.1.0",
“jest": “20.0.4",
“react-test-renderer": “16.0.0-alpha.12"
},
“jest": {
“preset": “react-native"
}
}我们将使用的一些npm软件包包括:
firebase:Firebase 用于身份验证和数据库连接的 SDKmobx:MobX 将处理我们的应用程序状态react-native-fcm:Firebase 推送消息 SDKreact-native-gifted-chat:用于呈现聊天室的库,包括日期分离、化身和许多其他功能react-native-keyboard-aware-scroll-view:确保屏幕键盘在处理表单时不会隐藏任何焦点文本输入的库react-native-vector-icons:我们将在此应用程序中使用令人敬畏的字体图标react-navigation:我们将有一个抽屉、一个选项卡和一个堆栈导航器来处理我们应用程序中的屏幕babel-plugin-transform-decorators-legacy:这个库允许我们使用 decorators(使用遗留的@语法),这在使用 MobX 时非常有用
运行npm install后,我们将准备好应用程序开始编码。正如在以前的应用程序中一样,我们的消息应用程序的入口点在 iOS 的index.ios.js和 Android 的index.android.js中都是相同的代码:
/*** index.ios.js and index.android.js ***/
import React from 'react'
import { AppRegistry } from 'react-native';
import App from './src/main';
import { Provider } from 'mobx-react/native';
import { chats, users } from './src/stores';
class MessagingApp extends React.Component {
render() {
return (
<Provider users={users} chats={chats}>
<App/>
</Provider>
)
}
}
AppRegistry.registerComponent('messagingApp', () => MessagingApp);这是启动与 MobX 一起工作的 React 本机应用程序的标准方法——提供一个<Provider />作为根元素,将两个存储区(users和chats注入到我们应用程序的屏幕中。所有初始化和导航逻辑都已延迟到src/main.js文件:
/*** src/main.js ***/
import React from 'react'
import { DrawerNavigator,TabNavigator } from 'react-navigation'
import { Platform, View } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import Login from './screens/Login'
import Chats from './screens/Chats'
import Profile from './screens/Profile'
import Search from './screens/Search'
import { users, chats } from './stores'
let Navigator;
if(Platform.OS === 'ios'){
Navigator = TabNavigator({
Chats: { screen: Chats },
Search: { screen: Search },
Profile: { screen: Profile }
}, {
tabBarOptions: {
inactiveTintColor: '#aaa',
activeTintColor: '#000',
showLabel: true
}
});
} else {
Navigator = DrawerNavigator({
Chats: { screen: Chats },
Search: { screen: Search },
Profile: { screen: Profile }
});
}
@inject('users') @observer
export default class App extends React.Component {
constructor() {
super();
}
render() {
if(this.props.users.isLoggedIn){
return <Navigator/>
} else {
return <Login/>
}
}
}我们在src/main.js文件中看到的第一件事是,我们将使用不同的导航器,这取决于我们运行应用程序的平台:iOS 将打开选项卡式导航器,而 Android 将打开基于抽屉的导航器。
然后,我们会在应用程序的许多组件中看到一行重复的内容:
@inject('users') @observer这是告诉 MobX 此组件需要接收users存储的方法。MobX 将把它作为道具传递给这个组件,因此我们可以使用它所拥有的所有方法和属性。在这种情况下,如果用户还没有登录,我们对isLoggedIn属性感兴趣,它会向用户显示<Login />屏幕。由于 MobX 将把这个属性作为属性注入到我们的组件中,所以访问它的正确方法是this.props.users.isLoggedIn。
在继续构建组件之前,让我们来看看我们将在本课中使用的商店,以便更好地了解哪些数据和动作是可用的。
此存储区负责保存用户周围的所有数据和逻辑,但也有助于聊天存储区在用户登录时进行初始化:
/*** src/stores/users.js ***/
import {observable, computed, map, toJS, action} from 'mobx';
import chats from './chats'
import firebase from 'firebase';
import { firebaseApp } from '../firebase';
import notifications from '../notifications'
class Users {
@observable id = null;
@observable isLoggedIn = false;
@observable name = null;
@observable avatar = null;
@observable notificationsToken = null;
@observable loggingIn = false;
@observable registering = false;
@observable loggingError = null;
@observable registeringError = null;
@action login = function(username, password) {
//login with Firebase email/password method
}
@action logout = function() {
//logout from Firebase authentication service
}
@action register = function(email, password, name) {
//register through firebase authentication service
}
@action setNotificationsToken(token) {
//store the notifications token for this device
}
searchUsers(name) {
//helper for searching users by name in the database
}
constructor() {
this.bindToFirebase();
}
bindToFirebase() {
//Initialise connection to Firebase user
//authentication status and data
}
}
const users = new Users();
export default users;这些是我们需要用于此存储的所有属性和方法。有几个标志(那些包含动词-ing 形式的属性)用于记录网络活动。现在让我们实现每个方法:
@action login = function(username, password) {
this.loggingIn = true;
this.loggingError = null;
firebase.auth().signInWithEmailAndPassword(username, password)
.then(() => {
this.loggingIn = false;
notifications.init((notificationsToken) => {
this.setNotificationsToken(notificationsToken);
});
})
.catch((error) => {
this.loggingIn = false;
this.loggingError = error.message;
});
}使用 Firebase 登录只需在其身份验证 SDK 上调用signInWithEmailAndPassword即可。如果登录成功,我们将初始化通知模块,使设备能够接收推送通知。我们将在注销时遵循相反的路径:
@action logout = function() {
notifications.unbind();
this.setNotificationsToken('');
firebase.auth().signOut();
}在注册操作中,除了为网络活动设置适当的标志外,我们还需要验证用户输入的名称,初始化通知,并将名称存储在数据库中:
@action register = function(email, password, name) {
if(!name || name == '') {
this.registering = false;
this.registeringError = 'Name was not entered';
return;
}
this.registering = true;
this.registeringError = null;
firebase.auth().createUserWithEmailAndPassword(email, password)
.then((user) => {
this.registering = false;
notifications.init((notificationsToken) => {
this.setNotificationsToken(notificationsToken);
});
firebaseApp.database().ref('/users/' + user.uid).set({
name: name
});
})
.catch((error) => {
this.registering = false;
this.registeringError = error.message;
})
}设置通知令牌只是数据库中的一个简单更新:
@action setNotificationsToken(token) {
if(!this.id) return;
this.notificationsToken = token;
firebaseApp.database().ref('/users/' + this.id).update({
notificationsToken: token
});
}searchUsers()方法没有标记为@action,因为它不会修改我们应用程序的状态,只会在数据库中搜索并返回一个具有提供名称的用户列表:
searchUsers(name) {
return new Promise(function(resolve) {
firebaseApp.database().ref('/users/').once('value')
.then(function(snapshot) {
let foundUsers = [];
const users = snapshot.val();
for(var id in users) {
if(users[id].name === name) {
foundUsers.push({
name: users[id].name,
avatar:
users[id].avatar,
notificationsToken:
users[id].
notificationsToken,
id
});
}
}
resolve(foundUsers);
});
});
}由于请求的异步性质,我们将返回结果作为承诺。
最后, bindToFirebase()将此存储中的属性附加到 Firebase 数据库中的数据快照。此方法由构造函数调用,因此它充当用户数据的初始化。请务必注意,当身份验证状态更改为始终反映用户的最新数据时,此数据将被更新:
bindToFirebase() {
return firebase.auth().onAuthStateChanged((user) => {
if(this.chatsBind && typeof this.chatsBind.off === 'function')
this.chatsBind.off();
if(this.userBind && typeof this.userBind.off === 'function')
this.userBind.off();
if (user) {
this.id = user.uid;
this.isLoggedIn = true;
this.chatsBind = chats.bindToFirebase(user.uid);
this.userBind = firebaseApp.database().ref('/users/' + this.id).
on('value', (snapshot) =>
{
const userObj = snapshot.val();
if(!userObj) return;
this.name = userObj.name;
this.avatar = userObj.avatar;
});
} else {
this.id = null;
this.isLoggedIn = false;
this.userBind = null;
this.name = null;
this.avatar = null;
}
});
}我们将存储聊天数据的侦听器(作为this.chatsBind和用户数据的侦听器(作为this.userBind),因此我们可以在每次auth状态更改时附加新的侦听器之前删除它们(通过调用off()方法)。
该存储负责保存聊天和消息周围的所有数据和逻辑,但也帮助chats存储在用户登录时初始化:
/*** src/stores/chats.js ***/
import { observable, computed, map, toJS, action } from 'mobx';
import { AsyncStorage } from 'react-native'
import { firebaseApp } from '../firebase'
import notifications from '../notifications'
class Chats {
@observable list;
@observable selectedChatMessages;
@observable downloadingChats = false;
@observable downloadingChat = false;
@action addMessages = function(chatId, contactId, messages) {
//add a list of messages to a chat
}
@action selectChat = function(id) {
//set a chat as selected and retrieve all the messages for it
}
@action add(user1, user2) {
//add a new chat to the list of chats for the users in it
}
bindToFirebase(userId) {
//listen for the list of chats in Firebase to update the
@observable list
}
}
const chats = new Chats()
export default chats;我们将在@observable list中存储用户已打开的聊天列表。当用户选择一个聊天时,我们将下载该聊天上的消息列表并将其同步到@observable selectedChatMessages。然后,当我们从 Firebase 数据库下载数据时,我们将有几个标志让用户知道。
让我们单独来看一下每种方法。我们将从addMessages开始:
@action addMessages = function(chatId, contactId, messages) {
if(!messages || messages.length < 1) return;
messages.forEach((message) => {
let formattedMessage = {
_id: message._id,
user: {
_id: message.user._id,
}
};
if(message.text) formattedMessage.text = message.text;
if(message.createdAt) formattedMessage.createdAt =
message.createdAt/1;
if(message.user.name) formattedMessage.user.name =
message.user.name;
if(message.user.avatar) formattedMessage.user.avatar =
message.user.avatar;
if(message.image) formattedMessage.image = message.image;
//add the message to the chat
firebaseApp.database().ref('/messages/' +
chatId).push().set(formattedMessage);
//notify person on the chat room
firebaseApp.database().ref('/users/' + contactId).once('value')
.then(function(snapshot) {
var notificationsToken = snapshot.val().notificationsToken;
notifications.sendNotification(notificationsToken, {
sender: message.user.name,
text: message.text,
image: message.user.image,
chatId
});
});
});
}此方法接收三个参数:
chatId:将添加消息的聊天的 ID。contactId:我们向其发送消息的用户的 ID。这将用于向用户的联系人发送通知。messages:这是一个包含我们想要添加到聊天中的所有消息的数组。
我们将循环浏览消息列表,按照我们希望的存储方式格式化消息。然后,我们将对数据库引用调用set()方法,以将新消息保存在 Firebase 的数据库中。最后,我们需要将通知发送给我们的联系人,因此我们通过他们的contactId查询users集合来检索他们的通知令牌。
发送通知通常由后端处理,但由于我们正在设置应用程序本身的所有逻辑,因此需要构建一个发送通知的函数。我们已在通知module: notifications.sendNotification(notificationsToken, data);中这样做。
让我们看看当我们选择一个聊天室来显示它的消息时会发生什么:
@action selectChat = function(id) {
this.downloadingChat = true;
if(this.chatBind && typeof this.chatBind.off === 'function')
this.chatBind.off();
this.chatBind = firebaseApp.database().ref('/messages/' + id)
.on('value', (snapshot) => {
this.selectedChatMessages = [];
this.downloadingChat = false;
const messagesObj = snapshot.val();
for(var id in messagesObj) {
this.selectedChatMessages.push({
_id: id,
text: messagesObj[id].text,
createdAt: messagesObj[id].createdAt,
user: {
_id: messagesObj[id].user._id,
name: messagesObj[id].user.name,
avatar: messagesObj[id].user.avatar
},
image: messagesObj[id].image
});
}
});
}这里的主要功能是将侦听器附加到 messages/chat ID 集合中,这将使可观察到的this.selectedChatMessages与数据库中所选聊天的消息列表同步。这意味着每次 Firebase 中存储新消息时,this.selectedChatMessages都将同步以反映它。Firebase SDK 中的on()方法就是这样工作的:我们传递一个回调,我们可以使用它将实时数据库与应用程序的状态同步。
将使用add()方法添加新聊天:
@action add(user1, user2) {
return new Promise(function(resolve, reject) {
firebaseApp.database().ref('/chats/' + user1.id + '/' + user1.id +
user2.id).set({
name: user2.name,
image: user2.avatar,
contactId: user2.id
}).then(() => {
firebaseApp.database().ref('/chats/' + user2.id + '/'
+ user1.id +
user2.id).set({
name: user1.name,
image: user1.avatar,
contactId: user1.id
}).then(() => {
resolve();
})
})
});
}在这里,我们正在构建并返回一个承诺,该承诺将在更新两个聊天(每个参与聊天的用户一个)时得到解决。这两个数据库更新可以看作是数据的重复,但它也会降低数据结构的复杂性,从而降低代码库的可读性。
本店最后一种方式为bindToFirebase():
bindToFirebase(userId) {
this.downloadingChats = true;
return firebaseApp.database().ref('/chats/' + userId).
on('value', (snapshot) => {
this.downloadingChats = false;
const chatsObj = snapshot.val();
this.list = [];
for(var id in chatsObj) {
this.list.push({
id,
name: chatsObj[id].name,
image: chatsObj[id].image,
contactId: chatsObj[id].contactId
});
}
});
}正如我们在users存储中看到的,当用户登录并将侦听器附加到数据的chats/<userId>快照时,将调用此方法,以保持所有聊天数据与this.list属性上的数据库同步。
为了方便起见,我们将两个商店都分组在src/stores/index.js中,这样我们就可以在一行代码中导入它们:
/*** src/stores/index.js ***/
import users from './users';
import chats from './chats';
export {
users,
chats
};这就是我们将要使用的所有商店。正如我们所看到的,大部分业务逻辑都在这里处理,因此可以对其进行彻底测试。现在让我们转到用于通知的帮助器。
Firebase 为 iOS 和 Android 集成了推送通知服务,但不幸的是,它没有在 SDK 上提供任何 JavaScript 来使用它。为此,创建了一个开源库,将 Objective-C 和 JavaSDK 连接到 React 本机模块中:react-native-fcm。
在本书中我们将不介绍此模块的安装,因为它是一个不断变化的过程,可以在的存储库中更好地遵循 https://github.com/evollu/react-native-fcm 。
我们决定在我们的src/notifications.js文件中抽象这个模块的逻辑,以使每个组件都可以使用它,同时保持其可维护性。让我们看看这个文件:
/*** src/notifications.js ***/
import {Platform} from 'react-native';
import FCM, {FCMEvent, RemoteNotificationResult, WillPresentNotificationResult, NotificationType} from 'react-native-fcm';
let notificationListener = null;
let refreshTokenListener = null;
const API_URL = 'https://fcm.googleapis.com/fcm/send';
const FirebaseServerKey = '<Your Firebase Server Key>';
const init = (cb) => {
FCM.requestPermissions();
FCM.getFCMToken().then(token => {
cb(token)
});
refreshTokenListener = FCM.on(FCMEvent.RefreshToken, (token) => {
cb(token);
});
}
const onNotification = (cb) => {
notificationListener = FCM.on(FCMEvent.Notification, (notif) => {
cb(notif);
if(Platform.OS ==='ios'){
switch(notif._notificationType){
case NotificationType.Remote:
notif.finish(RemoteNotificationResult.NewData)
break;
case NotificationType.NotificationResponse:
notif.finish();
break;
case NotificationType.WillPresent:
notif.finish(WillPresentNotificationResult.All)
break;
}
}
})
}
const unbind = () => {
if(notificationListener) notificationListener.remove();
if(refreshTokenListener) refreshTokenListener.remove();
}
const sendNotification = (token, data) => {
let body = JSON.stringify({
“to": token,
“notification": {
“title": data.sender || '',
“body": data. text || '',
“sound": “default"
},
“data": {
“name": data.sender,
“chatId": data.chatId,
“image": data.image
},
“priority": 10
});
let headers = new Headers({
“Content-Type": “application/json",
“Content-Length": parseInt(body.length),
“Authorization": “key=" + FirebaseServerKey
});
fetch(API_URL, { method: “POST", headers, body })
.then(response => console.log(“Send response", response))
.catch(error => console.log(“Error sending “, error));
}
export default { init, onNotification, sendNotification, unbind }本模块中公开了四个功能:
init:请求接收推送通知的权限(如果尚未授予该权限),并请求设备令牌或在发生更改时刷新该令牌。onNotification:当收到通知时,调用提供的回调。在 iOS 中,它还对通知调用适当的方法以关闭循环。unbind:停止监听推送通知。sendNotification:使用提供的通知令牌格式化推送通知并发送给特定设备。
在 Firebase 中发送通知可以使用他们的 HTTP API 来完成,因此我们将使用fetch来发送具有适当头和正文数据的POST 请求。
现在,我们已经具备了开始构建屏幕和组件所需的所有逻辑。
<Login />组件在很大程度上依赖于users存储区的逻辑,因为它主要关注于呈现两个表单以进行登录和注册。表单的所有验证都是由 Firebase 完成的,因此我们只需要关注呈现 UI 元素和调用适当的存储方法。
在这个屏幕中,我们将使用react-native-keyboard-aware-scroll视图,这是一个提供自滚动<Scrollview />的模块,它对任何聚焦的<TextInput />作出反应,因此当键盘弹出时它们不会隐藏。
让我们看一下代码:
/*** src/screens/Login.js ***/
import React, { PropTypes } from 'react'
import {
ScrollView,
TextInput,
Button,
Text,
View,
Image,
ActivityIndicator
} from 'react-native';
import { observer, inject } from 'mobx-react/native'
import Icon from 'react-native-vector-icons/FontAwesome'
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'
import LoginForm from '../components/LoginForm'
import RegistrationForm from '../components/RegistrationForm'
@inject('users') @observer
class Login extends React.Component {
onLogin(email, password) {
this.props.users.login(email, password);
}
onPressRegister(email, password, name) {
this.props.users.register(email, password, name);
}
render() {
return (
<KeyboardAwareScrollView style={{padding: 20, marginTop: 20,
backgroundColor: '#eee'}}>
<Icon name="comments" size={60} color='#ccc'
style={{alignSelf: 'center', paddingBottom: 20}}/>
<View style={{alignItems: 'center', marginBottom: 20}}>
<Text>- please, login to continue -</Text>
</View>
<LoginForm
onPress={this.onLogin.bind(this)}
busy={this.props.users.loggingIn}
loggingError={this.props.users.loggingError}
/>
<View style={{alignItems: 'center', marginTop: 20,
marginBottom: 20}}>
<Text>- or register -</Text>
</View>
<RegistrationForm
onPress={this.onPressRegister.bind(this)}
busy={this.props.users.registering}
registeringError={this.props.users.registeringError}
/>
</KeyboardAwareScrollView>
)
}
}
export default Login;我们将登录屏幕分为两种形式:<LoginForm />和<RegistrationForm />。两个组件都需要通过三个支柱:
onPress:按下发送按钮时组件需要做什么。busy:我们在等待远程数据吗?loginError/registrationError:记录/注册时发生的错误描述(如果发生)。
我们将整个屏幕包装在一个<KeyboardAwareScrollView />中,以确保聚焦时没有<TextInput />被键盘隐藏。让我们来看看现在的:
/*** src/components/LoginForm.js ***/
import React, { PropTypes } from 'react'
import {
TextInput,
Button,
Text,
View,
Image,
ActivityIndicator
} from 'react-native';
class LoginForm extends React.Component {
state= {
loginEmail: '',
loginPassword: ''
}
onPressLogin() {
this.props.onPress(this.state.loginEmail,
this.state.loginPassword);
}
render() {
return (
<View style={{backgroundColor: 'white', padding: 15,
borderRadius: 10}}>
{
this.props.loggingError &&
<View style={{backgroundColor: '#fcc', borderRadius: 5,
alignItems: 'center', marginBottom: 10}}>
<Text>{this.props.loggingError}</Text>
</View>
}
<TextInput
autoCapitalize='none'
autoCorrect={false}
keyboardType='email-address'returnKeyType='next'
style={{height: 40}}
onChangeText={(loginEmail) => this.setState({loginEmail})}
value={this.state.loginEmail}
placeholder='email'
onSubmitEditing={(event) => {
this.refs.loginPassword.focus();
}}
/>
<TextInput
ref='loginPassword'
style={{height: 40}}
onChangeText={(loginPassword) =>
this.setState({loginPassword})}
value={this.state.loginPassword}
secureTextEntry={true}
placeholder='password'
/>
{
this.props.busy ?
<ActivityIndicator/>
:
<Button
onPress={this.onPressLogin.bind(this)}
title='Login'
/>
}
</View>
)
}
}
export default LoginForm;对于包含电子邮件的<TextInput />元素,我们设置了属性keyboardType='email-address',以便可以在软件键盘上轻松访问@符号。还有其他选项,如数字键盘,但我们将仅在此应用程序中使用'email-address'。
<TextInput />上另一个有用的道具是returnKeyType。我们为那些不是最后一个在键盘上显示Next按钮的表单输入设置returnKeyType='next',以便用户知道他们可以通过点击该按钮进入下一个输入。此道具与以下道具一起使用:
onSubmitEditing={(event) => {
this.refs.loginPassword.focus();
}}onSubmitEditing是一个<TextInput />道具,当用户按下键盘上的Return或Next按钮时将调用该道具。我们用它来关注下一个<TextInput />,它在处理表单时非常方便用户。为了获得下一个<TextInput />的参考,我们使用ref,这不是最安全的方法,但对于简单的表单来说已经足够好了。要使其工作,我们需要将相应的ref分配给下一个<TextInput />:ref='loginPassword'。
RegistrationForm是一种非常相似的形式:
/*** src/components/RegistrationForm ***/
import React, { PropTypes } from 'react'
import {
ScrollView,
TextInput,
Button,
Text,
View,
Image,
ActivityIndicator
} from 'react-native';
class RegisterForm extends React.Component {
state= {
registerEmail: '',
registerPassword: '',
registerName: ''
}
onPressRegister() {
this.props.onPress(this.state.registerEmail,
this.state.registerPassword, this.state.registerName);
}
render() {
return (
<View style={{backgroundColor: 'white', padding: 15,
borderRadius: 10}}>
{
this.props.registeringError &&
<View style={{backgroundColor: '#fcc', borderRadius: 5,
alignItems: 'center', marginBottom: 10}}>
<Text>{this.props.registeringError}</Text>
</View>
}
<TextInput
autoCapitalize='none'
autoCorrect={false}
keyboardType='email-address'
returnKeyType='next'
style={{height: 40}}
onChangeText={(registerEmail) =>
this.setState({registerEmail})}
value={this.state.registerEmail}
placeholder='email'
onSubmitEditing={(event) => {
this.refs.registerName.focus();
}}
/>
<TextInput
ref='registerName'
style={{height: 40}}
onChangeText={(registerName) =>
this.setState({registerName})}
returnKeyType='next'
value={this.state.registerName}
placeholder='name'
onSubmitEditing={(event) => {
this.refs.registerPassword.focus();
}}
/>
<TextInput
ref='registerPassword'
style={{height: 40}}
onChangeText={(registerPassword) =>
this.setState({registerPassword})}
value={this.state.registerPassword}
secureTextEntry={true}
placeholder='password'
/>
{
this.props.busy ?
<ActivityIndicator/>
:
<Button
onPress={this.onPressRegister.bind(this)}
title='Register'
/>
}
</View>
)
}
}
export default RegisterForm;这是显示已打开聊天列表的屏幕。这里需要特别注意的是,我们使用第二个导航器在聊天列表的顶部显示选定的聊天。这意味着我们的Chats组件中需要一个StackNavigator,它将包含两个屏幕:ChatList和Chat。当用户点击ChatList中的聊天时,StackNavigator将在ChatList顶部显示所选聊天,并通过标题中的标准< back按钮提供聊天列表。
为了列出聊天记录,我们将使用<FlatList />,这是一个性能界面,用于呈现简单的平面列表,支持<ListView />的大部分功能:
/*** src/screens/Chats.js ***/
import React, { PropTypes } from 'react'
import { View, Text, FlatList, ActivityIndicator } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import { StackNavigator } from 'react-navigation'
import Icon from 'react-native-vector-icons/FontAwesome'
import notifications from '../notifications'
import ListItem from '../components/ListItem'
import Chat from './Chat'
@inject('chats') @observer
class ChatList extends React.Component {
imgPlaceholder =
'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
2160923_960_720.png'
componentWillMount() {
notifications.onNotification((notif)=>{
this.props.navigation.goBack();
this.props.navigation.navigate('Chat', {
id: notif.chatId,
name: notif.name || '',
image: notif.image || this.imgPlaceholder
})
});
}
render () {
return (
<View>
{
this.props.chats.list &&
<FlatList
data={this.props.chats.list.toJS()}
keyExtractor={(item, index) => item.id}
renderItem={({item}) => {
return (
<ListItem
text={item.name}
image={item.image || this.imgPlaceholder}
onPress={() => this.props.navigation.navigate('Chat',
{
id: item.id,
name: item.name,
image: item.image || this.imgPlaceholder,
contactId: item.contactId
})}
/>
)
}}
/>
}
{
this.props.chats.downloadingChats &&
<ActivityIndicator style={{marginTop: 20}}/>
}
</View>
)
}
}
const Navigator = StackNavigator({
Chats: {
screen: ChatList,
navigationOptions: ({navigation}) => ({
title: 'Chats',
}),
},
Chat: {
screen: Chat
}
});
export default class Chats extends React.Component {
static navigationOptions = {
tabBarLabel: 'Chats',
tabBarIcon: ({ tintColor }) => (
<Icon name="comment-o" size={30} color={tintColor}/>
)
};
render() {
return <Navigator />
}
}我们注意到的第一件事是,我们正在注入保存聊天列表的chats存储区:@inject('chats') @observer。我们需要它来构建我们的基于this.props.chats.list的<FlatList />,但由于聊天列表是一个可观察的 MobX 对象,我们需要使用其toJS()方法对其进行转换,以生成一个 JavaScript 数组。
在componentWillMount()功能中,每当用户在其设备上按下推送通知时,我们将调用通知模块上的onNotification打开相应的聊天。因此,我们将使用导航器上的navigate()方法打开适当的聊天屏幕,包括联系人的姓名和她的头像。
聊天列表依赖于<ListItem />呈现列表中的每个特定聊天。此组件是我们创建的自定义 UI 类,旨在降低ChatList组件的复杂性:
/*** src/components/ListItem.js ***/
import React, { PropTypes } from 'react'
import { View, Image, Text, TouchableOpacity } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'
const ListItem = (props) => {
return (
<TouchableOpacity onPress={props.onPress}>
<View style={{height: 60, borderColor: '#ccc',
borderBottomWidth: 1,
marginLeft: 10, flexDirection: 'row'}}>
<View style={{padding: 15, paddingTop: 10}}>
<Image source={{uri: props.image}} style={{width: 40,
height: 40,
borderRadius: 20, resizeMode: 'cover'}}/>
</View>
<View style={{padding: 15, paddingTop: 20}}>
<Text style={{fontSize: 15}}>{ props.text }</Text>
</View>
<Icon name="angle-right" size={20} color="#aaa"
style={{position: 'absolute', right: 20, top: 20}}/>
</View>
</TouchableOpacity>
)
}
export default ListItem这个组件上没有什么逻辑,因为它只接收一个名为onPress()的道具,当按下<ListItem />时会调用它,正如我们在这个组件的父组件上看到的那样,它将打开聊天屏幕以显示特定聊天的消息列表。让我们来看看在 TytT2A.屏幕上,所有的特定聊天的消息都被渲染了。
为了保持代码简洁和可维护性,我们将使用GiftedChat呈现聊天中的所有消息,但仍需要做一些工作来正确呈现此屏幕:
/*** src/screens/Chat.js ***/
import React, { PropTypes } from 'react'
import { View, Image, ActivityIndicator } from 'react-native';
import { observer, inject } from 'mobx-react/native'
import { GiftedChat } from 'react-native-gifted-chat'
@inject('chats', 'users') @observer
class Chat extends React.Component {
static navigationOptions = ({ navigation, screenProps }) => ({
title: navigation.state.params.name,
headerRight: <Image source={{uri: navigation.state.params.image}}
style={{
width: 30,
height: 30,
borderRadius: 15,
marginRight: 10,
resizeMode: 'cover'
}}/>
})
onSend(messages) {
this.props.chats.addMessages(this.chatId, this.contactId,
messages);
}
componentWillMount() {
this.contactId = this.props.navigation.state.params.contactId;
this.chatId = this.props.navigation.state.params.id;this.props.chats.selectChat(this.chatId);
}
render () {
var messages = this.props.chats.selectedChatMessages;
if(this.props.chats.downloadingChat) {
return <View><ActivityIndicator style={{marginTop: 20}}/></View>
}
return (
<GiftedChat
onSend={(messages) => this.onSend(messages)}
messages={messages ? messages.toJS().reverse() : []}
user={{
_id: this.props.users.id,
name: this.props.users.name,
avatar: this.props.users.avatar
}}
/>
)
}
}
export default Chat;我们还需要为我们的<Chat />组件注入一些存储来工作。这一次,我们需要users和chats存储,它们将作为组件内的道具提供。该组件还希望从导航器接收两个参数:chatId(聊天 ID)和contactId(与用户聊天的人的 ID)。
当组件准备安装(onComponentWillMount()时,我们将chatId和contactId保存在组件内部更方便的变量中,并在chats存储上调用selectChat()方法。这将触发对 Firebase 数据库的请求,以获取所选聊天的消息,这些消息将通过chats存储同步,组件可通过this.props.chats.selectedChatMessages访问。MobX 还将更新一个downloadingChat属性,以确保我们让用户知道正在从 Firebase 检索数据。
最后,我们需要在GiftedChat中添加一个onSend()函数,每当按下Send按钮时,该函数将调用chats存储上的addMessages()方法将消息发布到 Firebase。
GiftedChat帮助我们在很大程度上减少了为聊天呈现消息列表所需的工作量。另一方面,我们必须按照GiftedChat要求的方式格式化消息,并提供onSend()函数,以便在需要将消息发布到后端时执行。
搜索屏幕分为两个部分:一个<TextInput />用于用户搜索姓名,另一个<FlatList />用于显示使用输入姓名找到的联系人列表:
import React, { PropTypes } from 'react'
import { View, TextInput, Button, FlatList } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'
import { observer, inject } from 'mobx-react/native'
import ListItem from '../components/ListItem'
@inject('users', 'chats') @observer
class Search extends React.Component {
imgPlaceholder = 'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
2160923_960_720.png'
state = {
name: '',
foundUsers: null
}
static navigationOptions = {
tabBarLabel: 'Search',
tabBarIcon: ({ tintColor }) => (
<Icon name="search" size={30} color={tintColor}/>
)
};
onPressSearch() {
this.props.users.searchUsers(this.state.name)
.then((foundUsers) => {
this.setState({ foundUsers });
});
}
onPressUser(user) {
//open a chat with the selected user
}
render () {
return (
<View>
<View style={{padding: 20, marginTop: 20,
backgroundColor: '#eee'}}>
<View style={{backgroundColor: 'white', padding: 15,
borderRadius: 10}}>
<TextInput
style={{borderColor: 'gray', borderBottomWidth: 1,
height: 40}}
onChangeText={(name) => this.setState({name})}
value={this.state.name}
placeholder='Name of user'
/>
<Button
onPress={this.onPressSearch.bind(this)}
title='Search'
/>
</View>
</View>
{
this.state.foundUsers &&
<FlatList
data={this.state.foundUsers}
keyExtractor={(item, index) => index}
renderItem={({item}) => {
return (
<ListItem
text={item.name}
image={item.avatar || this.imgPlaceholder}
onPress={this.onPressUser.bind(this, item)}
/>
)
}}
/>
}
</View>
)
}
}
export default Search;此组件需要注入两个存储区(users和chats。users存储用于在用户点击Search按钮时调用searchUsers()方法。此方法不修改状态,因此我们需要提供回调来接收找到的用户列表,以最终设置组件状态的列表。
第二个存储区chats将通过onPressUser()功能调用add()来存储 Firebase 中打开的聊天:
onPressUser(user) {
this.props.chats.add({
id: this.props.users.id,
name: this.props.users.name,
avatar: this.props.users.avatar || this.imgPlaceholder,
notificationsToken: this.props.users.notificationsToken || ''
}, {
id: user.id,
name: user.name,
avatar: user.avatar || this.imgPlaceholder,
notificationsToken: user.notificationsToken || ''
});
this.props.navigation.navigate('Chats', {});
}chats存储中的add()方法需要传递两个参数:在新打开的聊天中,每个用户一个参数。这些数据将正确存储在 Firebase 中,因此两个用户都将在应用程序的聊天列表中看到聊天记录。添加新聊天记录后,我们会将应用导航到聊天记录屏幕,以便用户查看添加是否成功。
配置文件屏幕显示用户的化身、姓名和用于注销的Logout按钮:
import React, { PropTypes } from 'react'
import { View, Image, Button, Text } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import Icon from 'react-native-vector-icons/FontAwesome'
import notifications from '../notifications'
@inject('users') @observer
class Profile extends React.Component {
static navigationOptions = {
tabBarLabel: 'Profile',
tabBarIcon: ({ tintColor }) => (
<Icon name="user" size={30} color={tintColor}/>
),
};
imgPlaceholder =
'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
2160923_960_720.png'
onPressLogout() {
this.props.users.logout();
}
render () {
return (
<View style={{ padding: 20 }}>
{
this.props.users.name &&
<View style={{ flexDirection: 'row', alignItems: 'center'
}}>
<Image
source={{uri: this.props.users.avatar ||
this.imgPlaceholder}}
style={{width: 100, height: 100, borderRadius: 50,
margin: 20, resizeMode: 'cover'}}
/>
<Text style={{fontSize: 25}}>{this.props.users.name}
</Text>
</View>
}
<Button
onPress={this.onPressLogout.bind(this)}
title="Logout"
/>
</View>
)
}
}
export default Profile;通过调用users存储上的logout()方法触发注销过程。由于我们在src/main.js文件中控制了身份验证状态,当成功注销时,应用程序将自动返回登录或注册屏幕。
我们讨论了大多数现代企业应用程序的几个重要主题:用户管理、数据同步、复杂的应用程序状态和处理表单。这是一个完整的应用程序,我们通过一个小的代码库和 MobX 和 Firebase 的帮助来修复它。
Firebase 能够在大量用户的生产环境中处理此应用程序,但构建我们自己的后端系统不应该是一项复杂的任务,特别是如果我们有使用 socket.io 和实时数据库的经验。
本课程中缺少一些方面,例如处理安全性(可以完全在 Firebase 中完成)或为两个以上的用户创建聊天室。在任何情况下,这些方面都不属于 React Native 的环境,因此它们被故意忽略。
完成本课程后,我们应该能够在 Firebase 和 MobX 之上构建任何应用程序,因为我们涵盖了这两项技术中使用最多的用户案例。在这本书中,有一些更复杂的案例被遗漏了,但通过对本课程中所解释的基础知识的良好理解,可以很容易地学习到这些案例。
在下一课中,我们将构建一个非常不同的应用程序:一个用 React Native 编写的游戏。
- 此存储区负责保存聊天和消息周围的所有数据和逻辑,并在用户登录时帮助聊天存储区初始化。
- 推送通知
- 列表
- 聊天
- 搜索
- 以下哪项是计算存储在精灵中的每个精灵的新位置的主要功能。
getRockProps()reducer()action()moveSprites()
- 说明以下语句是否正确:Firebase 允许移动开发人员使用云托管的 NoSQL 数据库在用户和设备之间实时存储和同步数据。
- 背景图像不包含在任何自定义组件中,但包含在 _____;中。这是因为它不需要任何作为静态元素的特殊逻辑。
<GamseContainer /><Image /><TouchableWithoutFeedback /><TouchableOpacity />
- 可用的重复操作有哪些?











