【译】处理 iOS 中复杂的 Table Views 并保持优雅

处理 iOS 中复杂的 Table Views 并保持优雅

Table views 是 iOS 开发中最重要的布局组件之一。一般咱们的一些最重要的页面都是 table views:feed 流,设置页,条目列表等。前端

每一个开发复杂的 table view 的 iOS 开发者都知道这样的 table view 会使代码很快就变的很粗糙。这样会产生包含大量 UITableViewDataSource 方法和大量 if 和 switch 语句的巨大的 view controller。加上数组索引计算和偶尔的越界错误,你会在这些代码中遭受不少挫折。react

我会给出一些我认为有益(至少在如今是有益)的原则,它们帮助我解决了不少问题。这些建议并不只仅针对复杂的 table view,对你全部的 table view 来讲它们都能适用。android

咱们来看一下一个复杂的 UITableView 的例子。ios

这些很棒的截屏插图来自 LazyAmphygit

这是 PokeBall,一个为 Pokémon 定制的社交网络。像其它社交网络同样,它须要一个 feed 流来显示跟用户相关的不一样事件。这些事件包括新的照片和状态信息,按天进行分组。因此,如今咱们有两个须要担忧的问题:一是 table view 有不一样的状态,二是多个 cell 和 section。github

1. 让 cell 处理一些逻辑

我见过不少开发者将 cell 的配置逻辑放到 cellForRowAt: 方法中。仔细思考一下,这个方法的目的是建立一个 cell。UITableViewDataSource 的目的是提供数据。数据源的做用不是用来设置按钮字体的。编程

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell

  let status = statuses[indexPath.row]
  cell.statusLabel.text = status.text
  cell.usernameLabel.text = status.user.name

  cell.statusLabel.font = .boldSystemFont(ofSize: 16)
  return cell
}复制代码

你应该把配置和设置 cell 样式的代码放到 cell 中。若是是一些在 cell 的整个生命周期都存在的东西,例如一个 label 的字体,就应该把它放在 awakeFromNib 方法中。swift

class StatusTableViewCell: UITableViewCell {

  @IBOutlet weak var statusLabel: UILabel!
  @IBOutlet weak var usernameLabel: UILabel!

  override func awakeFromNib() {
    super.awakeFromNib()

    statusLabel.font = .boldSystemFont(ofSize: 16)
  }
}复制代码

另外你也能够给属性添加观察者来设置 cell 的数据。后端

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name
  }
}复制代码

那样的话你的 cellForRow 方法就变得简洁易读了。数组

func tableView(_ tableView: UITableView, 
  cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.row]
  return cell
}复制代码

此外,cell 的设置逻辑如今被放置在一个单独的地方,而不是散落在 cell 和 view controller 中。

2. 让 model 处理一些逻辑

一般,你会用从某个后台服务中获取的一组 model 对象来填充一个 table view。而后 cell 须要根据 model 来显示不一样的内容。

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name

    if status.comments.isEmpty {
      commentIconImageView.image = UIImage(named: "no-comment")
    } else {
      commentIconImageView.image = UIImage(named: "comment-icon")
    }

    if status.isFavorite {
      favoriteButton.setTitle("Unfavorite", for: .normal)
    } else {
      favoriteButton.setTitle("Favorite", for: .normal)
    }
  }
}复制代码

你能够建立一个适配 cell 的对象,传入上文提到的 model 对象来初始化它,在其中计算 cell 中须要的标题,图片以及其它属性。

class StatusCellModel {

  let commentIcon: UIImage
  let favoriteButtonTitle: String
  let statusText: String
  let usernameText: String

  init(_ status: Status) {
    statusText = status.text
    usernameText = status.user.name

    if status.comments.isEmpty {
      commentIcon = UIImage(named: "no-comments-icon")!
    } else {
      commentIcon = UIImage(named: "comments-icon")!
    }

    favoriteButtonTitle = status.isFavorite ? "Unfavorite" : "Favorite"
  }
}复制代码

如今你能够将大量的展现 cell 的逻辑移到 model 中。你能够独立地实例化并单元测试你的 model 了,不须要在单元测试中作复杂的数据模拟和 cell 获取了。这也意味着你的 cell 会变得很是简单易读。

var model: StatusCellModel! {
  didSet {
    statusLabel.text = model.statusText
    usernameLabel.text = model.usernameText
    commentIconImageView.image = model.commentIcon
    favoriteButton.setTitle(model.favoriteButtonTitle, for: .normal)
  }
}复制代码

这是一种相似于 MVVM 的模式,只是应用在一个单独的 table view 的 cell 中。

3. 使用矩阵(可是把它弄得漂亮点)

Just a regular iOS developer making some table views
Just a regular iOS developer making some table views

分组的 table view 常常乱成一团。你见过下面这种状况吗?

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  switch section {
  case 0: return "Today"
  case 1: return "Yesterday"
  default: return nil
  }
}复制代码

这一大团代码中,使用了大量的硬编码的索引,而这些索引本应该是简单而且易于改变和转换的。对这个问题有一个简单的解决方案:矩阵。

记得矩阵么?搞机器学习的人以及一年级的计算机科学专业的学生会常常用到它,可是应用开发者一般不会用到。若是你考虑一个分组的 table view,其实你是在展现分组的列表。每一个分组是一个 cell 的列表。听起来像是一个数组的数组,或者说矩阵。

矩阵才是你组织分组 table view 的正确姿式。用数组的数组来替代一维的数组。 UITableViewDataSource 的方法也是这样组织的:你被要求返回第 m 组的第 n 个 cell,而不是 table view 的第 n 个 cell。

var cells: [[Status]] = [[]]

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.section][indexPath.row]
  return cell
}复制代码

咱们能够经过定义一个分组容器类型来扩展这个思路。这个类型不只持有一个特定分组的 cell,也持有像分组标题之类的信息。

struct Section {
  let title: String
  let cells: [Status]
}
var sections: [Section] = []复制代码

如今咱们能够避免以前 switch 中使用的硬编码索引了,咱们定义一个分组的数组并直接返回它们的标题。

func tableView(_ tableView: UITableView, 
  titleForHeaderInSection section: Int) -> String? {
  return sections[section].title
}复制代码

这样在咱们的数据源方法中代码更少了,相应地也减小了越界错误的风险。代码的表达力和可读性也变得更好。

4. 枚举是你的朋友

处理多种 cell 的类型有时候会很棘手。例如在某种 feed 流中,你不得不展现不一样类型的 cell,像是图片和状态信息。为了保持代码优雅以及避免奇怪的数组索引计算,你应该将各类类型的数据存储到同一个数组中。

然而数组是同质的,意味着你不能在同一个数组中存储不一样的类型。面对这个问题首先想到的解决方案是协议。毕竟 Swift 是面向协议的。

你能够定义一个 FeedItem 协议,而且让咱们的 cell 的 model 对象都遵照这个协议。

protocol FeedItem {}
struct Status: FeedItem { ... }
struct Photo: FeedItem { ... }复制代码

而后定义一个持有 FeedItem 类型对象的数组。

var cells: [FeedItem] = []复制代码

可是,用这个方案实现 cellForRowAt: 方法时,会有一个小问题。

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  if let model = cellModel as? Status {
    let cell = ...
    return cell
  } else if let model = cellModel as? Photo {
    let cell = ...
    return cell
  } else {
    fatalError()
  }
}复制代码

在让 model 对象遵照协议的同时,你丢失了大量你实际上须要的信息。你对 cell 进行了抽象,可是实际上你须要的是具体的实例。因此,你最终必须检查是否能够将 model 对象转换成某个类型,而后才能据此显示 cell。

这样也能达到目的,可是还不够好。向下转换对象类型内在就是不安全的,并且会产生可选类型。你也没法得知是否覆盖了全部的状况,由于有无限的类型能够遵照你的协议。因此你还须要调用 fatalError 方法来处理意外的类型。

当你试图把一个协议类型的实例转化成具体的类型时,代码的味道就不对了。使用协议是在你不须要具体的信息时,只要有原始数据的一个子集就能完成任务。

更好的实现是使用枚举。那样你能够用 switch 来处理它,而当你没有处理所有状况时代码就没法编译经过。

enum FeedItem {
  case status(Status)
  case photo(Photo)
}复制代码

枚举也能够具备关联的值,因此也能够在实际的值中放入须要的数据。

数组依然是那样定义,但你的 cellForRowAt: 方法会变的清爽不少:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  switch cellModel {
  case .status(let status):
    let cell = ... 
    return cell
  case .photo(let photo):
    let cell = ...
    return cell
  }
}复制代码

这样你就没有类型转换,没有可选类型,没有未处理的状况,因此也不会有 bug。

5. 让状态变得明确

这些很棒的截屏插图来自 LazyAmphy

空白的页面可能会使用户困惑,因此咱们通常在 table view 为空时在页面上显示一些消息。咱们也会在加载数据时显示一个加载标记。可是若是页面出了问题,咱们最好告诉用户发生了什么,以便他们知道如何解决问题。

咱们的 table view 一般拥有全部的这些状态,有时候还会更多。管理这些状态就有些痛苦了。

咱们假设你有两种可能的状态:显示数据,或者一个提示用户没有数据的视图。初级开发者可能会简单的经过隐藏 table view,显示无数据视图来代表“无数据”的状态。

noDataView.isHidden = false
tableView.isHidden = true复制代码

在这种状况下改变状态意味着你要修改两个布尔值属性。在 view controller 的另外一部分中,你可能想修改这个状态,你必须牢记你要同时修改这两个属性。

实际上,这两个布尔值老是同步变化的。不能显示着无数据视图的时候,又在列表里显示一些数据。

咱们有必要思考一下实际中状态的数值和应用中可能出现的状态数值有何不一样。两个布尔值有四种可能的组合。这表示你有两种无效的状态,在某些状况下你可能会变成这些无效的状态值,你必须处理这种意外状况。

你能够经过定义一个 State 枚举来解决这个问题,枚举中只列举你的页面可能出现的状态。

enum State {
  case noData
  case loaded
}
var state: State = .noData复制代码

你也能够定义一个单独的 state 属性,来做为修改页面状态的惟一入口。每当该属性变化时,你就更新页面到相应的状态。

var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
    case .loaded:
      noDataView.isHidden = false
      tableView.isHidden = true
    }
  }
}复制代码

若是你只经过这个属性来修改状态,就能保证不会忘记修改某个布尔值属性,也就不会使页面处于无效的状态中。如今改变页面状态就变得简单了。

self.state = .noData复制代码

可能的状态数量越多,这种模式就越有用。
你甚至能够经过关联值将错误信息和列表数据都放置在枚举中。

enum State {
  case noData
  case loaded([Cell])
  case error(String)
}
var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
      errorView.isHidden = true
    case .loaded(let cells):
      self.cells = cells
      noDataView.isHidden = true
      tableView.isHidden = false
      errorView.isHidden = true
    case .error(let error):
      errorView.errorLabel.text = error      
      noDataView.isHidden = true
      tableView.isHidden = true
      errorView.isHidden = false
    }
  }
}复制代码

至此你定义了一个单独的数据结构,它彻底知足了整个 table view controller 的数据需求。它
易于测试
(由于它是一个纯 Swift 值),为 table view 提供了一个惟一更新入口惟一数据源。欢迎来到易于调试的新世界!

几点建议

还有几点不值得单独写一节的小建议,可是它们依然颇有用:

响应式!

确保你的 table view 老是展现数据源的当前状态。使用一个属性观察者来刷新 table view,不要试图手动控制刷新。

var cells: [Cell] = [] {
  didSet {
    tableView.reloadData()
  }
}复制代码

Delegate != View Controller

任何对象和结构均可以实现某个协议!你下次写一个复杂的 table view 的数据源或者代理时必定要记住这一点。有效并且更优的作法是定义一个类型专门用做 table view 的数据源。这样会使你的 view controller 保持整洁,把逻辑和责任分离到各自的对象中。

不要操做具体的索引值!

若是你发现本身在处理某个特定的索引值,在分组中使用 switch 语句以区别索引值,或者其它相似的逻辑,那么你颇有可能作了错误的设计。若是你在特定的位置须要特定的 cell,你应该在源数据的数组中体现出来。不要在代码中手动地隐藏这些 cell。

牢记迪米特法则

简而言之,迪米特法则(或者最少知识原则)指出,在程序设计中,实例应该只和它的朋友交谈,而不能和朋友的朋友交谈。等等,这是说的啥?

换句话说,一个对象只应访问它自身的属性。不该该访问其属性的属性。所以, UITableViewDataSource 不该该设置 cell 的 label 的 text 属性。若是你看见一个表达式中有两个点(cell.label.text = ...),一般说明你的对象访问的太深刻了。

若是你不遵循迪米特法则,当你修改 cell 的时候你也不得不一样时修改数据源。将 cell 和数据源解耦使得你在修改其中一项时不会影响另外一项。

当心错误的抽象

有时候,多个相近的 UITableViewCell 类 会比一个包含大量 if 语句的 cell 类要好得多。你不知道将来它们会如何分歧,抽象它们可能会是设计上的陷阱。YAGNI(你不会须要它)是个好的原则,但有时候你会实现成 YJMNI(你只是可能须要它)。

但愿这些建议能帮助你,我确信你确定会有下一次作 table view 的时候。这里还有一些扩展阅读的资源能够给你更多的帮助:

若是你有任何问题或建议,欢迎在下方留言。

Marin 是 COBE 的一名 iOS 开发人员,一名博主和一名计算机科学学生。他喜欢编程,学习东西,而后写下它们,还喜欢骑自行车和喝咖啡。大多数状况下,他只会把 SourceKit 搞崩溃。他有一只叫 Amigo 的胖猫。他基本上不是靠本身写完的这篇文章。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索