記事

Visitor Pattern|TableViewの可読性と拡張性を劇的に向上させる方法

TableViewの開発で直面する複雑なコード管理を解消し、Visitor Patternを活用して可読性と拡張性を大幅に改善。効率的な設計で保守性を高める具体的な手法を紹介します。

Visitor Pattern|TableViewの可読性と拡張性を劇的に向上させる方法

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


TableViewにおけるVisitorパターン

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: 新しいトラッキングフィード

  • タイプ …. もっと見る

タイプは将来的に機能のアップデートに伴い増えていくことが予想されます。

問題

架構設計がない場合、コードは次のようになる可能性があります:

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
        // viewObject/viewModelでセルを設定...
        return cell
    case .newSong:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newSong", for: indexPath) as! NewSongCell
        // viewObject/viewModelでセルを設定...
        return cell
    case .newEvent:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newEvent", for: indexPath) as! NewEventCell
        // viewObject/viewModelでセルを設定...
        return cell
    case .newText:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newText", for: indexPath) as! NewTextCell
        // viewObject/viewModelでセルを設定...
        return cell
    case .newPhotos:
        let cell = tableView.dequeueReusableCell(withIdentifier: "newPhotos", for: indexPath) as! NewPhotosCell
        // 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
    }
}
  • テストが難しい:どのタイプにどの対応するロジックがあるかをテストするのが難しい

  • 拡張・保守が困難:新しいタイプを追加する際に、この ViewController を変更する必要がある;cellForRow、heightForRow、willDisplay…が各関数に散らばっており、変更漏れや誤りが起きやすい

  • 読みにくい:すべてのロジックが View に集中している

Visitor Pattern 解決策

なぜ?

オブジェクト関係を整理しました。以下の図をご覧ください:

私たちは多くの種類の DataSource (ViewObject) があり、さまざまな種類のオペレーターとやり取りする必要があります。これは典型的な Visitor Double Dispatch の例です。

どうやって?

デモコードを簡略化するために、以下のように PlainTextFeedViewObject(テキストのみのフィード)、MemoriesFeedViewObject(毎日の思い出)、MediaFeedViewObject(画像フィード)を使用して設計を表現します。

Visitorパターンを適用したアーキテクチャ図は以下の通りです:

まず 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 の高さ対応の実装。

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()
        ]
        // 追加のセットアップをここに記述します。
    }
}

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 で実装するだけです。新しいオペレーターを追加する場合も、新しいクラスを作成して実装すればよいです。

  • 閲覧:各オペレーターオブジェクトを参照するだけで、ページ内の各ビューの構成ロジックが理解できます

完全なプロジェクト

ざわめき…

2022/07 思考の低迷期に執筆した記事です。内容に不十分な点や誤りがあれば、どうかご容赦ください!

関連記事

ご質問やご意見がありましたら、こちらからご連絡ください

Post は Medium から ZMediumToMarkdown によって変換されました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。