用枚举来驱动 TableView 开发「终于解决」

Ios (32) 2023-03-24 21:40

大家好,我是编程小6,很高兴遇见你,有问题可以及时留言哦。

本文译自 Enum-Driven TableView Development,原作者是 Keegan Rush


材料下载

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第1张

UITableView 是 iOS 开发里最基本的东西,一个简单而又整洁的控件。但 UITableView 的背后还隐藏了很多复杂性:在正确的时间显示等待小菊花、处理 error、等待服务回调并在得到结果的时候显示结果。

在这篇教程里,你会学习如何用枚举来驱动 TableView 的开发以便应对上述问题。

为了更好地学习这门技术,你需要重构一个现有的 app,这个 app 叫做 Chirper。在这个过程中,你会学习如下内容:

  • 如何用枚举来管理 ViewController 的状态。
  • 在视图中反映状态的重要性。
  • 状态定义欠缺的危险性。
  • 如何使用属性观察者来持续更新视图。
  • 如何利用分页来模拟无限滑动的搜索结果。

本教程需要你对 UITableView 和 Swift 枚举(enum)有所了解。否则可以先参考 iOS 和 Swift tutorials。

开始

需要我们重构的 Chirper app 显示了一列鸟类叫声,这个列表支持搜索,其数据来自 xeno-canto public API。

如果在 app 内搜索某种鸟类,它会为你显示一列匹配搜索关键词的录音。点击每行的按钮就可以播放对应的录音。

使用教程顶端或底端的材料下载 按钮来下载初始项目。下载好之后,在 Xcode 里打开这个初始项目。

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第2张

不同的状态

一个设计良好的 table view 有四种不同状态:

  • Loading - 加载:app 正在获取新数据。
  • Error - 错误:服务调用或其他操作失败了。
  • Empty - 空白:服务调用没有返回数据。
  • Populated - 填充:app 已经取得了需要显示的数据。

填充是最常见的状态,但其他状态也一样关键。应该始终让用户了解 app 的状态,也就是说在加载状态的时候显示等待小菊花、在没有数据的时候提醒用户该如何操作以及在出错的时候显示友好的错误消息。

先打开 MainViewController.swift 看一下代码。这个 view controller 基于属性状态做了一些很重要的事:

  • isLoading 设置为 true 时显示 loading indicator。
  • error 不为 nil 时告诉用户出错了。
  • 如果 recordings 数组为 nil 或空数组,view 会显示消息提示用户搜索其他关键词。
  • 如果上述情况都不成立,view 就会显示结果数组。
  • 根据当前状态将 tableView.tableFooterView 设置为正确的 view。

在修改代码时不仅要将上面这些东西牢记于心,未来给 app 增加功能时这个模式还会变得更加复杂。

状态定义欠缺

如果在 MainViewController.swift 里搜索,你会发现在这个文件中并没有出现 state

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第3张

state 就在那里,但定义不明确。欠缺定义的状态会让人费解,这段代码是在做什么?属性变化之后应该如何回应?

无效状态

如果 isLoadingtrue,app 就应该显示加载状态。如果 error 不是 nil,app 就应该显示错误状态。但如果这两种情况同时存在呢?app 此时就会处于无效状态MainViewController 的状态定义不清晰,会导致其在无效或不确定状态时出现 bug。

更好的方案

MainViewController 需要更好地管理状态,管理状态的方式应该是:

  • 易于理解
  • 易于维护
  • 不受 bug 的影响

下面我们会重构 MainViewController,用 enum 来管理状态。

重构为枚举状态

MainViewController.swift 里,把下面这段代码添加到类声明的上方:

enum State {
  case loading
  case populated([Recording])
  case empty
  case error(Error)
}

这个枚举可以清晰地定义 view controller 的状态。下面给 MainViewController 添加一个属性来设置状态:

var state = State.loading

构建并运行 app,看看是否还能正常运行。由于我们还没有对行为进行修改,所以应该还和原来一摸一样。

重构加载状态

我们要做的第一个改动就是移除 isLoading 属性,变为使用状态枚举。在 loadRecordings() 里面,isLoading 属性被设置为 truetableView.tableFooterView 被设置为 loading view。在 loadRecordings 的开头移除这两行:

isLoading = true
tableView.tableFooterView = loadingView

将其替换为:

state = .loading

然后,移除 fetchRecordings 回调 block 中的 self.isLoading = falseloadRecordings() 应该如下所示:

@objc func loadRecordings() {
  state = .loading
  recordings = []
  tableView.reloadData()
	
  let query = searchController.searchBar.text
  networkingService.fetchRecordings(matching: query, page: 1) { [weak self] response in
	  
	guard let self = self else {
	  return
	}
	  
	self.searchController.searchBar.endEditing(true)
	self.update(response: response)
  }
}

现在可以移除 MainViewController 的 isLoading 属性了,后面不会再用到。

构建并运行 app,应该如下所示:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第4张

虽然我们设置了 state 属性,但还没有用到它。tableView.tableFooterView 需要反映当前状态。在 MainViewController 里创建一个叫做setFooterView() 的新方法。

func setFooterView() {
  switch state {
  case .loading:
	tableView.tableFooterView = loadingView
  default:
	break
  }
}

现在回到 loadRecordings(),在设置 state 为 .loading 的后面添加如下代码:

setFooterView()

构建并运行 app。

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第5张

现在如果将 state 更改为 loading,serFooterView() 就会被调用,等待小菊花也会显示出来。干得不错!

重构错误状态

loadRecordings()NetworkingService 抓取了 recordings。它会获得 networkingService.fetchRecordings() 的 response 并且调用 update(response:) 来更新 app 状态。

update(response:) 里面,如果 response 有 error,就把 errorLabel 设置为 error 的 description。tableFooterView 也会被设置为 errorView,其中包含了 errorLabel。在 update(response:) 里面找到下面两行:

errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView

替换为:

state = .error(error)
setFooterView()

setFooterView() 里,为 error 状态添加一个新的 case:

case .error(let error):
  errorLabel.text = error.localizedDescription
  tableView.tableFooterView = errorView

view controller 不再需要 error: Error? 属性了,现在可以移除掉它。还需要在 update(response:) 里面移除对 error 属性的引用:

error = response.error

移除掉上面那行之后,构建并运行 app。

可以看到,加载状态仍然在正常显示。但怎么测试错误状态呢?最简单的方式是断开设备与网络的连接;如果你是在 Mac 上运行模拟器,就断开 Mac 与网络的连接。再次尝试加载数据,就会看到如下界面:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第6张

重构空白和填充状态

update(response:) 的开头有一长串 if-else。我们需要把它整理一下,将 update(response:) 替换为如下内容:

func update(response: RecordingsResult) {
  if let error = response.error {
	state = .error(error)
	setFooterView()
	tableView.reloadData()
	return
  }
  
  recordings = response.recordings
  tableView.reloadData()
}

虽然这样破坏了填充空白状态,但不用担心,我们马上就会修复它们!

设置正确的状态

if let error = response.error block 的下方添加如下代码:

guard let newRecordings = response.recordings,
  !newRecordings.isEmpty else {
	state = .empty setFooterView() tableView.reloadData() return } 

在更新状态时不要忘记调用 setFooterView()tableView.reloadData(),否则就看不到变动了。

下面找到 update(response:) 里这行代码:

recordings = response.recordings

将其替换为:

state = .populated(newRecordings)
setFooterView()

这样就重构了 update(response:)——根据 view controller 的 state 属性进行操作。

设置 Footer View

下面我们需要根据当前状态设置正确的 table footer view。给 setFooterView() 中的 switch 添加如下两个 case:

case .empty:
  tableView.tableFooterView = emptyView
case .populated:
  tableView.tableFooterView = nil

现在不再需要 default case 了,移除掉它。

构建并运行 app,看看有什么变化:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第7张

从 State 中获取数据

app 现在不显示数据了。view controller 的 recordings 属性用于填充 table view,但这个属性并没有被设置。现在 table view 需要从 state 属性了获取数据。在 State enum 的声明中添加下面这个计算属性:

var currentRecordings: [Recording] {
  switch self {
  case .populated(let recordings):
	return recordings
  default:
	return []
  }
}

可以使用这个属性来填充 table view。如果 state 是 populated,就使用 populated 的 recordings,否则就返回空数组。

tableView(_:numberOfRowsInSection:),移除下面这行:

return recordings?.count ?? 0

将其替换为:

return state.currentRecordings.count

接下来,在 tableView(_:cellForRowAt:) 中移除这一块代码:

if let recordings = recordings {
  cell.load(recording: recordings[indexPath.row])
}

将其替换为:

cell.load(recording: state.currentRecordings[indexPath.row])

不需要再用可选型了!

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第8张

MainViewControllerrecordings 属性也用不到了,删掉它以及 loadRecordings() 中对它的引用。

构建并运行 app。

所有状态现在都应该可以正常运行了。我们移除了 isLoadingerror、以及 recordings 属性,替换为唯一的定义清晰的 state 属性。干得漂亮!

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第9张

使用属性观察者保持同步

现在我们已经从 view controller 中移除了定义不明确的属性,可以根据 state 属性来轻易辨别视图的行为。同时,也不会出现既是加载状态又是错误状态的情况了——也就意味着不会再出现无效状态。

但是,仍然有一个问题存在。在更新 state 属性的时候,必须要记得调用 setFooterView()tableView.reloadData()。否则 view 无法更新并反映当前状态。如果在 state 被改变时可以自动刷新多好?

这种情况非常适合使用属性观察者 didSet。属性观察者用于响应属性值的变更。如果每次 state 属性被设置后都希望 reload table view 并设置 footer view,就需要添加一个 didSet 属性观察者。

var state = State.loading 替换为如下代码:

var state = State.loading {
  didSet {
	setFooterView()
	tableView.reloadData()
  }
}

state 的值被改变后,didSet 属性观察期就会启动。它会调用 setFooterView()tableView.reloadData() 来更新 view。

移除 setFooterView()tableView.reloadData() 的其他所有调用(各四个)。可以在 loadRecordings()update(response:) 中找到它们。它们不会再被用到了。

构建并运行 app,查看是否正常:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第10张

添加分页

使用 app 进行搜索时,虽然 API 会返回很多结果,但一次并不会返回全部结果。 例如,使用 Chirper 搜索某种常见的鸟(比如 parrot),一般会返回很多结果:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第11张

但是不对啊,只有 50 条鹦鹉的记录?

xeno-canto API 的限制是每次 500 条。这个项目在 NetworkingService.swift 中将其限制为 50 条,以便示范。

即便接收了前 500 条数据,后面的结果怎么获取呢?这个 API 通过分页(pagination) 来实现这一点。

API 是如何支持分页的

当我们在 NetworkingService 中调用 xeno-canto API 时,URL 如下所示:

http://www.xeno-canto.org/api/2/recordings?query=parrot

如上调用返回的结果被限制为前 500 条数据,也被称为第一页,包含了 1-500 条数据。接下来的 500 条结果被称作第二页。利用 query 参数来指定想要的页码:

http://www.xeno-canto.org/api/2/recordings?query=parrot&page=2

注意最后的 &page=2;这一小段代码会告诉 API 我们想要第二页(包含 501-1000 条数据)。

让 Table View 支持分页

看一下 MainViewController.loadRecordings(),在它调用 networkingService.fetchRecordings()page 参数被写死为 1。我们需要如下操作:

  1. 添加一个叫做 paging 的新状态。
  2. 如果 networkingService.fetchRecordings 的 response 表示还有更多页结果,就把 state 设置为 .paging
  3. 在 table view 即将显示最后一个 cell 时,如果 state 是 .paging 就加载下一页结果。
  4. 把调用服务得到的新纪录添加到 recordings 数组。

用户滑动到底部时,app 会抓取更多结果。这样就给人一种无限滚动列表的感觉——就像社交软件那样。酷吧?

添加新的 Paging 状态

先给 state enum 添加一个新 case:

case paging([Recording], next: Int)

它需要追踪用于显示的 recordings 数组,和 .populated 状态一样。还需要追踪 API 需要抓取的下一页页码。

尝试构建并运行项目,你会发现编译不通过。setFooterView 里面的 switch 语句需要是详尽的,也就是包含每一种 case,并且不包含 default case。这样的好处是确保添加新状态时能即时更新 switch 语句。将如下代码添加到 switch 语句:

case .paging:
  tableView.tableFooterView = loadingView

如果 app 处于 paging 状态,就会在 table view 的末尾显示 loading indicator。

然而 state 的计算属性 currentRecordings 还不够详尽,给其 currentRecordings 里面的 switch 语句添加一个新 case:

case .paging(let recordings, _):
  return recordings

把状态设置为 .paging

update(response:) 里面,将 state = .populated(newRecordings 替换为如下代码:

if response.hasMorePages {
  state = .paging(newRecordings, next: response.nextPage)
} else {
  state = .populated(newRecordings)
}

response.hasMorePages 会判断 API 拥有的总页数是否小于当前页码。如果还有页面需要抓取,就将 state 设置为 .paging。如果当前页就是最后一页或唯一一页,就将 state 设置为 .populated

构建并运行 app:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第12张

如果搜索结果有多页,app 就会在底部显示 loading indicator。但如果搜索结果只有一页,就会和之前一样得到 .populated 状态,没有 loading indicator。

可以看到有多个待加载页面时,app 并不会去加载它们。现在我们要修复这个问题。

加载下一页

我们希望在用户快要滑到列表底部时,app 能够开始加载下一页。首先,创建一个空方法,叫做 loadPage

func loadPage(_ page: Int) {
}

后面如果希望从 NetworkingService 加载某特定页的结果,就需要调用这个方法。

还记得 loadRecordings() 是如何默认加载第一页的吗?把 loadRecordings 里面所有代码移动到 loadPage(_:) 中,除了第一行(把 state 设置为 .loading)。

下面更新 fetchRecordings(matching: query, page: 1) 以便使用 page 参数,如下所示:

networkingService.fetchRecordings(matching: query, page: page)

loadRecordings() 现在看起来少了点什么,改一下让它调用 loadPage(_:),将 page 指定为 1:

@objc func loadRecordings() {
  state = .loading
  loadPage(1)
}

构建并运行 app:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第13张

如果什么都没有发生,那就对咯!

把下面代码添加到 tableView(_: cellForRowAt:)return 语句的前面。

if case .paging(_, let nextPage) = state,
  indexPath.row == state.currentRecordings.count - 1 {
  loadPage(nextPage)
}

如果当前 state 是 .paging,并且当前要显示的行数和 currentRecordings 数据的最后一个结果索引相同,就加载下一页。

构建并运行 app:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第14张

Exciting!loading indicator 进入 view 的时候,app 就会抓取下一页数据。但目前并不是把数据附加上去——而是把当前记录替换为新加载的记录。

附加记录

update(response:) 里,newRecordings 数组目前被用于 view 的新状态。在 if response.hasMorePages 语句前面,添加如下代码:

var allRecordings = state.currentRecordings
allRecordings.append(contentsOf: newRecordings)

获取当前记录数组,然后把新纪录附加到该数组上。现在更新一下 if response.hasMorePages 语句,用 allRecordings 替代 newRecordings

if response.hasMorePages {
  state = .paging(allRecordings, next: response.nextPage)
} else {
  state = .populated(allRecordings)
}

可见有了 state 枚举的帮助,修改变得轻松惬意。构建并运行 app 可以看到区别:

用枚举来驱动 TableView 开发「终于解决」_https://bianchenghao6.com/blog_Ios_第15张

后续

如果希望下载最终完成的项目,使用教程顶部或底部的材料下载按钮。

在这篇教程里你重构了 app,用更加清晰的方式来处理复杂度。用简单、清晰的 Swift 枚举替换了一大堆容易出错、定义不明确的状态。我们甚至添加了一个新功能来测试我们用枚举驱动的 table view:分页。

在重构代码时记得要进行测试,以便确保没有对某些功能造成破坏,最好是单元测试。如果想进一步学习,可以看看这篇教程 iOS Unit Testing and UI Testing。

现在你已经学习了如何在 app 中使用分页 API,下面你还可以学习如何构建一个实际的 API。Server Side Swift with Vapor 视频课程是一个不错的起点。

喜欢这篇教程吗?希望对你以后 app 的状态管理有所帮助!如果有任何问题或想法,欢迎在下方留言。

材料下载

发表回复