原文: http://holko.pl/2016/01/05/typed-table-view-controller/
UITableView
是几乎所有iOS开发者的日常. 绝大多数情况下, 我们只用一种数据类型和同一个UITableViewCell
和对应的reuse identifier来呈现数据. 当我们想要处理两种或者更多种cell的时候, 问题会变得复杂起来.
本文将介绍三种解决这一问题的方法. 后两种尝试解决上一种中出现的问题. 第一种方法是我们经常会在Obj-C代码库中看到的解决方案. 第二种方法利用了enumeration来更好地解决这一问题, 第三种方法基于protocol和generics, 属于Swift的最佳实践.
Basics
我会借助一个创建多种类cell的例子(在Github上), 如下图:

我喜欢把呈现一个view需要的所有数据封装起来, 叫它view data
. 例子的view data
中非常简单:
struct TextCellViewData {
let title: String
}
struct ImageCellViewData {
let image: UIImage
}
在实际项目中我们肯定有更多的数据, image
属性会变成NSURL
类型替代以去除对UIKit的依赖. 我们同时拥有两个用来呈现数据的cell:
class TextTableViewCell: UITableViewCell {
func updateWithViewData(viewData: TextCellViewData) {
textLabel?.text = viewData.title
}
}
class ImageTableViewCell: UITableViewCell {
func updateWithViewData(viewData: ImageCellViewData) {
imageView?.image = viewData.image
}
}
这些ready之后就可以开始处理View Controller了.
1st Approach: 'Easy'
我喜欢在解决问题一开始的时候先不要把它复杂化. 先简单地实现功能再说.
我们用一个叫items
的Array作为model层, 由于要包含不同类型的struct, 所以items
的类型是[AnyObject]
. 我们用registerCells()
方法来事先注册cell. 在tableView(_:cellForRowAtIndexPath:)
中, 我们检查当前view data的类型来决定用那种cell呈现哪些数据. 以下是具体实现:
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
var items: [Any] = [
TextCellViewData(title: "Foo"),
ImageCellViewData(image: UIImage(named: "Apple")!),
ImageCellViewData(image: UIImage(named: "Google")!),
TextCellViewData(title: "Bar"),
]
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
registerCells()
}
func registerCells() {
tableView.registerClass(TextTableViewCell.self, forCellReuseIdentifier: textCellIdentifier)
tableView.registerClass(ImageTableViewCell.self, forCellReuseIdentifier: imageCellIdentifier)
}
}
extension ViewController: UITableViewDataSource {
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let viewData = items[indexPath.row]
if (viewData is TextCellViewData) {
let cell = tableView.dequeueReusableCellWithIdentifier(textCellIdentifier) as! TextTableViewCell
cell.updateWithViewData(viewData as! TextCellViewData)
return cell
} else if (viewData is ImageCellViewData) {
let cell = tableView.dequeueReusableCellWithIdentifier(imageCellIdentifier) as! ImageTableViewCell
cell.updateWithViewData(viewData as! ImageCellViewData)
return cell
}
fatalError()
}
}
这样的实现是可行的, 但是我可以总结几点让我觉得不爽的原因:
-
我们不能让这个view controller变成可复用的. 如果我们决定添加一个新类型的cell, 比如说一个呈现video的cell,我们必须要在3个地方改代码: 1) 新的reuse identifier, 2)
registerCells()
, 3)tableView(_:cellForRowAtIndexPath:)
-
如果我们决定改变
items
里面的数据,提供一个新的data type, 我们势必要处理tableView(_:cellForRowAtIndexPath:)
的fatalError()
, 整体的封装性并不好. -
我们明明知道一种cell对应着一种view data, 但在代码中我们并没有体现这种关系.
2nd Approach: Enumeration
我们能通过enumeration来解决上述的部分问题
enum TableViewItem {
case Text(viewData: TextCellViewData)
case Image(viewData: ImageCellViewData)
}
然后我们的items
就变成[TableViewItem]
:
var items: [TableViewItem] = [
.Text(viewData: TextCellViewData(title: "Foo")),
.Image(viewData: ImageCellViewData(image: UIImage(named: "Apple")!)),
.Image(viewData: ImageCellViewData(image: UIImage(named: "Google")!)),
.Text(viewData: TextCellViewData(title: "Bar")),
]
接着改一下registerCells()
:
func registerCells() {
for item in items {
let cellClass: AnyClass
let identifier: String
switch(item) {
case .Text(viewData: _):
cellClass = TextTableViewCell.self
identifier = textCellIdentifier
case .Image(viewData: _):
cellClass = ImageTableViewCell.self
identifier = imageCellIdentifier
}
tableView.registerClass(cellClass, forCellReuseIdentifier: identifier)
}
}
最后是tableView(_:cellForRowAtIndexPath:)
:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let item = items[indexPath.row]
switch(item) {
case let .Text(viewData: viewData):
let cell = tableView.dequeueReusableCellWithIdentifier(textCellIdentifier) as! TextTableViewCell
cell.updateWithViewData(viewData)
return cell
case let .Image(viewData: viewData):
let cell = tableView.dequeueReusableCellWithIdentifier(imageCellIdentifier) as! ImageTableViewCell
cell.updateWithViewData(viewData)
return cell
}
}
这样操作的好处:
-
对于一个
viewController
实例来说, 数据的入口脱离了UITableViewCell
, 仅仅只有view data
了. -
我们用switch语句代替了if语句, 避免了
fatalError()
我们还可以在为这种实现更进一步, 比如说简化dequeing:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let item = items[indexPath.row]
switch(item) {
case let .Text(viewData: viewData):
return tableView.dequeueCellWithViewData(viewData) as TextTableViewCell
case let .Image(viewData: viewData):
return tableView.dequeueCellWithViewData(viewData) as ImageTableViewCell
}
}
这样操作有一个坏处就是, 我们要在UITableView的各个协议方法以及自己定义的中介方法中不停地重复书写switch语句. 虽然现在我们只要写两个, 但可以想象具体项目里我们要书写的数目之庞大. 比如说, AutoLayout成为性能瓶颈, 我们决定用手动layout整个tableView内容的时候, 我们就得用在tableView(_:heightForRowAtIndexPath:)
再写一次Switch了.
虽说我对此还是可以接受的, 但是一想到将来要重复写的这些代码, 我就觉得头疼.
3rd (Final) Approach: Protocols and Generics
让我们暂时先忘掉之前的第一种和第二种实现方法, 从头开始.
Updatable
我们用view data
来更新cell的数据, 所以我们可以为cell引入Updatable
协议, 协议中囊括更新所需的的ViewData
.
protocol Updatable: class {
typealias ViewData
func updateWithViewData(viewData: ViewData)
}
让cell纷纷遵循它:
extension TextTableViewCell: Updatable {
typealias ViewData = TextCellViewData
}
extension ImageTableViewCell: Updatable {
typealias ViewData = ImageCellViewData
}
在之前两个方法实现的时候, 我们不难发现对于每一个在items
中的view data
而言, 我们需要它:
- 知道它所对应的cell的类型
- 知道它所对应的reuse identifier
- 包含更新cell所需要的数据(图片,文字)
CellConfigurator
因此, 让我们把view data再wrap一层, 让新的struct能够包含所有Cell所需要的数据, 我们姑且叫它CellConfigurator
:
struct CellConfigurator<Cell where Cell: Updatable, Cell: UITableViewCell> {
let viewData: Cell.ViewData
let reuseIdentifier: String = NSStringFromClass(Cell)
let cellClass: AnyClass = Cell.self
...
这是一个generic的struct, 包含一个Cell参数. Cell必须遵循Updatable
, 并且是一个UITableViewCell
CellConfigurator
有3个属性: viewData
,reuseIdentifier
和cellClass
. viewData
的类型依赖于Cell的类型, 它是唯一没有默认值的属性. 另外两个属性都会根据Cell的类型拿到对应的默认值.
...
// further part of CellConfigurator
func updateCell(cell: UITableViewCell) {
if let cell = cell as? Cell {
cell.updateWithViewData(viewData)
}
}
}
最后,我们把updateCell()
放入UITableViewCell
之中. 这是一段非常精简的代码.
然后,我们在items里放入CellConfigurator
的实例:
let items = [
CellConfigurator<TextTableViewCell>(viewData: TextCellViewData(title: "Foo")),
CellConfigurator<ImageTableViewCell>(viewData: ImageCellViewData(image: UIImage(named: "Apple")!)),
CellConfigurator<ImageTableViewCell>(viewData: ImageCellViewData(image: UIImage(named: "Google")!)),
CellConfigurator<TextTableViewCell>(viewData: TextCellViewData(title: "Bar")),
]
但是这时候编译器会报一个错:
Type of expression is ambiguous without more context
这是因为CellConfigurator
是generic的, 但是Swift的Array
是homogeneous(同质的)的. 所以我们把CellConfigurator<TextTableViewCell>
和CellConfigurator<ImageTableViewCell>
放到一个Array里, 就会报错.
仔细想想, Cell这个参数只有在定义viewData
的时候用到了. 我们可以通过添加一个non-generic的protocol来把Cell这个参数隐藏掉.
protocol CellConfiguratorType {
var reuseIdentifier: String { get }
var cellClass: AnyClass { get }
func updateCell(cell: UITableViewCell)
}
让CellConfigurator
遵循它:
extension CellConfigurator: CellConfiguratorType {
}
然后我们把items的类型改为:
let items: [CellConfiguratorType]
bingo, 编译器报错消失.
View Controller
我们现在可以更新我们的ViewController了, registerCells()
变得非常精简:
func registerCells() {
for cellConfigurator in items {
tableView.registerClass(cellConfigurator.cellClass, forCellReuseIdentifier: cellConfigurator.reuseIdentifier)
}
}
tableView(_:cellForRowAtIndexPath:)
也变得简单很多:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cellConfigurator = items[indexPath.row]
let cell = tableView.dequeueReusableCellWithIdentifier(cellConfigurator.reuseIdentifier, forIndexPath: indexPath)
cellConfigurator.updateCell(cell)
return cell
}
还有一些别的让我们的view controller变得reusable的步骤, 比如说允许items
的添加删除. 我们在这里就不一一列举了, 具体的实现在被封装成了framework在这儿: GitHub: ConfigurableTableViewController.
Conclusion
让我们最后来总结一下第三种方法解决了前两种方法的哪些痛点:
- 我们在加入新类型cell的时候,不再需要修改view controller.
- view controller是type safe的, 如果我们提供了不满足需求的view data, 编译器会报错
- 我们不再需要重复写switch语句
由此看来, 第三种方法解决了我们所有的问题. 这印证了一句话:
A better solution is often just around the corner.
Thanks to Maciej Konieczny and Kamil Kołodziejczyk for reading drafts of this.
中文翻译: Dev端
网友评论