简体   繁体   中英

How should I refactor my custom UITableView to improve maintainability

I've got a UITableView with many different kind of views. In each method of the UITableView data source I need to check the type of the cell and type of the object, cast them, and act correctly. This is not very clean (it works) but not very maintainable.

So I was working on something to abstract this part but I'm a little bit stuck. The following code is simplified and maybe not that useful but it is to demonstrate my current problem:

extension UITableView {
    func dequeue<T: UITableViewCell>(_ type: T.Type,
                                     for indexPath: IndexPath) -> T {
        let cell = dequeueReusableCell(withIdentifier: String(describing: type),
                                       for: indexPath)
        guard let cellT = cell as? T else {
            fatalError("Dequeue failed, expect: \(type) was: \(cell)")
        }

        return cellT
    }
}

struct Row<Model, Cell> {
    let view: Cell.Type
    let model: Model

    var fill: ((Model, Cell) -> Void)
}

// Completly unrelated models
struct Person {
    let name: String
}

struct Animal {
    let age: Int
}

// Completely unrelated views
class PersonView: UITableViewCell {

}

class AnimalView: UITableViewCell {

}


// Usage:
let person = Person(name: "Haagenti")
let animal = Animal(age: 12)

let personRow = Row(view: PersonView.self, model: person) { person, cell in
    print(person.name)
}

let animalRow = Row(view: AnimalView.self, model: animal) { animal, cell in
    print(animal.age)
}

let rows = [
//    personRow
    animalRow
]



let tableView = UITableView()
for row in rows {
    tableView.register(row.view, forCellReuseIdentifier: String(describing: row.view))


    let indexPath = IndexPath(row: 0, section: 0)
    let cell = tableView.dequeue(row.view, for: indexPath)

    row.fill(row.model, cell)
}

The code works, but when I enable the animalRow Swift will complain. This is not that surprising since it cannot resolve the types. I cannot figure out how to get around this.

By using the following code I can declare everything once and execute all the parts like "fill" when I need them. I will also add code like onTap etc, but I removed all this code to keep to problem clear.

Sahil Manchanda's answer is covering the OOD approach to solving this problem but as a drawback you have to define your models as class.

First thing we need to consider is the fact that we're discussing about maintainability here, so in my humble opinion, Model should not know about the view (or which views it's compatible with), That is Controller's responsibility. (what if we want to use the same Model for another view somewhere else?)

Second thing is that if we want to abstract it to higher levels, it will definitely require down-cast/force-cast at some point, so there is a trade-off to how much it can be abstracted.

So for sake of maintainability, we can increase the readability and separation of concern/local reasoning.

I suggest to use an enum with associatedValue for your models:

enum Row {
    case animal(Animal)
    case person(Person)
}

Well right now our Models are separated and we can act differently based on them.

Now we have to come-up with a solution for Cells, I usually use this protocol in my code:

protocol ModelFillible where Self: UIView {
    associatedtype Model

    func fill(with model: Model)
}

extension ModelFillible {
    func filled(with model: Model) -> Self {
        self.fill(with: model)
        return self
    }
}

So, we can make our cells conform to ModelFillible :

extension PersonCell: ModelFillible {
    typealias Model = Person

    func fill(with model: Person) { /* customize cell with person */ }
}

extension AnimalCell: ModelFillible {
    typealias Model = Animal

    func fill(with model: Animal) { /* customize cell with animal */ }
}

Right now we have to glue them all together. We can refactor our delegate method tableView(_, cellForRow:_) just like this:

var rows: [Row] = [.person(Person()), .animal(Animal())]

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch rows[indexPath.row] {
    case .person(let person): return (tableView.dequeue(for: indexPath) as PersonCell).filled(with: person)
    case .animal(let animal): return (tableView.dequeue(for: indexPath) as AnimalCell).filled(with: animal)
    }
}

I believe in future this is more readable/maintainable than down-casting in Views or Models.

Suggestion

I also suggest to decouple PersonCell from Person too, and use it like this:

extension PersonCell: ModelFillible {
    struct Model {
        let title: String
    }

    func fill(with model: Model { /* customize cell with model.title */ }
}

extension PersonCell.Model {
    init(_ person: Person) { /* generate title from person */ }
}

And in your tableView delegate use it like this:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch rows[indexPath.row] {
    case .person(let person): return (tableView.dequeue(for: indexPath) as PersonCell).filled(with: .init(person))
    case .animal(let animal): return (tableView.dequeue(for: indexPath) as AnimalCell).filled(with: .init(animal))
    }
}

With current approach compiler will always know what's going on, and will block you from making mistakes & in future by reading this code, you know exactly what's going on.

Note

The reason that it will require down-cast/force-cast at some point if we try to abstract it to higher levels (just like Sahil's answer), is the fact that dequeue does not happen at the same-time we want to fill/customize our cell. dequeue has to return a type known to compiler. it's either UITableViewCell , PersonCell or AnimalCell . In first case we have to down-cast it, and it's not possible to abstract PersonCell and AnimalCell (unless we try down-cast/force-cast in their models). We can use a type like GenericCell<Row> and also cell.fill(with: row) but that means that our customized cell, has to handle all cases internally (it should handle PersonCell and AnimalCell views at the same time which is also not maintainable).

Without down-cast/force-cast this is the best I got to over the years. If you need more abstractions (single line for dequeue , and a single line for fill ) Sahil's answer is the best way to go.

Have a look at the following struct:

protocol MyDelegate {
    func yourDelegateFunctionForPerson(model: Person)
    func yourDelegateFunctionForAnimal(model: Animal)
}


enum CellTypes: String{
    case person = "personCell"
    case animal = "animalCell"
}

Base Model

class BaseModel{
    var type: CellTypes

    init(type: CellTypes) {
        self.type = type
    }
}

Person Model

class Person: BaseModel{
    var name: String
    init(name: String, type: CellTypes) {
        self.name = name
        super.init(type: type)
    }
}

Animal Model

class Animal: BaseModel{
    var weight: String
    init(weight: String, type: CellTypes) {
        self.weight = weight
        super.init(type: type)
    }
}

Base Cell

class BaseCell: UITableViewCell{
    var model: BaseModel?
}

Person Cell

class PersonCell: BaseCell{
    override var model: BaseModel?{
        didSet{
            guard let model = model as? Person else {fatalError("Wrong Model")}
            // do what ever you want with this Person Instance
        }
    }
}

Animal Cell

class AnimalCell: BaseCell{
    override var model: BaseModel?{
        didSet{
            guard let model = model as? Animal else {fatalError("Wrong Model")}
            // do what ever you want with this Animal Instance
        }
    }
}

View Controller

    class ViewController: UIViewController{
    @IBOutlet weak var tableView: UITableView!

    var list = [BaseModel]()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupList()
    }

    func setupList(){
        let person = Person(name: "John Doe", type: .person)
        let animal = Animal(weight: "80 KG", type: .animal)
        list.append(person)
        list.append(animal)
        tableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource{

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let model = list[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: model.type.rawValue, for: indexPath) as! BaseCell
        cell.model = model
        cell.delegate = self
        return cell
    }

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

extension ViewController: MyDelegate{
    func yourDelegateFunctionForPerson(model: Person) {

    }

    func yourDelegateFunctionForAnimal(model: Person) {

    }


}

MyDelegate protocol is used to perform "Tap" actions CellTypes enums is used to identify Cell Type and for dequeuing All of the Model class will inherit BaseModel which is quite useful and will eliminate the need to typecase in cellForRow at function. and All the tableViewCells have inherited BaseCell which holds two variables ie model and delegate. these are overridden in Person and Animal Cell.

Edit : Risk of losing Type Safety can certainly be reduced if you specify the 'celltype' directly in super.init() in model class. eg

class Person: BaseModel{
    var name: String
    init(name: String) {
        self.name = name
        super.init(type: .person)
    }
}

As cells are being dequeued with 'type' variable.. correct model will be supplied to correct cell.

I would create a protocol for the rows to be used in the data source array

protocol TableRow {
    var view: UITableViewCell.Type {get}
    func fill(_ cell: UITableViewCell)
}

And then create different row structs that conforms to this protocol

struct PersonRow: TableRow {
    var view: UITableViewCell.Type
    var model: Person

    func fill(_ cell: UITableViewCell) {
        cell.textLabel?.text = model.name
    }
}

struct AnimalRow: TableRow {
    var view: UITableViewCell.Type
    var model: Animal

    func fill(_ cell: UITableViewCell) {
        cell.textLabel?.text = String(model.age)
    }
}

Then the data source would be defined as

var rows: [TableRow]()

and any type conforming to the TableRow protocol can be added

rows.append(PersonRow(view: PersonView.self, model: person))
rows.append(AnimalRow(view: AnimalView.self, model: animal))

and setting values for a cell would be done by calling fill

let cell = tableView.dequeue(row.view, for: indexPath)    
row.fill(cell)

I understand what you want to implement. There is a small library in Swift for this thing. https://github.com/maxsokolov/TableKit

The most interesting part here for you is ConfigurableCell, it will solve your problem if you will just copy this protocol to your project: https://github.com/maxsokolov/TableKit/blob/master/Sources/ConfigurableCell.swift

Basic idea is following:

public protocol ConfigurableCell {

    associatedtype CellData

    static var reuseIdentifier: String { get }
    static var estimatedHeight: CGFloat? { get }
    static var defaultHeight: CGFloat? { get }

    func configure(with _: CellData)
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM