跳转至

第12章:创建一个简单的iPhone应用程序,第一部分

在前几章中,你创建了一个API,并使用RESTed与之进行交互。然而,用户希望使用TIL的东西更漂亮一些!因此,你必须使用TIL。接下来的两章将告诉你如何建立一个与API交互的简单iOS应用。在这一章中,你将学习如何创建不同的模型并从数据库中获取模型。

在这两章结束时,你将拥有一个iOS应用程序,它可以做你到目前为止所学的一切。它看起来将类似于以下内容:

img

Getting started

To kick things off, download the materials for this chapter. In Terminal, go the directory where you downloaded the materials and type:

开始工作

为了开始工作,请下载本章的材料。在终端机中,进入你下载材料的目录,并输入:

cd TILApp
swift run

这将建立并运行iOS应用将与之对话的TIL应用。如果你愿意,你可以使用你现有的TIL应用程序。

Note

这需要你的数据库的Docker容器正在运行。参见第6章,"配置数据库",以获得指导。

接下来,打开TILiOS项目。TILiOS包含一个与TIL API交互的骨架应用程序。它是一个有三个标签的标签栏应用程序:

  • Acronyms:查看所有的缩写,查看有关缩写的细节,并添加缩写。
  • Users:查看所有用户并创建用户。
  • Categories:查看所有类别并创建类别。

该项目包含几个空的表视图控制器,准备让你配置,以显示来自TIL API的数据。

看看项目中的Models组;它提供了三个模型类:

  • Acronym
  • User
  • Category

你可能会认出这些模型--这些模型与发现的API应用相匹配 这表明在客户端和服务器上使用相同的语言可以有多么强大。甚至可以创建一个两个项目都使用的独立模块,这样你就不用重复编写代码了。由于Fluent表示父子关系的方式,Acronym略有不同。你可以用一个像CreateAcronymData这样的DTO来解决这个问题,该项目也包括这个DTO

查看缩略语

第一个标签的表格显示所有的首字母缩写。在Utilities组中创建一个新的Swift文件,名为ResourceRequest.swift。打开该文件并创建一个类型来管理资源请求:

// 1
struct ResourceRequest<ResourceType>
  where ResourceType: Codable {
  // 2
  let baseURL = "http://localhost:8080/api/"
  let resourceURL: URL

  // 3
  init(resourcePath: String) {
    guard let resourceURL = URL(string: baseURL) else {
      fatalError("Failed to convert baseURL to a URL")
    }
    self.resourceURL =
      resourceURL.appendingPathComponent(resourcePath)
  }
}

下面是这个的作用:

  1. 定义一个通用的ResourceRequest类型,其通用参数必须符合Codable
  2. 设置API的基本URL。现在使用localhost

Note

这需要你在应用程序的Info.plist中禁用ATS(App Transport Security)。这已经在样本项目中为你设置好了。

  1. 初始化特定资源的URL

接下来,你需要一种方法来获取特定资源类型的所有实例。在init(resourcePath:)之后添加以下方法:

// 1
func getAll(
  completion: @escaping
    (Result<[ResourceType], ResourceRequestError>) -> Void
) {
  // 2
  let dataTask = URLSession.shared
    .dataTask(with: resourceURL) { data, _, _ in
      // 3
      guard let jsonData = data else {
        completion(.failure(.noData))
          return
      }
      do {
        // 4
        let resources = try JSONDecoder()
          .decode(
            [ResourceType].self,
            from: jsonData)
        // 5
        completion(.success(resources))
      } catch {
        // 6
        completion(.failure(.decodingError))
      }
    }
    // 7
    dataTask.resume()
}

下面是这个的作用:

  1. 定义一个函数,从API中获取资源类型的所有值。这需要一个完成闭包作为参数,它使用SwiftResult类型。
  2. 用资源的URL创建一个数据任务。
  3. 确保响应返回一些数据。否则,用适当的.failure情况调用completion(_:)关闭。
  4. 将响应数据解码为一个ResourceType数组。
  5. 调用completion(_:)闭包的.success情况,并返回ResourceType数组。
  6. 捕获任何错误并返回正确的失败案例。
  7. 启动dataTask

打开AcronymsTableViewController.swift,在// MARK: - Properties下添加以下内容:

// 1
var acronyms: [Acronym] = []
// 2
let acronymsRequest =
  ResourceRequest<Acronym>(resourcePath: "acronyms")

下面是这个的作用:

  1. 声明一个首字母缩写的数组。这些是表格中显示的首字母缩写。
  2. 为缩写创建一个ResourceRequest

获取首字母缩写

每当视图出现在屏幕上时,表格视图控制器会调用refresh(_:)。将refresh(_:)的实现替换为以下内容:

// 1
acronymsRequest.getAll { [weak self] acronymResult in
  // 2
  DispatchQueue.main.async {
    sender?.endRefreshing()
  }

  switch acronymResult {
  // 3
  case .failure:
    ErrorPresenter.showError(
      message: "There was an error getting the acronyms", 
      on: self)
  // 4
  case .success(let acronyms):
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.acronyms = acronyms
      self.tableView.reloadData()
    }
  }
}

下面是这个的作用:

  1. 调用getAll(completion:)来获取所有的首字母缩写。这将返回完成度闭包中的一个结果。
  2. 随着请求的完成,在刷新控件上调用endRefreshing()
  3. 如果获取失败,使用ErrorPresenter工具来显示一个带有适当错误信息的警报控制器。
  4. 如果获取成功,从结果中更新acronyms数组并重新加载表。

显示缩略语

还是在AcronymsTableViewController.swift中,更新tableView(_:numberOfRowsInSection:),将return 1替换为以下内容,以返回正确的缩写词数量:

return acronyms.count

接下来,更新tableView(_:cellForRowAt:)以显示表格中的缩写。在return cell前添加以下内容:

let acronym = acronyms[indexPath.row]
cell.textLabel?.text = acronym.short
cell.detailTextLabel?.text = acronym.long

这将标题和副标题文本设置为每个单元格的首字母缩写短和长属性。

建立并运行,你会看到你的表被数据库中的首字母缩写填充:

img

查看用户

查看所有的用户遵循一个类似的模式。大部分的视图控制器已经设置好了。打开UsersTableViewController.swift,在下面:

var users: [User] = []

增加以下内容:

let usersRequest = 
  ResourceRequest<User>(resourcePath: "users")

这将创建一个ResourceRequest来从API获得用户。接下来,将refresh(_:)的实现替换为以下内容:

// 1
usersRequest.getAll { [weak self] result in
  // 2
  DispatchQueue.main.async {
    sender?.endRefreshing()
  }
  switch result {
  // 3
  case .failure:
    ErrorPresenter.showError(
      message: "There was an error getting the users",
      on: self)
  // 4
  case .success(let users):
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.users = users
      self.tableView.reloadData()
    }
  }
}

下面是这个的作用:

  1. 调用getAll(completion:)来获取所有的用户。这将在完成度闭包中返回一个结果。
  2. 随着请求的完成,在刷新控件上调用endRefreshing()
  3. 如果获取失败,使用ErrorPresenter工具来显示一个带有适当错误信息的警告视图。
  4. 如果获取成功,从结果中更新users数组并重新加载表。

建立并运行。进入Users标签,你会看到该表被数据库中的用户填充:

img

查看类别

按照类似的模式来查看所有的类别。打开CategoriesTableViewController.swift,在下面:

var categories: [Category] = []

增加下面的内容:

let categoriesRequest =
  ResourceRequest<Category>(resourcePath: "categories")

这设置了一个ResourceRequest来从API中获取类别。接下来,将refresh(_:)的实现替换为以下内容:

// 1
categoriesRequest.getAll { [weak self] result in
  // 2
  DispatchQueue.main.async {
    sender?.endRefreshing()
  }
  switch result {
  // 3
  case .failure:
    let message = "There was an error getting the categories"
    ErrorPresenter.showError(message: message, on: self)
  // 4
  case .success(let categories):
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.categories = categories
      self.tableView.reloadData()
    }
  }
}

下面是这个的作用:

  1. 调用getAll(completion:)来获取所有的类别。这将返回完成度闭包中的一个结果。
  2. 随着请求的完成,在刷新控件上调用endRefreshing()
  3. 如果获取失败,使用ErrorPresenter工具来显示一个带有适当错误信息的警告视图。
  4. 如果获取成功,从结果中更新categories数组,并重新加载表。

建立并运行。进入Categories选项卡,你会看到表格被TIL应用程序的类别填充:

img

创建用户

TIL API中,你必须有一个用户来创建缩略语,所以先设置好这个流程。打开ResourceRequest.swift,在ResourceRequest的底部添加一个新方法来保存一个模型:

// 1
func save<CreateType>(
  _ saveData: CreateType,
  completion: @escaping 
    (Result<ResourceType, ResourceRequestError>) -> Void
) where CreateType: Codable {
  do {
    // 2
    var urlRequest = URLRequest(url: resourceURL)
    // 3
    urlRequest.httpMethod = "POST"
    // 4
    urlRequest.addValue(
      "application/json",
      forHTTPHeaderField: "Content-Type")
    // 5
    urlRequest.httpBody =
      try JSONEncoder().encode(saveData)
    // 6
    let dataTask = URLSession.shared
      .dataTask(with: urlRequest) { data, response, _ in
        // 7
        guard
          let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200,
          let jsonData = data
          else {
            completion(.failure(.noData))
            return
        }

        do {
          // 8
          let resource = try JSONDecoder()
            .decode(ResourceType.self, from: jsonData)
          completion(.success(resource))
        } catch {
          // 9
          completion(.failure(.decodingError))
        }
      }
    // 10
    dataTask.resume()
  // 11
  } catch {
    completion(.failure(.encodingError))
  }
}

以下是新方法的作用:

  1. 声明一个方法save(_:completion:),它接收一个要保存的通用Codable类型和一个接收保存结果的完成处理器。这使用了一个通用类型而不是ResourceRequest,因为保存Acronym的API使用了CreateAcronymData而不是Acronym
  2. 为保存请求创建一个URLRequest
  3. 设置请求的HTTP方法为POST
  4. 设置请求的Content-Type头为application/json,以便API知道有JSON数据需要解码。
  5. 将请求正文设置为编码后的保存数据。
  6. 用请求创建一个数据任务。
  7. 确保有一个HTTP响应。检查响应状态是否为200 OK,这是API在成功保存时返回的代码。确保响应体中有数据。
  8. 将响应体解码为资源类型。用一个成功的结果调用完成处理程序。
  9. 捕获一个解码错误并以失败的结果调用完成处理程序。
  10. 启动数据任务。
  11. 捕获任何来自try JSONEncoder().encode(resourceToSave)的编码错误,并以失败的结果调用完成处理程序。

接下来,打开CreateUserTableViewController.swift,将save(_:)的实现替换为以下内容:

// 1
guard 
  let name = nameTextField.text,
  !name.isEmpty 
  else {
    ErrorPresenter
      .showError(message: "You must specify a name", on: self)
    return
}

// 2
guard 
  let username = usernameTextField.text,
  !username.isEmpty 
  else {
    ErrorPresenter.showError(
      message: "You must specify a username",
      on: self)
    return
}

// 3
let user = User(name: name, username: username)
// 4
ResourceRequest<User>(resourcePath: "users")
  .save(user) { [weak self] result in
    switch result {
    // 5
    case .failure:
      let message = "There was a problem saving the user"
      ErrorPresenter.showError(message: message, on: self)
    // 6
    case .success:
      DispatchQueue.main.async { [weak self] in
        self?.navigationController?
          .popViewController(animated: true)
      }
    }
}

下面是这个的作用:

  1. 确保名称文本字段包含一个非空的字符串。
  2. 确保用户名文本字段包含一个非空的字符串。
  3. 根据提供的数据创建一个新的用户。
  4. User创建一个ResourceRequest并调用save(_:completion:)
  5. 如果保存失败,显示一个错误信息。
  6. 如果保存成功,返回到之前的视图:用户表。

建立并运行。进入Users标签,点击+按钮,打开创建用户界面。填写两个字段,然后点击Save

如果保存成功,屏幕关闭,新用户出现在表格中:

img

创建首字母缩写词

现在你有了创建用户的能力,是时候实现创建缩略语了。毕竟,如果你不能向它添加缩写词,那么一个缩写词词典应用程序有什么用呢。

选择用户

当你用API创建一个首字母缩写时,你必须提供一个用户ID。要求用户记住并输入一个UUID并不是一个好的用户体验! iOS应用程序应该允许用户通过名字来选择用户。

打开CreateAcronymTableViewController.swift,在viewDidLoad()下创建一个新方法,在创建首字母表的User单元格中填入一个默认用户:

func populateUsers() {
  // 1
  let usersRequest =
    ResourceRequest<User>(resourcePath: "users")

  usersRequest.getAll { [weak self] result in
    switch result {
    // 2
    case .failure:
      let message = "There was an error getting the users"
      ErrorPresenter
        .showError(message: message, on: self) { _ in
          self?.navigationController?
            .popViewController(animated: true)
        }
    // 3
    case .success(let users):
      DispatchQueue.main.async { [weak self] in
        self?.userLabel.text = users[0].name
      }
      self?.selectedUser = users[0]
    }
  }
}

下面是这个的作用:

  1. API中获取所有用户。
  2. 如果请求失败,显示一个错误。当用户解散警报控制器时,从创建缩写视图返回。这使用了showError(message:on:dismissAction:)上的dismissAction
  3. 如果请求成功,将用户字段设置为第一个用户的名字并更新selectedUser

viewDidLoad()的末尾添加以下内容:

populateUsers()

你的应用程序的用户可以点击USER单元格来选择一个不同的用户来创建一个缩写。这个手势会打开Select A User屏幕。

打开SelectUserTableViewController.swift。在下面的内容:

var users: [User] = []

添加下面的内容:

var selectedUser: User

这个属性持有被选中的用户。接下来,在init?(coder:selectedUser:)中,在super.init(coder:coder)之前将提供的用户分配给新的属性:

self.selectedUser = selectedUser

接下来,在loadData()中添加以下实现,这样当视图加载时,表格就会显示用户:

// 1
let usersRequest =
  ResourceRequest<User>(resourcePath: "users")

usersRequest.getAll { [weak self] result in
  switch result {
  // 2
  case .failure:
    let message = "There was an error getting the users"
    ErrorPresenter
      .showError(message: message, on: self) { _ in
        self?.navigationController?
          .popViewController(animated: true)
      }
    // 3
  case .success(let users):
    self?.users = users
    DispatchQueue.main.async { [weak self] in
      self?.tableView.reloadData()
    }
  }
}

下面是这个的作用:

  1. API中获取所有的用户。
  2. 如果请求失败,显示一个错误信息。一旦用户在警报上点击解散,就返回到之前的视图。
  3. 如果请求成功,保存用户并重新加载表的数据。

tableView(_:cellForRowAt:)中,在return cell前添加以下内容:

if user.name == selectedUser.name {
  cell.accessoryType = .checkmark
} else {
  cell.accessoryType = .none
}

这是将当前单元格与当前选择的用户进行比较。如果它们是相同的,在该单元格上设置一个复选标记。

当用户点击一个单元格时,SelectUserTableViewController使用一个unwind segue来导航回CreateAcronymTableViewController

SelectUserTableViewController中添加以下prepare(for:)的实现,以设置segue的选定用户:

// 1
if segue.identifier == "UnwindSelectUserSegue" {
  // 2
  guard
    let cell = sender as? UITableViewCell,
    let indexPath = tableView.indexPath(for: cell)
    else {
      return
  }
  // 3
  selectedUser = users[indexPath.row]
}

下面是这个的作用:

  1. 验证这是否是预期的转场。
  2. 获取触发分离的单元格的索引路径。
  3. selectedUser更新为被点击的单元格的用户。

解除分离调用CreateAcronymTableViewController中的updateSelectedUser(_:)。打开CreateAcronymTableViewController.swift,为updateSelectedUser(_:)添加以下实现:

// 1
guard let controller = segue.source 
  as? SelectUserTableViewController 
  else {
    return
}
// 2
selectedUser = controller.selectedUser
userLabel.text = selectedUser?.name

下面是这个的作用:

  1. 确保segue来自SelectUserTableViewController
  2. 用新的值更新selectedUser,并更新用户标签。

最后,将makeSelectUserViewController(_:)的实现替换为以下内容:

guard let user = selectedUser else {
  return nil
}
return SelectUserTableViewController(
  coder: coder,
  selectedUser: user)

这确保我们有一个被选中的用户,并为该用户创建一个SelectUserTableViewController。当用户点击用户字段时,应用程序使用@IBSegueAction来创建选择用户屏幕。

建立并运行。在缩略语标签中,点击+,调出Create An Acronym视图。点击用户行,应用程序打开Select A User视图,允许你选择一个用户。

当你点击一个用户时,该用户就被设置在Create An Acronym页面上:

img

保存缩略语

现在你可以成功地选择一个用户,是时候实现将新的首字母缩写保存到数据库中了。将CreateAcronymTableViewController.swift中的save(_:)的实现改为以下内容:

// 1
guard
  let shortText = acronymShortTextField.text,
  !shortText.isEmpty 
  else {
    ErrorPresenter.showError(
      message: "You must specify an acronym!",
      on: self)
    return
}
guard
  let longText = acronymLongTextField.text,
  !longText.isEmpty 
  else {
    ErrorPresenter.showError(
      message: "You must specify a meaning!",
      on: self)
    return
}
guard let userID = selectedUser?.id else {
  let message = "You must have a user to create an acronym!"
  ErrorPresenter.showError(message: message, on: self)
  return
}

// 2
let acronym = Acronym(
  short: shortText,
  long: longText,
  userID: userID)
let acronymSaveData = acronym.toCreateData()
// 3
ResourceRequest<Acronym>(resourcePath: "acronyms")
  .save(acronymSaveData) { [weak self] result in
    switch result {
    // 4
    case .failure:
      let message = "There was a problem saving the acronym"
      ErrorPresenter.showError(message: message, on: self)
    // 5
    case .success:
      DispatchQueue.main.async { [weak self] in
        self?.navigationController?
          .popViewController(animated: true)
      }
    }
}

以下是保存首字母缩写的步骤:

  1. 确保用户已经填写了首字母缩写和含义。检查所选用户不是nil,并且用户有一个有效的ID
  2. 根据提供的数据创建一个新的Acronym。使用toCreateData()辅助方法将缩写转换为CreateAcronymData
  3. Acronym创建一个ResourceRequest并使用创建的数据调用`save(_:)'。
  4. 如果保存请求失败,显示一个错误信息。
  5. 如果保存请求成功,返回到之前的视图:缩写表。

建立并运行。在Acronyms标签上,点击+。填写创建首字母缩写的字段,然后点Save

保存的首字母缩写出现在表格中:

img

接下来去哪?

在本章中,你学习了如何从一个iOS应用程序与API进行交互。你看到了如何创建不同的模型并从API中检索它们。你还学习了如何以一种用户友好的方式管理所需的关系。

下一章将在此基础上,查看关于一个缩写的细节。你还将学习如何实现其余的CRUD操作。最后,你将看到如何在类别和首字母缩写之间建立关系。