阅读 1589

【译】如何合理地处理复杂TableView页面

原文链接:medium.cobeisfresh.com/dealing-wit… 求大佬们点个关注,会定期写原创和翻译国外最新文章,跟大佬们一起学习进步,有问题或者建议欢迎加微信ruiwendelll,拉大家进技术交流群,一起探讨学习,谢谢了!

table view是iOS开发中最重要的布局组件之一。通常我们最重要的一些页面是表格视图:Feed,设置,列表等。

每个写过复杂table viewiOS开发人员都知道它可以非常快速地实现。它有大量的UITableViewDataSource方法和大量的if和switch语句。

我总结了一套原则,我暂时满意,这有助于我克服这些问题。这些技巧的好处在于它们不仅适用于复杂的表视图,而且也适用于所有表视图。

下面是一个复杂table view的例子:

这是PokeBall,Pokémon的社交网络。与所有社交网络一样,它需要一个显与用户的不同动态的Feed。这些动态包括按天分组的新照片和状态消息。因此,我们有两个点需要担心:表视图具有不同的状态,以及多个cell和section。

Cell

我看到很多开发者将cell配置过程放在他们的cellForRowAt:方法中。w我们思考一下啊,该方法的目的是创建一个cell。 UITableViewDataSource的目的是提供数据。dataSource不应该为按钮设置字体。

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的整个生命周期中都会出现,就像标label的字体一样,将它放在awakeFromNib方法中。

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和视图控制器之间。

Model

通常,你使用从某种后端接口获得的模型对象数组来填充table view。然后,cell需要根据该模型对自身进行更改。

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的标题,图像和其他属性。

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代码非常简单易读。

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。

矩阵

分section的table view通常会早成代码很乱。你见过类似下面的代码吗:

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

这是很多代码,并且很多硬编码索引应该非常简单,易于更改和交换。这个问题有一个简单的解决方案:矩阵。

还记得矩阵吗?这是机器学习相关开发者和一年级CS专业学生使用的东西,但应用程序开发人员通常不这样做。然而,如果你想到一个分段的table view,你正在展示一个section列表。每个section都是一个cell列表。这听起来像一个数组或矩阵。

这就是你应该对分段table view进行建模的方式。而不是一维数组,使用二维数组。这就是UITableViewDataSource方法的结构:你被要求返回第m个section的第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
}
复制代码

然后我们可以通过定义Section容器类型来扩展这个概念。此类型不仅会保存某个section的cell,还会保留secton标题。

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

现在我们可以避免使用我们的硬编码索引,而是可以定义一个section数组并直接返回它们的标题。

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

这样,我们的数据源方法中的代码就越少,因此越界错误的可能性就越小。代码也变得更具表现力和可读性。

枚举

使用多种cell类型可能非常棘手。考虑某种类型的feed,你必须显现不同类型的cell,如照片和状态。为了保持清楚并避免奇怪的数组索引运算,你应该将它们存储在同一个数组中。

但是,数组是同质的,这意味着您不能拥有不同类型的数组。想到的第一个解决方案是协议。毕竟,Swift是面向协议的!

你可以定义协议FeedItem,并确保我们的cell的模型实现该协议。

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()
  }
}
复制代码

在将模型向上转换为协议时,您丢失了许多实际需要的信息。你已经抽出了cell,但实际上你需要具体的实例。因此,您最终必须检查是否可以转换为类型,然后根据该类型显示cell。

这会有效,但它并不漂亮。向下倾斜本质上是不安全的,并导致optional。你也不知道是否已涵盖所有 情况,因为无数种类型都可以实现你的协议。这就是为什么你需要调用fatalError,为了防止你得到一个意外的类型。

当您尝试将协议的实例强制转换为具体类型时,通常会使代码出现问题。当你不需要特定信息时,可以使用协议,但可以使用原始数据的子集代替。

更好的方法是使用枚举。这样你可以打开它,如果你没有处理所有情况,代码将无法编译。

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
  }
}
复制代码

这样,你没有强制转换,没有可选项和没有未处理的情况,所以我们没有错误。

使状态清晰

因为看到空白屏幕会让人感到困惑,所以当table view为空时,我们通常会显示某种消息。我们还在数据加载时显示一个加载动画。但是,如果事情不对,那么告诉用户发生了什么以便他们知道如何解决问题会很好。

我们的table view通常具有所有这些状态等等。管理它们可能会很痛苦

假设您有两种可能的状态:显示数据或无数据视图。一个naive的开发人员会隐藏table View并展示无数据视图就来表示“无数据”状态。

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

在这种情况下更改状态意味着您必须更改两个bool属性。在视图控制器的另一部分中,您可能希望将状态设置为其他部分,并且需要记住设置两个属性。

实际上,这两个bool属性应该始终保持同步。您不能拥有无数据视图而同时以显示一些数据。

考虑现实世界状态数与应用中可能的状态数之间的区别很有用。两个布尔值有四种可能的组合。这意味着你有两个不想要的无效状态,你需要处理这些状态。

您可以通过定义一个包含屏幕可能处于的所有可能状态的状态枚举来解决此问题。

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

你还可以定义单个状态属性,这是更改屏幕状态的唯一方法。每次更改属性时,你都将更新屏幕以显示该状态。

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提供单点更新和单一事实来源。

建议

下面是几个小贴士,非常有用:

reactive

确保table view始终展示数据源源数组的当前状态。使用属性观察器刷新table View,不要尝试手动保持它们同步。

Delegate != ViewController

任何人都可以实现协议!请记住,下次编写复杂的table view数据源或委托时。定一个唯一目的是table view的数据源的类型会更好 。这样可以保持视图控制器的干净,并将逻辑和职责分离到各自的对象中。

不要比较index

如果你发现你会确认某个indexPath是某个确切的index,通过switch语句到某个section,或者类似的操作。这是不对的。如果你有某个cell要放在确定的位置,在你的源数组中展示它。不要在你的代码中隐藏这些cell。

记住法则

总而言之,唯一的法则是在编程中,朋友只和它的朋友交谈,不要和朋友的朋友交谈。

换句话说,一个对象应该只访问它自己的属性。那些属性的属性应该保持不变。所以,UITableViewDataSource不应该为cell的label设置text属性。如果你在代码中看到两个点(例如cell.label.text=...)那就是不对的。

如果你不按照这个原则来,更改cell意味着你也不得不更改数据源。将cell和数据源解耦可以让你更改或者重构一个cell而不用影响其他。

错误的抽象

有时候,拥有多个类似的UITableViewCell类比使用一堆if语句的单个类更好。你不会知道他们以后会怎么出现问题,将它们抽象是一个陷阱。

我希望这些技巧可以帮助你,我相信你下次写table view相关代码会用到这些建议。

求大佬们点个关注,会定期写原创和翻译国外最新文章,跟大佬们一起学习进步,有问题或者建议欢迎加微信ruiwendelll,拉大家进技术交流群,一起探讨学习,谢谢了!

关注下面的标签,发现更多相似文章
评论