TableViewにおけるVisitorパターン
Visitorパターンを使ってTableViewの可読性と拡張性を向上させる

Photo by Alex wong
はじめに
前回の「 Visitor Pattern in Swift 」でVisitorパターンと簡単な実務例を紹介しましたが、今回はiOSの開発現場での別の実際の応用例を紹介します。
要件シナリオ
動的なウォール機能を開発するには、複数の異なるタイプのセクションを動的に組み合わせて表示する必要があります。
StreetVoice のタイムラインを例にすると:

上の図のように、ダイナミックウォールは複数の異なるタイプのブロックが動的に組み合わさって構成されています:
-
Type A: イベントフィード
-
Type B: トラッキングおすすめ
-
Type C: 新曲の動的コンテンツ
-
Type D: 新アルバムの動的コンテンツ
-
Type E: 新しいトラッキングフィード
-
Type …. さらに表示
タイプは将来的に機能のアップデートに伴い増えていくことが予想されます。
問題
架構設計がない場合、コードは次のようになることがあります:
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パターンによる解決策
なぜ?
オブジェクトの関係を整理しました。以下の図をご覧ください:

私たちは多くの種類の DataSource (ViewObject) が多様な操作器とやり取りする必要があり、これは典型的な Visitor Double Dispatch です。
どうやって?
デモコードを簡素化するために、以下のように PlainTextFeedViewObject(テキストのみのフィード)、MemoriesFeedViewObject(デイリーメモリー)、MediaFeedViewObject(画像フィード)を使用して設計を示します。
Visitor Pattern を適用したアーキテクチャ図は以下の通りです:

まずは Visitor インターフェースを定義します。このインターフェースは、オペレーターが受け入れ可能なデータソースの型を抽象的に宣言するためのものです:
protocol FeedVisitor {
associatedtype T
func visit(_ viewObject: PlainTextFeedViewObject) -> T?
func visit(_ viewObject: MediaFeedViewObject) -> T?
func visit(_ viewObject: MemoriesFeedViewObject) -> T?
//...
}
各オペレーターは FeedVisitor インターフェースを実装します:
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 の対応を実装する。
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 の高さ対応の実装。
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をviewObjectで設定する
return nil
}
func visit(_ viewObject: MemoriesFeedViewObject) -> Any? {
guard let cell = cell as? MediaFeedTableViewCell else { return nil }
// cellをviewObjectで設定する
return nil
}
func visit(_ viewObject: PlainTextFeedViewObject) -> Any? {
guard let cell = cell as? MediaFeedTableViewCell else { return nil }
// cellをviewObjectで設定する
return nil
}
}
ViewObject <-> Cell の対応方法の実装。
新しい DataSource (ViewObject) をサポートする必要がある場合は、FeedVisitor インターフェースに新しいメソッドを追加し、各オペレーターで対応するロジックを実装するだけです。
DataSource(ViewObject)とオペレーターの結びつき:
protocol FeedViewObject {
@discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?
}
ViewObject が実装するバインディングインターフェース:
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)
}
}
struct MediaFeedViewObject: FeedViewObject {
func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
return visitor.visit(self)
}
}
UITableViewでの実装:
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 によって変換されました。



コメント