01月22日
[译] MVC 模式下的 iOS Tableview

如果你写过 iOS 项目的话,应该会了解到,iOS 里面最常用的一个控件就是 UITableView;即便没写过 iOS 项目,你应该也会在一些流行的 App 里面看到过它,比如:YouTube,Facebook,Twitter,Medium 等等。一般来讲,当你想要在一个页面上,展示一个数量动态变化的数据的时候,你应该会考虑使用 UITableView。

还有一个基础控件是 CollectionView,它相对来讲更灵活,所以我个人更喜欢用这个。稍后我还会写一篇文章来讲它。

所以,在你的项目里面,不可避免的会用到 UITableView。

比较常见的做法是使用 UITableViewController,它有一个内置的 UITableView;通过简单的设置就可以让它工作起来,你需要做的只是设置好数组数据和显示数据的 Cell。它使用起来很简单,而且也可以满足需求,但是它有一个缺点:这会让 UITableViewController 里面的代码变得超级长,而且这打破了 MVC 模式。关于 MVC 具体是什么,或者我们为什么要去了解它,你可以先看一下 这篇文章译文),它很好的介绍了 iOS 里面所有的架构模式。

即便你不想去弄懂所有的这些模式,至少对于 UITableViewController 里面的那上千行代码,你总是想要重构划分一下的吧。

在我的上一篇文章里面,我提到了 从 Controller 向 Model 传递数据的三种方式

在这篇文章里面,我要讲的是我处理 tableView 所有的方式,也就是在上篇文章里提到的 - 代理的方式。用这种处理方式,可以让代码看起来更整洁、模块化、易重用。

这次不适用 UITableViewController,而是把它划分成几个类:

  • DRHTableViewController:UIViewController 的子类,然后添加一个 UITableView 作为子视图
  • DRHTableViewCell:UITableViewCell 的子类
  • DRHTableViewDataModel:它有一个 API 方法:创建数据并用代理的方式返回数据给 DRHTableViewController
  • DRHTableViewDataModelItem:数据类:它包括了展示在 DRHTableViewCell 里面的所有数据

先从 UITableViewCell 开始吧。

一、TableViewCell

以单视图应用(Single View Application)为模板,创建一个新工程;然后删掉自带的 ViewController.swift 和 Main.storyboard 文件。稍后我们会一步步的创建所有用到的文件。

首先,创建一个 UITableViewCell 的子类。如果你想用 XIB,就勾选“Also create XIB file”这个选项。

图片

在这里,我们想要做的是一个 Medium 主页的简化版,所以需要添加下面这些子视图:

  1. 用户头像
  2. 姓名标签
  3. 日期标签
  4. 文章标题
  5. 文章概要

约束条件(Autolayout)你可以随意加,这不是重点。给每个视图添加一个对应的属性,完了在你的 DRHTableViewCell.swift 文件里面,应该有类似下面的这部分代码:

class DRHTableViewCell: UITableViewCell {
   @IBOutlet weak var avatarImageView: UIImageView?
   @IBOutlet weak var authorNameLabel: UILabel?
   @IBOutlet weak var postDateLabel: UILabel?
   @IBOutlet weak var titleLabel: UILabel?
   @IBOutlet weak var previewLabel: UILabel?
}

在这里,我把每个 @IBOutlet 默认的 “!” 改成了 “?”。当你从 InterfaceBuilder 里面拖拽 UILabel 到代码里的时候,它会自动强制解包开这个标签,然后在它后面加上 “!”。这里面有一部分原因是为了和 objective-C API 保持一致性,但是我个人总是喜欢避免强制解包,所以我这里用 optional 标识符做了替换。

接下来,还需要一个方法:用数据去填充上面的这些标签和图片。在数据这块,我们不是在 Cell 里创建很多的变量去表示它,而是为它创建一个新的类 DRHTableViewDataModelItem:

class DRHTableViewDataModelItem {
   var avatarImageURL: String?
   var authorName: String?
   var date: String?
   var title: String?
   var previewText: String?
}

最好还是用 Date 类型去存储 date,但是这里为了方便,就把它存储成了 String 型。

所有的变量都是可选的(optional),所以不用去担心默认值的问题,稍后还会为它添加一个 Init() 方法。现在再回到 DRHTableViewCell.swift 文件,添加下面这些代码(用数据去填充 Cell 里面的标签和图片):

func configureWithItem(item: DRHTableViewDataModelItem) {
   // setImageWithURL(url: item.avatarImageURL)
   authorNameLabel?.text = item.authorName
   postDateLabel?.text = item.date
   titleLabel?.text = item.title
   previewLabel?.text = item.previewText
}

setImageWithURL() 方法具体的实现,依赖于具体项目里面对图片缓存的处理;所以这里没有去管它。

现在我们已经有了 Cell,可以创建 TableView 了。

二、TableView

在这里,我们使用基于故事版的(storyboard-based)ViewController。你可以先看下 我的上一篇文章,了解下怎么更好的使用故事版。

首先,创建一个 UIViewController 的子类:

图片

在这面,用 UIViewController 而不是 UITableViewController,这样可以有更多的控制。比如把 UITableView 创建成一个子视图,就可以根据自己的需要,用约束条件去设置它的位置。

接下来,创建一个故事版文件,用相同的名字给它命名:DRHTableViewController。从对象库里面拖拽出来一个 ViewController,并设置它为上面创建的类。

图片

添加一个 UITableView,并让它跟 View 的四边对齐。

图片

最后,在 DRHTableViewController 里面添加 tableView 属性。

class DRHTableViewController: UIViewController {
   @IBOutlet weak var tableView: UITableView?
}

我们已经创建了 DRHTableViewDataModelItem 类,现在在 viewController 里面添加一个本地变量

fileprivate var dataArray = [DRHTableViewDataModelItem]()

这个变量用来存储将要展示在 tableView 上面的数据。

记住,我们不会在 ViewController 里面去创建数据:dataArray 只是一个空数组;而是在稍后用代理的方式给它填充数据。

现在在 viewDidLoad 方法里面设置 tableView 的一些基本属性。在这里颜色和样式都可以随意设置,但是唯一需要确认的是注册 nib 文件:

tableView?.register(nib: UINib?, forCellReuseIdentifier: String)

在调用这个方法之前(这个方法里面的 identifier 参数很难写),我们先不创建 nib 文件,而是在 DRHTableViewCell 里面添加两个方法:nib、identifier。

要尽量避免去重复写一些很难写的字符串;如果实在没有办法,可以创建一个 字符串变量,并用它来代替。

打开 DRHTableViewCell,在开头添加下面的代码:

class DRHMainTableViewCell: UITableViewCell {
   class var identifier: String { 
      return String(describing: self)
   }
   class var nib: UINib { 
      return UINib(nibName: identifier, bundle: nil)
   }
   .....
}

保存这些修改,然后回到 DRHTableViewController,调用 registerNib 方法:

tableView?.register(DRHTableViewCell.nib, forCellReuseIdentifier: DRHTableViewCell.identifier)

不要忘了设置 tableViewDataSource 和 tableViewDelegate 为 self:

override func viewDidLoad() {
   super.viewDidLoad()
   tableView?.register(DRHTableViewCell.nib, forCellReuseIdentifier:   
   DRHTableViewCell.identifier)
   tableView?.delegate = self
   tableView?.dataSource = self
}

写完之后,编译器会报错:“Cannot assign value of type DRHTableViewController to type UITableViewDelegate”

当你使用 UITableViewController 子类的时候,tableView 的代理和数据源是已经设置好了的。但是如果你是在 UIViewController 中创建 UITableView 的话,就需要让 UIViewController 继承一下 UITableViewControllerDelegate 和 UITableViewControllerDataSource。

只要为 DRHTableViewController 添加两个扩展,就可以解决了:

extension DRHTableViewController: UITableViewDelegate {
}
extension DRHTableViewController: UITableViewDataSource {
}

又会报错:“type DRHTableViewController does not conform to protocol UITableViewDataSource”。这是因为有一些必须实现的方法,需要你在这个扩展里面实现它们:

extension DRHTableViewController: UITableViewDataSource {
      func tableView(_ tableView: UITableView, cellForRowAt    
      indexPath: IndexPath) -> UITableViewCell {
      }
      func tableView(_ tableView: UITableView, numberOfRowsInSection 
      section: Int) -> Int {
      }
}

UITableViewDelegate 所有的方法都是非必须的,所以即使你没有实现,这里也不报错。按住 Command 键,点击 UITableViewDelegate,可以看到它具体都有哪些方法。它最常用的方法是 选择/取消选择 某个 cell,设置 cell 高度,配置 tableView 的 header/footer 等。

上面两个方法都是需要返回值的,所以编译器又报错了:“Missing return type”。让我们来解决它。

首先,需要设置 section 里面 row 的数量:我们已经有了 dataArray,可以直接使用它的 count 就可以:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return dataArray.count
}

在这里,我没有重载另一个方法:numberOfSectionsInTableView。这个方法是非必须的,它默认是返回 1;而这个项目里面 tableView 只有一个 section,所以不需要去重载这个方法。

最后一步,配置 UITableViewDataSource 还需要在 cellForRowAtIndexPath 方法里面返回 cell:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell   
   {
      return cell 
   }
   return UITableViewCell()
}

我们分行来看一下。

为了创建 cell,我们可以使用 DRHTableViewCell 的 identifier 作为参数去调用 dequeueReusableCell 方法。它会返回一个 UITableViewCell,所以我们需要用一个可选标识符把它从 UITableViewCell 转换成 DRHTableViewCell:

let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell

然后安全解包它(safe-unwrap):如果成功,就返回这个自定义的 cell:

if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell   
   {
      return cell 
   }

如果安全解包失败,就返回一个默认的 UITableViewCell:

if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell   
   {
      return cell 
   }
return UITableViewCell()

我们是不是漏了什么?对,还需要用数据去配置 cell 视图:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   if let cell = tableView.dequeueReusableCell(withIdentifier: 
   DRHTableViewCell.identifier, for: indexPath) as? DRHTableViewCell 
   { 
      cell.configureWithItem(item: dataArray[indexPath.item])
      return cell
   }
   return UITableViewCell()
}

我们已经为最后一部分做好准备了:创建 DataSource 并连接到 TableView。

三、DataModel

创建一个 DRHTableViewDataModel 类。

我们会在这个类里面获取数据,至于获取方式,可以是从一个 JSON 文件,或者是 HTTP 请求,或者是别的数据源,这不是本文的重点。我们假定已经有了一个 API 方法,它可以返回一个可选类型的数据对象和一个可选类型的错误信息:

class DRHTableViewDataModel {
   func requestData() {
      // code to request data from API or local JSON file will go   
         here
      // this two vars were returned from wherever:
      // var response: [AnyObject]?
      // var error: Error?
      if let error = error {
          // handle error
      } else if let response = response {
          // parse response to [DRHTableViewDataModelItem]
          setDataWithResponse(response: response)
      }
   }
}

在 setDataWithResponse 方法里面,我们需要用一个 AnyObject 类型的数组对象 response,构建出一个 DRHTableViewDataModelItem 类型的数组;所以,紧接着添加下面这些代码:

private func setDataWithResponse(response: [AnyObject]) {
   var data = [DRHTableViewDataModelItem]()
   for item in response {
     // create DRHTableViewDataModelItem out of AnyObject
   }
}

在这个方法里面,我们创建了一个 DRHTableViewDataModelItem 类型的空数组,我们需要用 response 数组去构建它。然后我们遍历 reponse 数组里面的每个 item;在这个遍历循环里面,我们需要根据 AnyObject 类型的 item 创建一个 DRHTableViewDataModelItem 类型的对象。

我们还没有给 DRHTableViewDataModel 创建初始化方法,所以回到 DRHTableViewDataModel 类,创建这个初始化方法。在这里,我们用一个 Dictionary [String: String]? 类型的对象作为参数,创建一个 Optional 类型的初始化方法(或者说是 可失败的初始化)。

init?(data: [String: String]?) {
if let data = data, let avatar = data[“avatarImageURL”], let name = data[“authorName”], let date = data[“date”], let title = data[“title”], let previewText = data[“previewText”] {
self.avatarImageURL = avatar
self.authorName = name
self.date = date
self.title = title
self.previewText = previewText
} else {
   return nil
}
}

如果这个 Dictionary 里面,缺少了任意一个必需的 key 值,或者说这个字典本身就是一个 nil 的话,那么这次初始化就是失败的(返回 nil)。

有了这个可失败的初始化方法(Failable Init),就可以补全 DRHTableViewDataModel 类里面的 setDataWithResponse 方法了:

private func setDataWithResponse(response: [AnyObject]) {
   var data = [DRHTableViewDataModelItem]()
   for item in response {
     if let drhTableViewDataModelItem =   
     DRHTableViewDataModelItem(data: item as? [String: String]) {
        data.append(drhTableViewDataModelItem)
     }
   }
}

在 for 循环之后,我们得到了一个 DRHTableViewDataModelItem 类型的数组。那么我们怎么把这个数据传递给 TableView 呢?

四、Delegate

首先,在 DRHTableViewDataModel.swift 文件里面创建一个代理 协议 DRHTableViewDataModelDelegate,放在 DRHTableViewDataModel 类的正上方:

protocol DRHTableViewDataModelDelegate: class {
}

在这个协议里面,创建两个方法:

protocol DRHTableViewDataModelDelegate: class {
   func didRecieveDataUpdate(data: [DRHTableViewDataModelItem])
   func didFailDataUpdateWithError(error: Error)
}

Swift 协议中,class 这个关键字限定了该协议只接受 class 类型(不接受结构体或者枚举类型),从而可以对它使用弱引用(weak reference )。为了确保代理和委托对象之间不会有循环引用,在这里需要用到弱引用。

然后,在 DRHTableViewDataModel 里面添加一个可选的弱引用。

weak var delegate: DRHTableViewDataModelDelegate?

现在,需要在可能用到它的地方调用它。具体到这个例子,在请求失败的时候需要传递错误信息,在创建成功的时候需要传递数据。错误处理的方法可以放在 requestData 方法里面调用:

class DRHTableViewDataModel {
func requestData() {
      // code to request data from API or local JSON file will go   
         here
      // this two vars were returned from wherever:
      // var response: [AnyObject]?
      // var error: Error?
      if let error = error {
          delegate?.didFailDataUpdateWithError(error: error)
     } else if let response = response {
          // parse response to [DRHTableViewDataModelItem]
          setDataWithResponse(response: response)
     }
   }
}

最后,在 setDataWithResponse 方法里面调用第二个代理方法:

private func setDataWithResponse(response: [AnyObject]) {
   var data = [DRHTableViewDataModelItem]()
   for item in response {
      if let drhTableViewDataModelItem =  
      DRHTableViewDataModelItem(data: item as? [String: String]) {
         data.append(drhTableViewDataModelItem)
      }
   }
   delegate?.didRecieveDataUpdate(data: data)
}

五、显示数据

有了 DRHTableViewDataModel 就可以向 tableView 里面传递数据了。

首先,需要在 DRHTableViewController 里面创建 dataModel 的引用:

private let dataSource = DRHTableViewDataModel()

然后,还需要请求数据。我会在 ViewWillAppear 方法里面去做这个事情,这样每次视图出现的时候数据都会得到更新:

override func viewWillAppear(_ animated: Bool) {
   super.viewWillAppear(true)
   dataSource.requestData()
}

这是一个简单的例子,所以我在 viewWillAppear 方法里面请求数据。在真正的 app 里面,这需要根据很多因素视情况而定,比如缓存时间、API 的使用、App 自身的逻辑等等。

然后,在 viewDidLoad 方法里面,把它的代理赋值给 self:

dataSource.delegate = self

又报编译错误,这是因为 DRHTableViewController 还没有继承 DRHTableViewDataModelDelegate。在文件的末尾添加下面的代码就可以搞定:

extension DRHTableViewController: DRHTableViewDataModelDelegate {
   func didFailDataUpdateWithError(error: Error) {
   }
   func didRecieveDataUpdate(data: [DRHTableViewDataModelItem]) {
   }
}

最后,我们需要处理 didFailDataUpdateWithError 和 didRecieveDataUpdate 这两种情况:

extension DRHTableViewController: DRHTableViewDataModelDelegate {
   func didFailDataUpdateWithError(error: Error) {
       // handle error case appropriately (display alert, log an error, etc.)
   }
   func didRecieveDataUpdate(data: [DRHTableViewDataModelItem]) {    
       dataArray = data
   }
}

给 dataArray 赋值就表示,其实我们是想要重新加载 tableView 的数据的。但是在这里我们并没有在 didRecieveDataUpdate 方法里去做这件事,而是用对 dataArray 添加 属性观察者(property observer)的方式来实现:

fileprivate var dataArray = [DRHTableViewDataModelItem]() {
   didSet {
      tableView?.reloadData()
   }
}

设置属性观察者(Setter Property Observer)会在设置完成之后,运行它里面的这些代码。

就是这些!

现在,你有了一个 tableView 模板,它配置了自定义的数据源和自定义的 cell。

你不再需要那个把所有代码都搞在一起,弄了有上千行代码的 tableViewController 了。

你上面创建的每一个部分,在整个项目里都是可以重用的,当然这是做代码划分的另一个好处了。

ps:想看所有代码的话,可以查看 Github 上面的这个仓库

附:原文链接

7条评论

IB拖出来的视图还不强制解包??????

prettykai5 个月前回复

年底了,都不用正经上班了?

z2xy10 个月前回复

没有代码高亮差评……

Fwolf10 个月前回复

虽然看不太明白,但是沙发支持。。。

AndyHua仔仔10 个月前回复

嘿 沙发

xin10 个月前回复

r_evo10 个月前回复

f_q10 个月前回复