设计一个可扩展的列表页

开发故事

Sprint 1,产品给了你一个需求:

需要一个好友列表页

这是一个很简单的需求,你很快的创建了一个 UITableViewController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Member {
let name: String
}
class TableViewController: UITableViewController {
var members: [Member] = []
func loadData() {
members = ...
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return members.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
let member = members[indexPath.row]
cell.textLabel?.text = member.name
}
}

你用几十分钟做完了这个需求,然后划水到下班。

Sprint 2,产品决定:

好友列表页加上群组

于是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct Member {
let name: String
}
struct Group {
let name: String
}
class TableViewController: UITableViewController {
var members: [Member] = []
var groups: [Group] = []
func loadData() {
members = ...
groups = ...
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return members.count
} else {
return groups.count
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
if section == 0 {
let member = members[indexPath.row]
cell.textLabel?.text = member.name
} else {
let group = groups[indexPath.row]
cell.textLabel?.text = group.name
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if section == 0 {
return "群组"
} else {
return "好友"
}
}
}

你用几十分钟做完了这个需求,然后划水到下班。

Sprint 3,产品决定:

好友列表页加上搜索功能

这个时候你的代码已经变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if isSearching {
return searchResult.count
} else {
if section == 0 {
return members.count
} else {
return groups.count
}
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if isSearching {
return nil
} else {
if section == 0 {
return "群组"
} else {
return "好友"
}
}
}
...
...

代码中存着的问题:

  1. 充斥着各种 if-else 嵌套和重复代码。
  2. 且因为 members、groups、searchResult 是线程不安全的,如果这个好友列表要支持 socket 实时增删的话,很有可能因为 UITableView 数据与数据源不匹配而导致 crash。
  3. 不好写单元测试。

如何设计一个可扩展的列表页

一. 将数据抽象为 Section 和 Item

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Section {
enum SectionType {
case members
case groups
var title: {
swicth self {
case .members:
return "好友"
case .groups:
return "群组"
}
}
}
let type: SectionType
let items: [Any]
}

二. 创建角色 Builder,用来加工数据(省略 map 过程)

1
2
3
4
5
6
7
8
9
10
11
12
13
class Builder {
var sections: [Section] = []
func buildSection(data: [String: Any], complete: () -> Void) {
if let members = data["members"] {
sections.append(Section(type: .members, items: members))
}
if let groups = data["groups"] {
sections.append(Section(type: .groups, items: groups))
}
complete()
}
}

这个时候 ViewController 的代码就可以写成这样了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TableViewController: UITableViewController {
let builder = Builder()
func loadData() {
builder.buildSection(data: data) {
self.tableView.reloadData()
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
return builder.sections.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return builder.sections[section].items.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return builder.sections[section].type.title
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
这里怎么写呢?
}
}

三. 创建角色 Renderer,用来渲染数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Renderer {
func render(cell: UITableViewCell, from item: Item) {
switch item {
case is Member:
...
case is Group:
...
}
}
}
class TableViewController: UITableViewController {
...
let renderer = Renderer()
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
let item = builder.sections[indexPath.section].items[indexPath.row]
renderer.render(cell, from: item)
}
}

四. 加个需求,增加搜索模式

只需少量处理即可满足需求(省略 map 过程):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct Section {
enum SectionType {
case members
case groups
case search // add search type
var title: {
swicth self {
case .members:
return "好友"
case .groups:
return "群组"
case .search:
return nil
}
}
}
let type: SectionType
let items: [Any]
}
class Builder {
var sections: [Section] = []
func buildSection(data: [String: Any], complete: () -> Void) {
if let searchResult = data["searchResult"] {
sections.append(Section(type: .search, items: searchResult))
} else {
if let members = data["members"] {
sections.append(Section(type: .members, items: members))
}
if let groups = data["groups"] {
sections.append(Section(type: .groups, items: groups))
}
}
complete()
}
}

总结

分离出 Builder 与 Renderer 两个角色,来加工数据和渲染数据(可以让 ViewModel 同时充当这两个角色)。
对 ViewController 来说,不需要知道数据要怎么处理,它只管展示数据。
对 Renderer 来说,它也不需要知道业务逻辑,只需知道 Item 在特定的 Cell 上应该如何渲染。
对 Builder 来说,只在它的 -buildSection:data 方法里处理数据逻辑,所以能更好应对变化,如果将这个方法线程安全化,即可保证 UITableView 的数据与数据源匹配。且因为是单一的数据源 input,能更轻松的写测试,