原文: https://www.raywenderlich.com/188692/enum-driven-tableview-development
在iOS开发中,有没有比UITableView更基础的东西?这是一个简单,干净的控制。不幸的是,很多复杂性都在于:你的代码需要在正确的时间显示加载指示器,处理错误,等待服务呼叫完成并在进入时显示结果。
在本教程中,您将学习如何使用Enum驱动的TableView开发来管理这种复杂性。
要遵循这一技巧,您将重构一个名为Chirper的现有应用程序。一路走来,你会学到以下几点:
- 如何使用枚举来管理你的状态
ViewController
。 - 在视图中反映用户状态的重要性。
- 状态不佳的危险。
- 如何使用属性观察来保持视图是最新的。
- 如何使用分页来模拟无尽的搜索结果列表。
入门
您将为本教程重构的Chirper应用程序提供了来自xeno-canto公共API的可搜索鸟类声音列表。
如果您在应用内搜索鸟类,它会显示与您的搜索查询匹配的记录列表。您可以通过点击每一行中的按钮来播放录音。
要下载初学者项目,请使用本教程顶部或底部的下载材料按钮。一旦你下载了这个,在Xcode中打开starter项目。
不同的状态
精心设计的tableview有四种不同的状态:
- 正在加载:该应用正在忙于提取新数据。
- 错误:服务调用或其他操作失败。
- 空:服务呼叫没有返回任何数据。
- 已填充:应用程序已检索到要显示的数据。
填充状态是最明显的,但其他状态也很重要。您应该始终让用户知道应用程序状态,这意味着在加载状态期间显示加载指示符,告诉用户如何处理空数据集并在出现问题时显示友好的错误消息。
首先,打开MainViewController.swift来查看代码。视图控制器根据其某些属性的状态执行一些非常重要的事情:
- 该视图在
isLoading
设置为时显示加载指示器true
。 - 该视图告诉用户出错当
error
不是nil
时。 - 如果
recordings
数组为nil
或为空,视图将显示一条消息,提示用户搜索不同的内容。 - 如果以前的条件都不成立,则视图将显示结果列表。
-
tableView.tableFooterView
设置为当前状态的正确视图。
修改代码时需要记住很多事项。而且,更糟糕的是,当您通过应用程序添加更多功能时,此模式变得更加复杂。
定义不清的状态
通过MainViewController.swift搜索,您会看到单词状态在任何地方都没有提及。
状态在那里,但它没有明确的定义。这种定义不清的状态使得很难理解代码在做什么以及它如何响应其属性的变化。
无效状态
如果isLoading
是true
,应用程序应显示加载状态。如果error
非零,应用程序应显示错误状态。但是,如果这两个条件都满足会发生什么?你不知道。该应用程序将处于无效状态。
MainViewController
没有明确定义它的状态,这意味着它可能由于无效或不确定状态而存在一些错误。
一个更好的选择
MainViewController
需要更好的方式来管理其状态。它需要一种技术:
- 容易明白
- 易于维护
- 不易受bug影响
在接下来的步骤中,您将重构MainViewController
使用enum
以管理其状态。
重构到一个状态枚举
在MainViewController.swift中,在类的声明上添加它:
enum State {
case loading
case([ Recording ])
case empty
case error( Error)
}
这是您将用来明确定义视图控制器状态的枚举。接下来,添加一个属性MainViewController
来设置状态:
var state = State.loading
构建并运行应用程序以查看它仍然有效。你还没有对行为做任何改变,所以一切都应该是一样的。
重构加载状态
你要做的第一个改变就是删除isLoading
属性,以支持状态枚举。在loadRecordings()
,该isLoading
属性设置为true
。该tableView.tableFooterView
设置为加载视图。从以下两行开始删除这两行loadRecordings()
:
isLoading = true
tableView.tableFooterView = loadingView
用下面的代替它:
state = .loading
然后,self.isLoading = false
在fetchRecordings
完成块内移除。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
属性。你不会再需要它了。
构建并运行应用程序。你应该有以下看法:
该state
属性已设置,但你没有做任何事情。tableView.tableFooterView
需要反映当前状态。在MainViewController
named中创建一个新方法setFooterView()
。
func setFooterView() {
switch state {
case .loading:
tableView.tableFooterView = loadingView
default:
break
}
}
现在,回到loadRecordings()
。将状态设置为后.loading
,添加以下内容:
setFooterView()
构建并运行应用程序。
现在,当您更改状态setFooterView()
为调用加载并显示进度指示器时。做得好!
重构错误状态
loadRecordings()
从中获取记录NetworkingService
。它接收来自networkingService.fetchRecordings()
和调用的响应update(response:)
,这会更新应用程序的状态。
在内部update(response:)
,如果响应有错误,它会在错误的描述中设置错误errorLabel
。将tableFooterView
被设置为errorView
,其中包含errorLabel
。在以下两行中找到这两行update(response:)
:
errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView
将它们替换为:
state = .error(error)
setFooterView()
在这里setFooterView()
,为该error
州增添一个新案例:
case .error(let error):
errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView
视图控制器不再需要它的error: Error?
属性。你可以删除它。在里面update(response:)
,你需要删除error
你刚刚删除的属性的引用:
error = response.error
一旦你删除了该行,构建并运行该应用程序。
您会看到加载状态仍然正常。但是,你如何测试错误状态?最简单的方法是将您的设备与互联网断开连接; 如果您在Mac上运行模拟器,请立即断开Mac与互联网的连接。这是应用程序尝试加载数据时应该看到的内容:
重构空和填充状态
if-else
开始就有一条相当长的链条update(response:)
。要清理它,请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
块下面添加这个:
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:)
了视图控制器的状态属性。
设置页脚视图
接下来,您需要为当前状态设置正确的表格页脚视图。将这两个例子添加到switch语句里面setFooterView()
:
case .empty:
tableView.tableFooterView = emptyView
case .populated:
tableView.tableFooterView = nil
该应用不再使用该default
案例,因此请将其移除。
构建并运行应用程序以查看会发生什么情况:
从状态获取数据
该应用程序不再显示数据。视图控制器的recordings
属性填充表视图,但它没有被设置。表视图需要state
现在从属性中获取其数据。将这个计算属性添加到State
枚举声明中:
var currentRecordings: [Recording] {
switch self {
case .populated(let recordings):
return recordings
default:
return []
}
}
您可以使用此属性填充表格视图。如果状态是.populated
,它使用填充的记录; 否则,它返回一个空数组。
在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()
中的最终引用一起删除。
构建并运行应用程序。
所有的状态都应该开始工作。你已经移除了isLoading
,error
和recordings
属性,以支持一个明确定义的状态属性。做得好!
与属性观察者保持同步
您已经从视图控制器中删除了定义不明的状态,现在您可以轻松地从状态属性中辨别视图的行为。此外,无论是在加载和错误状态都是不可能的 - 这意味着没有无效状态的机会。
不过,还是有一个问题。当你更新状态属性的值时,你必须记得调用setFooterView()
和tableView.reloadData()
。如果你不这样做,视图将不会更新以正确反映它所处的状态。如果状态改变时刷新了所有内容,不是更好吗?
这是使用didSet
属性观察的好机会。您使用属性观察器来响应属性值的更改。如果您想重新加载表格视图并在每次state
设置属性时设置页脚视图,则需要添加didSet
属性观察器。
var state = State.loading
用这个替换声明:
var state = State.loading {
didSet {
setFooterView()
tableView.reloadData()
}
}
当值state
改变时,didSet
属性观察者将会触发。它调用setFooterView()
并tableView.reloadData()
更新视图。
删除所有其他调用setFooterView()
和tableView.reloadData()
; 每个都有四个。你可以找到他们loadRecordings()
和update(response:)
。他们不再需要了。
构建并运行应用程序以检查一切仍然有效:
添加分页
当您使用应用程序进行搜索时,该API有许多结果,但不会一次返回所有结果。
例如,在Chirper搜寻一种常见的鸟类物种,这是你期望看到许多结果的东西 - 比如说一只鹦鹉:
这是不对的。只有50个鹦鹉录音?
xeno-canto API一次将结果限制为500。您的项目应用程序会将这个数量减少到50个NetworkingService.swift
,这样就可以使这个例子易于使用。
如果您只收到前500个结果,那么您如何获得其余的结果?您用来检索录音的API通过分页来完成。
API如何支持分页
当你查询其中的xeno-canto API时NetworkingService
,这就是这个URL的样子:
http://www.xeno-canto.org/api/2/recordings?query=parrot
这次返回的结果仅限于前500个项目。这被称为第一页,其中包含项目1-500。接下来的500个结果将被称为第二页。您可以指定将哪个页面作为查询参数:
http://www.xeno-canto.org/api/2/recordings?query=parrot&page=2
注意&page=2
结尾; 此代码告诉API您想要第二页,其中包含项目501-1000。
在你的tableview中支持分页
看看MainViewController.loadRecordings()
。当它调用时networkingService.fetchRecordings()
,page
参数被硬编码为1
。这是你需要做的事情:
- 添加一个称为的新状态
paging
。 - 如果来自于的响应
networkingService.fetchRecordings
指示有更多页面,则将状态设置为.paging
。 - 当表视图即将显示表中的最后一个单元格时,如果状态为,则加载结果的下一页
.paging
。 - 将服务呼叫中的新录音添加到录音数组中。
当用户滚动到底部时,应用程序将获取更多结果。这给人以无限列表的印象 - 就像你在社交媒体应用中看到的一样。很酷,是吧?
添加新的paging状态
首先将新paging
案例添加到状态枚举中:
case paging([Recording], next: Int)
它需要跟踪显示的一系列记录,就像.populated
状态一样。它还需要跟踪API应该获取的下一页。
尝试构建并运行该项目,您会看到它不再编译。switch语句setFooterView
是详尽的,意思是它涵盖所有没有default
案例的案例。这很好,因为它可以确保在添加新状态时更新它。将此添加到switch语句中:
case .paging:
tableView.tableFooterView = loadingView
如果应用程序处于分页状态,它会在表格视图的末尾显示加载指示器。
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对当前查询的总页数是否小于当前页面。如果有更多的页面被提取,您将状态设置为.paging
。如果当前页面是最后一页或唯一页面,则将状态设置为.populated
。
构建并运行应用程序:
如果您搜索多个页面的内容,应用会在底部显示加载指示器。但是,如果您搜索只有一页结果的术语,那么您将得到.populated
没有加载指示符的通常状态。
您可以看到何时需要加载更多页面,但应用程序没有做任何加载它们的操作。你现在就解决这个问题。
加载下一页
当用户即将到达列表的末尾时,您希望应用程序开始加载下一页。首先,创建一个名为的新空方法loadPage
:
func loadPage(_ page: Int) {
}
这是当您要从中加载结果的特定页面时要调用的方法NetworkingService
。
记得loadRecordings()
默认情况下如何加载第一页?将所有代码loadRecordings()
移至loadPage(_:)
,除了状态设置为的第一行外.loading
。
接下来,更新fetchRecordings(matching: query, page: 1)
以使用页面参数,如下所示:
networkingService.fetchRecordings(matching: query, page: page)
loadRecordings()
现在看起来有点裸露。更新它以调用loadPage(_:)
,将页面1指定为要加载的页面:
@objc func loadRecordings() {
state = .loading
loadPage(1)
}
构建并运行应用程序:
如果什么都没有改变,你就在正确的轨道上!
tableView(_: cellForRowAt:)
在return
声明之前添加以下内容。
if case .paging(_, let nextPage) = state,
indexPath.row == state.currentRecordings.count - 1 {
loadPage(nextPage)
}
如果当前状态为.paging
,并且要显示的当前行与currentRecordings
数组中最后一个结果的索引相同,那么应该加载下一页。
构建并运行应用程序:
精彩!当加载指示符进入视图时,应用程序将获取下一页数据。但它不会将数据附加到当前的录音 - 它只是用新的录音替换当前的录音。
追加录音
在中update(response:)
,该newRecordings
数组正在用于视图的新状态。在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)
}
看,在状态数组的帮助下它有多容易?构建并运行应用程序以查看区别:
然后去哪儿?
想要学得更快?通过我们的视频课程节省时间
如果您想下载完成的项目,请使用本教程顶部或底部的“ 下载材料”按钮。
在本教程中,您重构了一个应用程序,以更清晰的方式处理复杂性。你用一个干净而简单的Swift枚举代替了很多容易出错,定义不明的状态。你甚至通过添加一个复杂的新特性来测试你的枚举驱动的表视图:分页。
当你重构代码时,重要的是测试一下东西以确保你没有破坏任何东西。单元测试对此很好。查看iOS单元测试和UI测试教程以了解更多信息。
下载资料