Post

Visitor Pattern|提升 TableView 可扩充性与可维护性设计范例

针对多类型动态墙 TableView,运用 Visitor Pattern 解决难测试、难扩充与难阅读问题,实现单一职责分离,轻松新增资料来源与操作器,提升程式码品质与维护效率。

Visitor Pattern|提升 TableView 可扩充性与可维护性设计范例

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


Visitor Pattern in TableView

使用 Visitor Pattern 增加 TableView 的阅读和扩充性

Photo by [Alex wong](https://unsplash.com/@killerfvith?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Alex wong

前言

接上篇「 Visitor Pattern in Swift 」介绍 Visitor 模式及一个简单的实务应用场景,此篇将介绍另一个在 iOS 需求开发上的实际应用。

需求场景

要开发一个动态墙功能,有多种不同类型的区块需要动态组合显示。

以 StreetVoice 的动态墙为例:

如上图所示,动态墙是由多种不同类型的区块动态组合而成:

  • Type A: 活动动态

  • Type B: 追踪推荐

  • Type C: 新歌动态

  • Type D: 新专辑动态

  • Type E: 新追纵动态

  • Type …. 更多

类型可预期会在未来随著功能迭代越来越多。

问题

在没有任何架构设计的情况下 Code 可能会长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let row = datas[indexPath.row]
    switch row.type {
    case .invitation:
        let cell = tableView.dequeueReusableCell(withIdentifier: "invitation", for: indexPath) as! InvitationCell
        // config cell with viewObject/viewModel...
        return cell
    case .newSong:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newSong", for: indexPath) as! NewSongCell
        // config cell with viewObject/viewModel...
        return cell
    case .newEvent:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newEvent", for: indexPath) as! NewEventCell
        // config cell with viewObject/viewModel...
        return cell
    case .newText:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newText", for: indexPath) as! NewTextCell
        // config cell with viewObject/viewModel...
        return cell
    case .newPhotos:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newPhotos", for: indexPath) as! NewPhotosCell
        // config cell with viewObject/viewModel...
        return cell
    }
}

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    let row = datas[indexPath.row]
    switch row.type {
    case .invitation:
        if row.isEmpty {
            return 100
        } else {
            return 300
        }
    case .newSong:
        return 100
    case .newEvent:
        return 200
    case .newText:
        return UITableView.automaticDimension
    case .newPhotos:
        return UITableView.automaticDimension
    }
}
  • 难以测试:什么 Type 有什么对应的逻辑输出难以测试

  • 难以扩充维护:需要新增新 Type 时,都要更动此 ViewController;cellForRow、heightForRow、willDisplay…四散在各个 Function 内,难保忘记改,或改错

  • 难以阅读:全部逻辑都在 View 身上

Visitor Pattern 解决方案

Why?

整理了一下物件关系,如下图所示:

我们有许多种类型的 DataSource (ViewObject) 需要与多种类型的操作器做交互,是一个很典型的 Visitor Double Dispatch

How?

为简化 Demo Code 以下改用 PlainTextFeedViewObject 纯文字动态、 MemoriesFeedViewObject 每日回忆、 MediaFeedViewObject 图片动态,呈现设计。

套用 Visitor Pattern 的架构图如下:

首先定义出 Visitor 介面,此介面用途是抽象宣告出操作器能接受的 DataSource 类型:

1
2
3
4
5
6
7
protocol FeedVisitor {
    associatedtype T
    func visit(_ viewObject: PlainTextFeedViewObject) -> T?
    func visit(_ viewObject: MediaFeedViewObject) -> T?
    func visit(_ viewObject: MemoriesFeedViewObject) -> T?
    //...
}

各操作器实现 FeedVisitor 介面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FeedCellVisitor: FeedVisitor {
    typealias T = UITableViewCell.Type
    
    func visit(_ viewObject: MediaFeedViewObject) -> T? {
        return MediaFeedTableViewCell.self
    }
    
    func visit(_ viewObject: MemoriesFeedViewObject) -> T? {
        return MemoriesFeedTableViewCell.self
    }
    
    func visit(_ viewObject: PlainTextFeedViewObject) -> T? {
        return PlainTextFeedTableViewCell.self
    }
}

实现 ViewObject <-> UITableViewCell 对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct FeedCellHeightVisitor: FeedVisitor {
    typealias T = CGFloat
    
    func visit(_ viewObject: MediaFeedViewObject) -> T? {
        return 30
    }
    
    func visit(_ viewObject: MemoriesFeedViewObject) -> T? {
        return 10
    }
    
    func visit(_ viewObject: PlainTextFeedViewObject) -> T? {
        return 10
    }
}

实现 ViewObject <-> UITableViewCell Height 对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct FeedCellConfiguratorVisitor: FeedVisitor {
    
    private let cell: UITableViewCell
    
    init(cell: UITableViewCell) {
        self.cell = cell
    }
    
    func visit(_ viewObject: MediaFeedViewObject) -> Any? {
        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
        // cell.config(viewObject)
        return nil
    }
    
    func visit(_ viewObject: MemoriesFeedViewObject) -> Any? {
        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
        // cell.config(viewObject)
        return nil
    }
    
    func visit(_ viewObject: PlainTextFeedViewObject) -> Any? {
        guard let cell = cell as? MediaFeedTableViewCell else { return nil }
        // cell.config(viewObject)
        return nil
    }
}

实现 ViewObject <-> Cell 如何 Config 对应。

当需要支援新的 DataSource (ViewObject) 时,只需在 FeedVisitor 介面上多加一个开口,并在各操作器中实现对应的逻辑。

DataSource (ViewObject) 与操作器的绑定:

1
2
3
protocol FeedViewObject {
    @discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?
}

ViewObject 实现绑定的介面:

1
2
3
4
5
6
7
8
9
10
struct PlainTextFeedViewObject: FeedViewObject {
    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
        return visitor.visit(self)
    }
}
struct MemoriesFeedViewObject: FeedViewObject {
    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
        return visitor.visit(self)
    }
}

UITableView 中的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
final class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    private let cellVisitor = FeedCellVisitor()
    
    private var viewObjects: [FeedViewObject] = [] {
        didSet {
            viewObjects.forEach { viewObject in
                let cellName = viewObject.accept(visitor: cellVisitor)
                tableView.register(cellName, forCellReuseIdentifier: String(describing: cellName))
            }
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.delegate = self
        tableView.dataSource = self
        
        viewObjects = [
            MemoriesFeedViewObject(),
            MediaFeedViewObject(),
            PlainTextFeedViewObject(),
            MediaFeedViewObject(),
            PlainTextFeedViewObject(),
            MediaFeedViewObject(),
            PlainTextFeedViewObject()
        ]
        // Do any additional setup after loading the view.
    }
}

extension ViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewObjects.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let viewObject = viewObjects[indexPath.row]
        let cellName = viewObject.accept(visitor: cellVisitor)
        
        let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: cellName), for: indexPath)
        let cellConfiguratorVisitor = FeedCellConfiguratorVisitor(cell: cell)
        viewObject.accept(visitor: cellConfiguratorVisitor)
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        let viewObject = viewObjects[indexPath.row]
        let cellHeightVisitor = FeedCellHeightVisitor()
        let cellHeight = viewObject.accept(visitor: cellHeightVisitor) ?? UITableView.automaticDimension
        return cellHeight
    }
}

结果

  • 测试:符合单一职责原则,可针对每个操作器的每个资料单点进行测试

  • 扩充维护:当需要支援新的 DataSource (ViewObject) 时只需在 Visitor 协议扩充一个开口,并在个别操作器 Visitor 上进行实现、需要抽离新操作器时,也只要 New 新的 Class 实现即可。

  • 阅读:只需浏览各操作器物件即可知道整个页面各个 View 的组成逻辑

完整专案

Murmur…

2022/07 思维低谷期中撰写的文章,内容如有描述不周、错误敬请海纳!

延伸阅读

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.