使用AWS构建后端(三) —— Data Store(一)

版本记录

版本号 时间
V1.0 2020.11.17 星期二

前言

使用Amazon Web Services(AWS)为iOS应用构建后端,可以用来学习很多东西。下面我们就一起学习下如何使用Xcode Server。感兴趣的可以看下面几篇文章。
1. 使用AWS构建后端(一) —— Authentication & API(一)
2. 使用AWS构建后端(二) —— Authentication & API(二)

开始

首先看下主要内容:

在本教程中,您将扩展上一教程中的Isolation Nation应用程序,使用AWS PinpointAWS Amplify DataStore添加分析和实时聊天功能。内容来自翻译

下面看下写作环境:

Swift 5, iOS 14, Xcode 12

接着就是正文啦。

在本教程中,您将选择在Part 1, Authentication & API结尾处停下来的Isolation Nation应用。 您将使用AWS PinpointAWS Amplify DataStore扩展应用程序,以添加分析和实时聊天功能。

在您开始之前,请登录到AWS Console

在项目的Amplify控制台中,选择Backend environments选项卡,单击Edit backend链接,然后单击Copy拉命令。

导航到终端中的启动程序项目,然后粘贴刚从Amplify控制台复制的命令。 出现提示时,请选择您之前设置的配置文件(如果适用)。 选择no default editor,一个iOS应用程序,然后从终端的选项列表中选择Y来修改后端。

接下来,通过运行以下命令来生成Amplify配置文件:

amplify-app --platform ios

Xcode中打开IsolationNation.xcworkspace文件,然后编辑amplifytools.xcconfig文件以反映以下设置:

push=true
modelgen=true
profile=default
envName=dev

注意:打开正确文件的快速简便方法是输入以下命令:

xed .

最后,在终端中运行以下命令:

pod update

更新完成后,构建您的应用程序。您可能需要构建两次,因为Amplify第一次需要生成User模型文件。

注意:尽管建议您先阅读上一教程,但这不是必需的。在开始之前,您必须在计算机上设置AWS Amplify。而且,您必须添加具有Cognito Auth和带有用户模型的AppSync API的新Amplify项目。请参阅上一教程中的说明。

Isolation Nation是一款针对因COVID-19而自我隔离的人的应用程序。它使他们可以向当地社区的其他人寻求帮助。在上一教程的结尾,该应用程序使用AWS Cognito允许用户注册并登录到该应用程序。它使用AWS AppSync读取和写入有关用户的公共用户数据。

现在构建并运行。如果您已经完成了上一教程,请确认您仍然可以使用之前创建的用户帐户登录。如果您是从这里开始的,请注册一个新用户。


App Analytics

分析人们在现实生活中是如何使用你的应用程序的,这是创建一个伟大产品过程中的一个重要部分。AWS Pinpoint是一项为您的应用程序提供分析和营销能力的服务。在本节中,您将学习如何记录用户行为,以便将来进行分析。

首先,在项目的根目录打开一个终端。使用Amplify CLI为您的项目添加分析功能:

amplify add analytics

当出现提示时,选择Amazon Pinpoint并按Enter接受默认资源名。键入Y接受推荐的授权默认值。

接下来,在Xcode中打开工作区并打开Podfile。在包含end的行前插入以下代码:

pod 'AmplifyPlugins/AWSPinpointAnalyticsPlugin'

这就增加了AWS Pinpoint插件作为你的应用程序的依赖。切换到你的终端并运行以下程序来安装插件:

pod install --repo-update 

回到Xcode,打开AppDelegate.swift,将Pinpoint插件添加到Amplify配置中。在application(_:didFinishLaunchingWithOptions:)中,直接在调用Amplify.configure()之前添加以下代码行:

try Amplify.add(plugin: AWSPinpointAnalyticsPlugin())

您的应用程序现在配置为发送分析数据到AWS Pinpoint


Tracking Users and Sessions

使用Amplify跟踪用户会话非常简单。事实上,再简单不过了,因为你什么都不用做!只要安装插件就会自动记录应用程序打开和关闭的时间。

但是,为了真正有用,你应该在你的分析调用中添加user identification。在Xcode中,打开AuthenticationService.swift。在文件的最底部,添加以下扩展名:

// MARK: AWS Pinpoint

extension AuthenticationService {
  // 1
  func identifyUserToAnalyticsService(_ user: AuthUser) {
    // 2
    let userProfile = AnalyticsUserProfile(
      name: user.username,
      email: nil,
      plan: nil,
      location: nil,
      properties: nil
    )
    // 3
    Amplify.Analytics.identifyUser(user.userId, withProfile: userProfile)
  }
}

在这段代码中,你做了几件事:

  • 1) 首先,创建一个新方法identifyUserToAnalyticsService(_:),它接受一个AuthUser对象。
  • 2) 然后,为用户创建一个analytics user profile。对于分析,您只关心用户名,所以您将其他可选字段设置为nil
  • 3) 最后调用identifyUser(_:withProfile:)。传递用户ID和刚刚创建的用户配置文件。这将在AWS Pinpoint中识别用户。

接下来,更新setUserSessionData(_:)的方法签名,以接受一个可选的AuthUser参数:

private func setUserSessionData(_ user: User?, authUser: AuthUser? = nil) {

将以下内容添加到该方法中的DispatchQueue块的末尾:

if let authUser = authUser {
  identifyUserToAnalyticsService(authUser)
}

现在,在两个地方更新对setUserSessionData(_:authUser:)的调用。在signIn(as:identifiedBy:)checkAuthSession()结束时进行相同的更改:

setUserSessionData(user, authUser: authUser)

现在将authUser传递到setUserSessionData。这允许它调用identifyUserToAnalyticsService(_:)

构建和运行。与您的用户多次登录和退出,这样您就会在您的Pinpoint分析中看到一些东西。

接下来,打开终端,输入以下内容:

amplify console analytics

这将在浏览器中打开一个Pinpoint console,显示应用程序后端的Analytics overview

在默认情况下,Pinpoint显示过去30天的汇总数据。就目前而言,几乎可以肯定,这一数字的平均值将为零。在标题下面,选择Last 30 days。然后,在弹出框中,选择今天的日期作为时间段的开始和结束。点击离开弹出窗口关闭它,统计将刷新与今天的数据。

在左侧菜单中,选择Usage。在显示活动端点和活动用户的框中,您应该看到一些非零值。如果计数仍然为零,不要担心——刷新数据可能需要15分钟。如果是这种情况,请继续学习本教程并在结束时再次检查。

到目前为止,这些分析已经足够了。是时候开始构建聊天功能了!


Updating Data in DynamoDB

您可能已经注意到,所有测试用户的位置Locations列表中都有SW1A位置。相反,你的应用程序需要询问人们住在哪里。遗憾的是,不是每个人都能住在白金汉宫!

打开HomeScreenViewModel.swift。在文件的顶部,导入Amplify库:

import Amplify

HomeScreenViewModel发布一个名为userPostcodeState的属性。这将一个可选String包装在一个Loading枚举中。

导航到fetchUser()。请注意如何将userPostcodeState设置为.loaded,以及一个硬编码的相关值SW1A 1AA。将这一行改为:

// 1
userPostcodeState = .loading(nil)
// 2
_ = Amplify.API.query(request: .get(User.self, byId: userID)) { [self] event in
  // 3
  DispatchQueue.main.async {
    // 4
    switch event {
    case .failure(let error):
      logger?.logError(error.localizedDescription)
      userPostcodeState = .errored(error)
      return
    case .success(let result):
      switch result {
      case .failure(let resultError):
        logger?.logError(resultError.localizedDescription)
        userPostcodeState = .errored(resultError)
        return
      case .success(let user):
        // 5
        guard 
          let user = user, 
          let postcode = user.postcode 
        else {
          userPostcodeState = .loaded(nil)
          return
        }
        // 6
        userPostcodeState = .loaded(postcode)
      }
    }
  }
}

下面是这段代码的作用:

  • 1) 首先,将userPostcodeState设置为loading
  • 2) 然后,从DynamoDB获取用户。
  • 3) 分派到主队列,因为您应该始终从主线程修改已发布的变量。
  • 4) 用通常的方式检查错误。
  • 5) 如果请求成功,检查用户模型是否有邮政编码设置。如果没有,将userPostcodeState设置为nil
  • 6) 如果是,则将userPostcodeState设置为loaded,并将用户的邮政编码作为关联值。

构建和运行。这一次,当您的测试用户登录时,应用程序将显示一个屏幕,邀请用户输入邮政编码。

如果你想知道这个应用程序是如何显示这个屏幕的,请查看HomeScreen.swift。注意,如果postcodenil,视图是如何呈现SetPostcodeView的。

Home group中打开SetPostcodeView.swift。这是一个相当简单的视图。TextField收集用户的邮政编码。Button要求view model在单击时执行addPostCode操作。

现在,再次打开HomeScreenViewModel.swift。在文件的底部找到addPostCode(_:)并写它的实现:

// 1
_ = Amplify.API.query(request: .get(User.self, byId: userID)) { [self] event in
  DispatchQueue.main.async {
    switch event {
    case .failure(let error):
      logger?.logError("Error occurred: \(error.localizedDescription )")
      userPostcodeState = .errored(error)
    case .success(let result):
      switch result {
      case .failure(let resultError):
        logger?
          .logError("Error occurred: \(resultError.localizedDescription )")
        userPostcodeState = .errored(resultError)
      case .success(let user):
        guard var user = user else {
          let error = IsolationNationError.noRecordReturnedFromAPI
          userPostcodeState = .errored(error)
          return
        }

        // 2
        user.postcode = postcode
        // 3 (Replace me later)
        _ = Amplify.API.mutate(request: .update(user)) { event in
          // 4
          DispatchQueue.main.async {
            switch event {
            case .failure(let error):
              logger?
                .logError("Error occurred: \(error.localizedDescription )")
              userPostcodeState = .errored(error)
            case .success(let result):
              switch result {
              case .failure(let resultError):
                logger?.logError(
                  "Error occurred: \(resultError.localizedDescription )")
                userPostcodeState = .errored(resultError)
              case .success(let savedUser):
                // 5
                userPostcodeState = .loaded(savedUser.postcode)
              }
            }
          }
        }
      }
    }
  }
}

同样,这看起来有很多代码。但它的大部分只是检查是否成功的请求和处理错误,如果没有:

  • 1) 你调用Amplify.API.query。以通常的方式通过ID查询请求用户。
  • 2) 如果成功,您可以通过将postcode设置为用户输入的值来修改获取的用户模型。
  • 3) 然后调用Amplify.API.mutate改变已存在的模型。
  • 4) 您处理响应。然后再次切换到主线程并检查是否有error
  • 5) 如果成功,则将userPostcodeState设置为保存的值。

再次构建并运行。当视图显示以收集用户的邮政编码时,输入SW1A 1AA并点击Update。一秒钟后,应用程序将再次显示Locations屏幕,SW1A thread显示在列表中。

现在输入以下到您的终端:

amplify console api

当被询问时,选择GraphQLAWS AppSync登录页面将在您的浏览器中打开。选择Data Sources。单击User表的链接,然后选择Items选项卡。

选择刚刚为其添加邮政编码的用户的ID。注意,postcode字段现在出现在记录中。

为其他用户打开记录,注意该字段是空的。这是像DynamoDB这样的键-值数据库的一个重要特性。它们允许灵活的schema,这对于新应用程序的快速迭代非常有用。

在本节中,您已经添加了一个GraphQL schema。您使用AWS AppSync从该schema声明式地生成后端。您还使用了AppSync来读取和写入数据到底层的DynamoDB


Designing a Chat Data Model

到目前为止,你已经有了一个基于云登录的应用程序。它还将用户记录读写到基于云的数据库中。但这对用户来说并不令人兴奋,不是吗?

是时候解决这个问题了!在本教程的其余部分中,您将为您的应用程序设计和构建聊天特性。

打开schema.graphql。在AmplifyConfig组中。在文件底部添加以下Thread model:

# 1
type Thread
  @model
  # 2
  @key(
    fields: ["location"], 
    name: "byLocation", 
    queryField: "ThreadByLocation")
{
  id: ID!
  name: String!
  location: String!
  # 3
  messages: [Message] @connection(
    name: "ThreadMessages", 
    sortField: "createdAt")
  # 4
  associated: [UserThread] @connection(keyName: "byThread", fields: ["id"])
  createdAt: AWSDateTime!
}

运行整个模型,这是你要做的:

  • 1) 定义一个Thread类型。使用@model指令告诉AppSync为这个模型创建一个DynamoDB表。
  • 2) 您添加了@key指令,该指令在DynamoDB数据库中添加了一个自定义索引。在本例中,您指定希望能够查询Thread
  • 3) 您可以向Thread模型添加messagesmessages包含Message类型的数组。您可以使用@connection指令来指定Thread及其Messages之间的一对多连接。稍后您将了解更多相关信息。
  • 4) 添加一个包含UserThread对象数组的associated字段。要在AppSync中支持多对多连接,您需要创建一个joining modelUserThread是支持用户和线程之间连接的joining model

接下来,为Message类型添加类型定义:

type Message
  @model
{
  id: ID!
  author: User! @connection(name: "UserMessages")
  body: String!
  thread: Thread @connection(name: "ThreadMessages")
  replies: [Reply] @connection(name: "MessageReplies", sortField: "createdAt")
  createdAt: AWSDateTime!
}

如您所料,Message类型具有到author的连接,类型为User。它还拥有到Thread的连接以及对该Message的任何Replies。注意,线程@connection的名称与线程类型中提供的名称相匹配。

接下来,添加回复的定义:

type Reply
  @model
{
  id: ID!
  author: User! @connection(name: "UserReplies")
  body: String!
  message: Message @connection(name: "MessageReplies")
  createdAt: AWSDateTime!
}

这里没什么新东西!这与上面的Message类似。

现在为我们的UserThread类型添加模型:

type UserThread
  @model
  # 1
  @key(name: "byUser", fields: ["userThreadUserId", "userThreadThreadId"])
  @key(name: "byThread", fields: ["userThreadThreadId", "userThreadUserId"])
{
  id: ID!
  # 2
  userThreadUserId: ID!
  userThreadThreadId: ID!
  # 3
  user: User! @connection(fields: ["userThreadUserId"])
  thread: Thread! @connection(fields: ["userThreadThreadId"])
  createdAt: AWSDateTime!
}

当使用AppSync创建多对多连接时,您不会直接在类型上创建连接。相反,您可以创建一个连接模型。为了你的加入模型工作,你必须提供以下几件事:

  • 1) 您可以为模型的每一边标识一个密钥。fields数组中的第一个字段定义此键的hash key,第二个字段是sort key
  • 2) 对于连接中的每个类型,您可以指定一个ID字段来保存连接数据。
  • 3) 还可以提供每种类型的字段。这个字段使用@connection指令来指定上面的ID字段用于连接到类型。

最后,将以下连接添加到postcode后的User类型,这样您的用户将访问他们的数据:

threads: [UserThread] @connection(keyName: "byUser", fields: ["id"])
messages: [Message] @connection(name: "UserMessages")
replies: [Reply] @connection(name: "UserReplies")

构建和运行。这将需要一些时间,因为Amplify Tools插件做了很多工作:

  • 1) 它会注意到所有新的GraphQL类型。
  • 2) 它为你生成Swift模型。
  • 3) 它在云中更新AppSyncDynamoDB

构建完成后,查看您的AmplifyModels组。它现在包含所有新类型的模型文件。

然后在浏览器中打开DynamoDB选项卡,确认每种类型的表也存在。

您现在有了一个数据模型,它反映在您的代码和云中!


Amplify DataStore

在前面,您学习了如何使用Amplify API通过AppSync读取和写入数据。Amplify还提供DataStoreDataStore是用于与云同步数据的更复杂的解决方案。

Amplify DataStore的主要优点是它在移动设备上创建和管理一个本地数据库。DataStore存储了从云端获取的所有模型数据,就在你的手机上!

这允许您在没有互联网连接的情况下查询和修改数据。当您的设备重新联机时,DataStore将同步更改。这不仅允许离线访问,也意味着你的应用对用户来说更快捷。这是因为在UI中显示更新之前,您不必等待到服务器的往返。

用于与DataStore交互的编程模型与Amplify API的编程模型略有不同。在使用API时,可以确保返回的任何结果都是DynamoDB中存储的最新结果。相比之下,DataStore将立即返回本地结果!然后它发出一个请求来更新它在后台的本地缓存。如果要显示最新信息,代码必须订阅更新或再次查询缓存。

如果你想根据数据的存在与否做出决定,这使得Amplify API成为一个更好的解决方案。例如,我是否应该显示邮政编码输入屏幕?但是DataStore是提供丰富用户体验的更好的抽象。因此,应用程序中的聊天功能将使用DataStore

要开始使用DataStore,打开Podfile并添加依赖项:

pod 'AmplifyPlugins/AWSDataStorePlugin'

然后,在您的终端中,按正常方式安装:

pod install --repo-update

接下来,打开AppDelegate.swift并定位application(_:didFinishLaunchingWithOptions:)。在调用Amplify.configure()之前添加以下配置代码:

try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: AmplifyModels()))

您现在已经在应用程序中安装了DataStore!接下来,您将使用它在本地存储数据。


Writing Data to DataStore

Isolation Nation允许住在彼此附近的人请求援助。当用户更改邮政编码时,应用程序需要检查该邮政编码区域是否已经存在Thread。如果没有,它必须创建一个。然后,它必须将用户添加到Thread中。

打开HomeScreenViewModel.swift。在文件的底部,类的右括号内,添加以下方法:

// MARK: - Private functions

// 1
private func addUser(_ user: User, to thread: Thread) -> Future<String, Error> {
  return Future { promise in
    // 2
    let userThread = UserThread(
      user: user, 
      thread: thread, 
      createdAt: Temporal.DateTime.now())
    // 3
    Amplify.DataStore.save(userThread) { result in
      // 4
      switch result {
      case .failure(let error):
        promise(.failure(error))
      case .success(let userThread):
        promise(.success(userThread.id))
      }
    }
  }
}

在这个方法中,你使用DataStore API来保存一个新的UserThread记录:

  • 1) 首先,您接收UserThread模型并返回一个Future
  • 2) 接下来,创建一个连接用户和线程的UserThread模型。
  • 3) 您使用的是Amplify.DataStore.save API以保存用户线程。
  • 4) 最后,在适当的情况下使用成功或失败来完成promise

下面,添加另一个方法在DataStore中创建一个新线程:

private func createThread(_ location: String) -> Future<Thread, Error> {
  return Future { promise in
    let thread = Thread(
      name: location, 
      location: location, 
      createdAt: Temporal.DateTime.now())
    Amplify.DataStore.save(thread) { result in
      switch result {
      case .failure(let error):
        promise(.failure(error))
      case .success(let thread):
        promise(.success(thread))
      }
    }
  }
}

这与前面的示例非常相似。

接下来,创建一个方法来获取或创建线程,基于位置:

private func fetchOrCreateThreadWithLocation(
  location: String
) -> Future<Thread, Error> {
  return Future { promise in
    // 1
    let threadHasLocation = Thread.keys.location == location
    // 2
    _ = Amplify.API.query(
      request: .list(Thread.self, where: threadHasLocation)
    ) { [self] event in
      switch event {
      case .failure(let error):
        logger?.logError("Error occurred: \(error.localizedDescription )")
        promise(.failure(error))
      case .success(let result):
        switch result {
        case .failure(let resultError):
          logger?.logError(
            "Error occurred: \(resultError.localizedDescription )")
          promise(.failure(resultError))
        case .success(let threads):
          // 3
          guard let thread = threads.first else {
            // Need to create the Thread
            // 4
            _ = createThread(location).sink(
              receiveCompletion: { completion in
                switch completion {
                case .failure(let error): promise(.failure(error))
                case .finished:
                  break
                }
              },
              receiveValue: { thread in
                promise(.success(thread))
              }
            )
            return
          }
          // 5
          promise(.success(thread))
        }
      }
    }
  }
}

下面是这段代码的作用:

  • 1) 首先为查询线程构建谓词(predicate)。在本例中,您希望查询具有给定位置的线程。
  • 2) 然后你使用Amplify。用于查询具有所提供位置的线程的API。这里使用的是Amplify API,而不是数据存储。这是因为您想立即知道线程是否已经存在。注意,这种形式的query API接受上面的谓词作为第二个参数。
  • 3) 在检查错误之后,您将检查从API返回的值。
  • 4) 如果API没有返回线程,那么就使用前面编写的方法创建一个线程。
  • 5) 否则,返回从API查询接收到的线程。

现在,添加最后一个方法:

// 1
private func addUser(_ user: User, to location: String) {
  // 2
  cancellable = fetchOrCreateThreadWithLocation(location: location)
    .flatMap { thread in
      // 3
      return self.addUser(user, to: thread)
    }
    .receive(on: DispatchQueue.main)
    .sink(
      receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
          self.userPostcodeState = .errored(error)
        case .finished:
          break
        }
    },
      receiveValue: { _ in
        // 4
        self.userPostcodeState = .loaded(user.postcode)
    }
    )
}

在这里,您编排调用您刚刚创建的方法:

  • 1) 您接收Userlocation
  • 2) 你调用fetchOrCreateThreadWithLocation(location:),它会返回一个thread
  • 3) 然后调用addUser(_:to:),它在数据存储中创建一个UserThread行。
  • 4) 最后,将userPostcocdeState设置为loaded

最后,您需要更新addPostCode()以从邮政编码中提取位置,并使用它来调用addUser(_:to:)。找到// 3 (Replace me later)注释。删除mutate调用,并将其替换为:

// 1
Amplify.DataStore.save(user) { [self] result in
  DispatchQueue.main.async {
    switch result {
    case .failure(let error):
      logger?.logError("Error occurred: \(error.localizedDescription )")
      userPostcodeState = .errored(error)
    case .success:
      // Now we have a user, check to see if there is a Thread already created
      // for their postcode. If not, create it.
      // 2
      guard let location = postcode.postcodeArea() else {
        logger?.logError("""
          Could not find location within postcode \
          '\(String(describing: postcode))'. Aborting.
          """
        )
        userPostcodeState = .errored(
          IsolationNationError.invalidPostcode
        )
        return
      }
      // 3
      addUser(user, to: location)
    }
  }
}

下面是你正在做的事情:

  • 1) 首先,使用DataStore save API在本地保存用户。
  • 2) 处理错误后,检查邮政编码是否具有有效的邮政编码区域。
  • 3) 然后使用前面编写的方法将用户添加到该位置。

在运行应用程序之前,在浏览器中打开DynamoDB标签。找到您先前为测试用户设置的邮政编码。因为您当时没有创建线程,所以这些数据现在是危险的!要删除它,请单击字段左侧的灰色加号图标。然后单击Remove

构建和运行。因为你删除了邮政编码,应用程序会显示“enter postcode”屏幕。输入与前面相同的邮政编码SW1A 1AA,然后点击Update

您将看到Locations屏幕,正确的位置显示在列表的顶部。

在浏览器中,转到DynamoDB选项卡并打开User表。刷新页面。单击您的用户的链接并确认确实设置了邮政编码。打开ThreadUserThread表并确认那里也有记录。

现在构建并在其他模拟器上运行。当出现提示时,输入与前面相同的邮政编码SW1A 1AA。返回浏览器,确认已经为其他User设置了邮政编码。您还应该看到另一个UserThread记录,但没有新Thread


Loading Threads

你可能感觉不到,但你的聊天应用程序已经开始成型了!您的应用程序现在有:

  • 通过身份验证的用户
  • 用户位置
  • 线程与正确的用户分配
  • 数据存储在云,使用DynamoDB

下一步是在Location屏幕中为用户加载正确的线程。

打开ThreadsScreenViewModel.swift。在文件的顶部,导入Amplify:

import Amplify

然后,在文件的底部,添加以下扩展名:

// MARK: AWS Model to Model conversions

extension Thread {
  func asModel() -> ThreadModel {
    ThreadModel(id: id, name: name)
  }
}

这个扩展提供了一个关于Amplify-generated Thread的方法。它返回视图使用的view model。这样就可以将Amplify-specific的关注点从UI代码中移除!

接下来,删除fetchThreads()及其硬编码线程的内容。将其替换为:

// 1
guard let loggedInUser = userSession.loggedInUser else {
  return
}
let userID = loggedInUser.id

// 2
Amplify.DataStore.query(User.self, byId: userID) { [self] result in
  switch result {
  case .failure(let error):
    logger?.logError("Error occurred: \(error.localizedDescription )")
    threadListState = .errored(error)
    return
  case .success(let user):
    // 3
    guard let user = user else {
      let error = IsolationNationError.unexpectedGraphQLData
      logger?.logError("Error fetching user \(userID): \(error)")
      threadListState = .errored(error)
      return
    }

    // 4
    guard let userThreads = user.threads else {
      let error = IsolationNationError.unexpectedGraphQLData
      logger?.logError("Error fetching threads for user \(userID): \(error)")
      threadListState = .errored(error)
      return
    }

    // 5
    threadList = userThreads.map { $0.thread.asModel() }
    threadListState = .loaded(threadList)
  }
}

下面是你正在做的事情:

  • 1) 检查已登录的用户。
  • 2) 使用DataStore查询APIID查询用户。
  • 3) 检查DataStore中的error之后,确认用户不是nil
  • 4) 还要检查用户上的userThreads数组是否为nil
  • 5) 最后,设置要显示的线程列表。然后,将发布的threadListState更新为loaded

构建和运行。确认Locations列表仍然显示正确的thread

现在是时候开始在用户之间发送消息了!

注意:对于本教程的其余部分,您应该运行两个模拟器。它们在同一个thread中应该有不同的用户。


Sending Messages

这里的第一个任务与上面ThreadsScreenViewModel中的更改类似。

打开MessagesScreenViewModel.swift。在文件的顶部添加Amplify导入:

import Amplify

在文件的底部,添加一个扩展名,在Amplify模型和view model之间进行转换:

// MARK: AWS Model to Model conversions

extension Message {
  func asModel() -> MessageModel {
    MessageModel(
      id: id,
      body: body,
      authorName: author.username,
      messageThreadId: thread?.id,
      createdAt: createdAt.foundationDate
    )
  }
}

然后,删除fetchMessages()的内容。一旦您可以创建真实的消息,您就不需要这些硬编码的消息了!用DataStore中的正确query替换内容:

// 1
Amplify.DataStore.query(Thread.self, byId: threadID) { [self] threadResult in
  switch threadResult {
  case .failure(let error):
    logger?
      .logError("Error fetching messages for thread \(threadID): \(error)")
    messageListState = .errored(error)
    return

  case .success(let thread):
    // 2
    messageList = thread?.messages?.sorted { $0.createdAt < $1.createdAt }
      .map({ $0.asModel() }) ?? []
    // 3
    messageListState = .loaded(messageList)
  }
}

这就是你在这里所做的:

  • 1) 首先,通过Thread的ID查询Thread
  • 2) 检查error后,检索连接到thread的消息。将它们映射到一个MessageModels列表。使用DataStore API很容易访问连接的对象。您只需访问它们 — 数据将根据需要从后端存储延迟加载。
  • 3) 最后,将messageListState设置为loaded

构建和运行。点击该thread以查看消息列表。现在列表是空的。

在屏幕的底部,有一个文本框,用户可以在这里输入他们的帮助请求。当用户点击Send时,视图将在视图模型上调用perform(action:)。这将请求转发给addMessage(input:)

还在MessagesScreenViewModel.swift,添加以下实现到addMessage(input:):

// 1
guard let author = userSession.loggedInUser else {
  return
}

// 2
Amplify.DataStore.query(Thread.self, byId: threadID) { [self] threadResult in
  switch threadResult {
  case .failure(let error):
    logger?.logError("Error fetching thread \(threadID): \(error)")
    messageListState = .errored(error)
    return

  case .success(let thread):
    // 3
    var newMessage = Message(
      author: author, 
      body: input.body, 
      createdAt: Temporal.DateTime.now())
    // 4
    newMessage.thread = thread
    // 5
    Amplify.DataStore.save(newMessage) { saveResult in
      switch saveResult {
      case .failure(let error):
        logger?.logError("Error saving message: \(error)")
        messageListState = .errored(error)
      case .success:
        // 6
        messageList.append(newMessage.asModel())
        messageListState = .loaded(messageList)
        return
      }
    }
  }
}

这个实现看起来非常熟悉!这就是你正在做的:

  • 1) 首先检查是否有一个登录的用户作为作者。
  • 2) 然后,在数据存储中查询thread
  • 3) 接下来,使用来自input的值创建一个新消息。
  • 4) 您将thread设置为newMessage的所有者。
  • 5) 将消息保存到数据存储区。
  • 6) 最后,将消息追加到view modelmessageList并发布messageListState以更新API

在两个模拟器上构建和运行,然后点击Messages屏幕。在一个模拟器上创建一个新消息…好哇!一条消息出现在屏幕上。

在浏览器中,在DynamoDB选项卡中打开Message表。确认消息已保存到云上。

您的新消息会出现——但只出现在您用来创建它的模拟器上。在另一个模拟器上,单击back,然后重新进入thread。该消息现在将出现。很明显,这是可行的,但是对于一个聊天应用来说,这并不是实时的!


Subscribing to Messages

幸运的是,DataStore支持GraphQL Subscriptions,这是这类问题的完美解决方案。

打开MessagesScreenViewModel.swift并定位subscribe()。在这个方法之前,添加一个属性来存储一个AnyCancellable?:

var fetchMessageSubscription: AnyCancellable?

接下来,添加subscription completion handler

private func subscriptionCompletionHandler(
  completion: Subscribers.Completion<DataStoreError>
) {
  if case .failure(let error) = completion {
    logger?.logError("Error fetching messages for thread \(threadID): \(error)")
    messageListState = .errored(error)
  }
}

如果subscription completes时出现错误,此代码将messageListState设置为error状态。

最后,向subscribe()添加以下实现:

// 1
fetchMessageSubscription = Amplify.DataStore.publisher(for: Message.self)
  // 2
  .receive(on: DispatchQueue.main)
  .sink(receiveCompletion: subscriptionCompletionHandler) { [self] changes in
    do {
      // 3
      let message = try changes.decodeModel(as: Message.self)

      // 4
      guard 
        let messageThreadID = message.thread?.id, 
        messageThreadID == threadID 
        else {
          return
      }

      // 5
      messageListState = .updating(messageList)
      // 6
      let isNewMessage = messageList.filter { $0.id == message.id }.isEmpty
      if isNewMessage {
        messageList.append(message.asModel())
      }
      // 7
      messageListState = .loaded(messageList)
    } catch {
      logger?.logError("\(error.localizedDescription)")
      messageListState = .errored(error)
    }
  }

以下是您如何实现您的消息订阅:

  • 1) 您可以使用DataStore中的publisher API侦听Message模型的更改。无论何时从AppSync接收到GraphQL订阅,或者当对数据存储进行本地更改时,都会调用该API。
  • 2) 订阅主队列上的发布服务器(publisher)
  • 3) 如果成功,则从更改响应中解码Message对象。
  • 4) 你检查以确保这条消息是应用程序正在显示的同一线程。遗憾的是,DataStore目前不允许使用谓词(predicate)设置订阅。
  • 5) 将messageListState设置为update,并将其发布到UI。
  • 6) 您检查该消息是否是新的。如果是,则将其附加到messageList
  • 7) 最后,将messageListState更新为loaded

同样,在两个模拟器上构建和运行。点击两者上的消息列表,从其中一个发送消息。注意消息是如何立即出现在两个设备上的。

这是一个实时聊天应用程序!


Replying to Messages

回复消息所需的更改几乎与发送消息所需的更改相同。如果你想创建一个功能齐全的聊天应用程序,那么请继续阅读!您将很快地了解它,因为它与上面的代码非常相似。但如果你对学习更感兴趣,可以跳过这一部分。

打开RepliesScreenViewModel.swift并导入文件顶部的Amplify:

import Amplify

接下来,在底部添加模型转换代码作为扩展:

// MARK: AWS Model to Model conversions

extension Reply {
  func asModel() -> ReplyModel {
    return ReplyModel(
      id: id,
      body: body,
      authorName: author.username,
      messageId: message?.id,
      createdAt: createdAt.foundationDate
    )
  }
}

用一个DataStore查询替换fetchReplies()中的stub实现:

Amplify.DataStore
  .query(Message.self, byId: messageID) { [self] messageResult in
  switch messageResult {
  case .failure(let error):
    logger?.
      logError("Error fetching replies for message \(messageID): \(error)")
    replyListState = .errored(error)
    return

  case .success(let message):
    self.message = message?.asModel()
    replyList = message?.replies?.sorted { $0.createdAt < $1.createdAt }
      .map({ $0.asModel() }) ?? []
    replyListState = .loaded(replyList)
  }
}

addReply()中,添加一个实现来创建一个回复:

guard let author = userSession.loggedInUser else {
  return
}

Amplify.DataStore.query(Message.self, byId: messageID) { [self] messageResult in
  switch messageResult {
  case .failure(let error):
    logger?.logError("Error fetching message \(messageID): \(error)")
    replyListState = .errored(error)
    return

  case .success(let message):
    var newReply = Reply(
      author: author, 
      body: input.body, 
      createdAt: Temporal.DateTime.now())
    newReply.message = message
    Amplify.DataStore.save(newReply) { saveResult in
      switch saveResult {
      case .failure(let error):
        logger?.logError("Error saving reply: \(error)")
        replyListState = .errored(error)
      case .success:
        replyList.append(newReply.asModel())
        replyListState = .loaded(replyList)
        return
      }
    }
  }
}

添加handling subscription:

var fetchReplySubscription: AnyCancellable?

private func subscriptionCompletionHandler(
  completion: Subscribers.Completion<DataStoreError>
) {
  if case .failure(let error) = completion {
    logger?.logError("Error fetching replies for message \(messageID): \(error)")
    replyListState = .errored(error)
  }
}

最后,实现subscribe()

fetchReplySubscription = Amplify.DataStore.publisher(for: Reply.self)
  .receive(on: DispatchQueue.main)
  .sink(receiveCompletion: subscriptionCompletionHandler) { [self] changes in
    do {
      let reply = try changes.decodeModel(as: Reply.self)

      guard 
        let replyMessageID = reply.message?.id, 
        replyMessageID == messageID 
      else {
        return
      }

      replyListState = .updating(replyList)
      let isNewReply = replyList.filter { $0.id == reply.id }.isEmpty
      if isNewReply {
        replyList.append(reply.asModel())
      }
      replyListState = .loaded(replyList)
    } catch {
      logger?.logError("\(error.localizedDescription)")
      replyListState = .errored(error)
    }
  }

哇,速度真快!

在两个模拟器上编译和运行。点击thread查看消息,然后点击一条消息查看回复。在你的用户之间来回发送一些回复。他们相处得多好,不是很好吗?

恭喜你!你有一个工作的聊天应用程序!

在这个由两部分组成的系列教程中,您已经创建了一个使用AWS Amplify作为后端功能完备的聊天应用程序。这里有一些文档链接,可以帮助你锁定在本教程中获得的知识:

您可以从Amplify Docs中了解有关Amplify的更多信息。这些包括用于webAndroid的库。如果你想给你的应用程序添加额外的功能,你可以考虑使用S3来保存静态数据,比如用户图片。或者您可以使用@auth GraphQL directive指令向模型数据添加对象级或字段级身份验证。

后记

本篇主要讲述了Data Store,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 197,368评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,941评论 2 374
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,369评论 0 326
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,848评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,719评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,505评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,904评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,528评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,819评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,848评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,652评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,468评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,912评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,095评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,389评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,906评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,120评论 2 339

推荐阅读更多精彩内容