大家好,我是编程小6,很高兴遇见你,有问题可以及时留言哦。
本文译自 Enum-Driven TableView Development,原作者是 Keegan Rush
材料下载
UITableView 是 iOS 开发里最基本的东西,一个简单而又整洁的控件。但 UITableView 的背后还隐藏了很多复杂性:在正确的时间显示等待小菊花、处理 error、等待服务回调并在得到结果的时候显示结果。
在这篇教程里,你会学习如何用枚举来驱动 TableView 的开发以便应对上述问题。
为了更好地学习这门技术,你需要重构一个现有的 app,这个 app 叫做 Chirper。在这个过程中,你会学习如下内容:
ViewController
的状态。本教程需要你对
UITableView
和 Swift 枚举(enum)有所了解。否则可以先参考 iOS 和 Swift tutorials。
需要我们重构的 Chirper app 显示了一列鸟类叫声,这个列表支持搜索,其数据来自 xeno-canto public API。
如果在 app 内搜索某种鸟类,它会为你显示一列匹配搜索关键词的录音。点击每行的按钮就可以播放对应的录音。
使用教程顶端或底端的材料下载 按钮来下载初始项目。下载好之后,在 Xcode 里打开这个初始项目。
一个设计良好的 table view 有四种不同状态:
填充是最常见的状态,但其他状态也一样关键。应该始终让用户了解 app 的状态,也就是说在加载状态的时候显示等待小菊花、在没有数据的时候提醒用户该如何操作以及在出错的时候显示友好的错误消息。
先打开 MainViewController.swift 看一下代码。这个 view controller 基于属性状态做了一些很重要的事:
isLoading
设置为 true
时显示 loading indicator。error
不为 nil
时告诉用户出错了。recordings
数组为 nil
或空数组,view 会显示消息提示用户搜索其他关键词。tableView.tableFooterView
设置为正确的 view。在修改代码时不仅要将上面这些东西牢记于心,未来给 app 增加功能时这个模式还会变得更加复杂。
如果在 MainViewController.swift 里搜索,你会发现在这个文件中并没有出现 state。
state 就在那里,但定义不明确。欠缺定义的状态会让人费解,这段代码是在做什么?属性变化之后应该如何回应?
如果 isLoading
是 true
,app 就应该显示加载状态。如果 error
不是 nil,app 就应该显示错误状态。但如果这两种情况同时存在呢?app 此时就会处于无效状态。 MainViewController
的状态定义不清晰,会导致其在无效或不确定状态时出现 bug。
MainViewController
需要更好地管理状态,管理状态的方式应该是:
下面我们会重构 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
属性被设置为 true
,tableView.tableFooterView
被设置为 loading view。在 loadRecordings
的开头移除这两行:
isLoading = true
tableView.tableFooterView = loadingView
将其替换为:
state = .loading
然后,移除 fetchRecordings
回调 block 中的 self.isLoading = false
。loadRecordings()
应该如下所示:
@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,应该如下所示:
虽然我们设置了 state
属性,但还没有用到它。tableView.tableFooterView
需要反映当前状态。在 MainViewController
里创建一个叫做setFooterView()
的新方法。
func setFooterView() {
switch state {
case .loading:
tableView.tableFooterView = loadingView
default:
break
}
}
现在回到 loadRecordings()
,在设置 state 为 .loading
的后面添加如下代码:
setFooterView()
构建并运行 app。
现在如果将 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 与网络的连接。再次尝试加载数据,就会看到如下界面:
在 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 属性进行操作。
下面我们需要根据当前状态设置正确的 table footer view。给 setFooterView()
中的 switch 添加如下两个 case:
case .empty:
tableView.tableFooterView = emptyView
case .populated:
tableView.tableFooterView = nil
现在不再需要 default
case 了,移除掉它。
构建并运行 app,看看有什么变化:
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])
不需要再用可选型了!
MainViewController
的 recordings
属性也用不到了,删掉它以及 loadRecordings()
中对它的引用。
构建并运行 app。
所有状态现在都应该可以正常运行了。我们移除了 isLoading
、error
、以及 recordings
属性,替换为唯一的定义清晰的 state
属性。干得漂亮!
现在我们已经从 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,查看是否正常:
使用 app 进行搜索时,虽然 API 会返回很多结果,但一次并不会返回全部结果。 例如,使用 Chirper 搜索某种常见的鸟(比如 parrot),一般会返回很多结果:
但是不对啊,只有 50 条鹦鹉的记录?
xeno-canto API 的限制是每次 500 条。这个项目在 NetworkingService.swift
中将其限制为 50 条,以便示范。
即便接收了前 500 条数据,后面的结果怎么获取呢?这个 API 通过分页(pagination) 来实现这一点。
当我们在 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 条数据)。
看一下 MainViewController.loadRecordings()
,在它调用 networkingService.fetchRecordings()
时 page
参数被写死为 1
。我们需要如下操作:
paging
的新状态。networkingService.fetchRecordings
的 response 表示还有更多页结果,就把 state 设置为 .paging
。.paging
就加载下一页结果。用户滑动到底部时,app 会抓取更多结果。这样就给人一种无限滚动列表的感觉——就像社交软件那样。酷吧?
先给 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
在 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:
如果搜索结果有多页,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(_: cellForRowAt:)
里 return
语句的前面。
if case .paging(_, let nextPage) = state,
indexPath.row == state.currentRecordings.count - 1 {
loadPage(nextPage)
}
如果当前 state 是 .paging
,并且当前要显示的行数和 currentRecordings
数据的最后一个结果索引相同,就加载下一页。
构建并运行 app:
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 可以看到区别:
如果希望下载最终完成的项目,使用教程顶部或底部的材料下载按钮。
在这篇教程里你重构了 app,用更加清晰的方式来处理复杂度。用简单、清晰的 Swift 枚举替换了一大堆容易出错、定义不明确的状态。我们甚至添加了一个新功能来测试我们用枚举驱动的 table view:分页。
在重构代码时记得要进行测试,以便确保没有对某些功能造成破坏,最好是单元测试。如果想进一步学习,可以看看这篇教程 iOS Unit Testing and UI Testing。
现在你已经学习了如何在 app 中使用分页 API,下面你还可以学习如何构建一个实际的 API。Server Side Swift with Vapor 视频课程是一个不错的起点。
喜欢这篇教程吗?希望对你以后 app 的状态管理有所帮助!如果有任何问题或想法,欢迎在下方留言。
材料下载