記事

Visitor Pattern|iOS Swiftでの設計パターン活用法と実践例

iOS開発者向けにVisitor Patternの具体的な適用シーンを解説。設計の複雑さを軽減し、コードの拡張性を向上させる実践的な手法を紹介します。

Visitor Pattern|iOS Swiftでの設計パターン活用法と実践例

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

記事一覧


SwiftにおけるVisitorパターン(XXXへの共有オブジェクト例)

Visitor パターンの実際の適用シーン分析(商品、曲、記事などを Facebook、Line、Linkedin にシェアする場合)

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

Photo by Daniel McCullough

前書き

「デザインパターン」という存在を知ってからもう10年以上経ちますが、完全に理解していると自信を持って言えることはまだできません。ずっと何となく理解しているだけで、何度も最初から最後まで全てのパターンを見直しましたが、内面化できず、実務で使わなければすぐに忘れてしまいます。

私は本当にダメだ。

内功と技術

かつて見たとても良い比喩があります。技術部分、例えば PHP、Laravel、iOS、Swift、SwiftUI などのアプリケーションは、学習の敷居がそれほど高くありません。しかし、アルゴリズム、データ構造、デザインパターンなどの基礎力は「内功」にあたります。内功と技術は相互に補完し合う関係にありますが、技術は習いやすく、内功は習得が難しいです。技術が優れていても内功が優れているとは限らず、内功が優れていれば技術はすぐに習得できます。つまり、相互補完というよりは、内功が基礎であり、技術と組み合わせてこそ最強になるのです。

自分に合った学習方法を見つける

以前の学習経験に基づいて、私に合ったデザインパターンの学び方は「先に深く理解し、その後広く習得する」ことだと思います。まずいくつかのパターンを深くマスターし、内面化して柔軟に使いこなせるようにします。そして、どの場面にどのパターンが適しているか判断できる感覚を養います。その後、新しいパターンを一つずつ積み重ねて、最終的に全てを習得します。最良の方法は多くの実務の場面を探し、実践から学ぶことだと感じています。

学習リソース

おすすめの無料学習リソースを2つ紹介します。

Visitor — 行動パターン

第一章は Visitor Pattern について記録しています。これは街声での1年間の仕事の中で掘り当てた金鉱の一つでもあり、StreetVoice アプリには Visitor を活用して構造問題を解決した箇所が多くあります。この経験を通じて Visitor の原理と本質を理解できました。だからこそ、第一章でこれを書きます!

Visitorとは何か

まずは Visitor とは何かを理解しましょう。Visitor はどんな問題を解決するためのものか?その構成要素は何か?

圖片取自 [refactoringguru](https://refactoringguru.cn/design-patterns/visitor){:target="_blank"}

画像は refactoringguru から引用しています

詳細な内容はここで繰り返しませんので、まずは refactoringguru の Visitor に関する解説 をご参照ください。

iOS 実務シーン — 共有機能

今日、以下の3つのモデルがあるとします:UserModel、SongModel、PlaylistModel。この3つのモデルに対して共有機能を実装します。共有先はFacebook、Line、Instagramの3つのプラットフォームです。各モデルが表示する共有メッセージは異なり、各プラットフォームが必要とするデータもそれぞれ異なります。

組み合わせのシナリオは上図の通りで、最初の表は各モデルのカスタマイズ内容を示し、二番目の表は各共有プラットフォームが必要とするデータを示しています。

特に Instagram はプレイリストを共有する際に複数の画像が必要で、他の共有先とは異なるリソースが求められます。

Modelの定義

まず各モデルのプロパティ定義を完了させます:

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
// モデル
struct UserModel {
    let id: String
    let name: String
    let profileImageURLString: String
}

struct SongModel {
    let id: String
    let name: String
    let user: UserModel
    let coverImageURLString: String
}

struct PlaylistModel {
    let id: String
    let name: String
    let user: UserModel
    let songs: [SongModel]
    let coverImageURLString: String
}

// データ

let user = UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png")

let song = SongModel(id: "1",
                     name: "Wake me up",
                     user: user,
                     coverImageURLString: "https://zhgchg.li/cover/1.png")

let playlist = PlaylistModel(id: "1",
                            name: "Avicii Tribute Concert",
                            user: user,
                            songs: [
                                song,
                                SongModel(id: "2", name: "Waiting for love", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/3.png"),
                                SongModel(id: "3", name: "Lonely Together", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/1.png"),
                                SongModel(id: "4", name: "Heaven", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/4.png"),
                                SongModel(id: "5", name: "S.O.S", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/5.png")],
                            coverImageURLString: "https://zhgchg.li/playlist/1.png")

何も考えずにやる方法

アーキテクチャを全く考慮せず、とにかく何も考えずに最も汚い方法をまず実装する。

周星馳 — 食神

周星馳 — 食神

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
class ShareManager {
    private let title: String
    private let urlString: String
    private let imageURLStrings: [String]

    init(user: UserModel) {
        self.title = "Hi 素敵なアーティスト\(user.name)をシェアします。"
        self.urlString = "https://zhgchg.li/user/\(user.id)"
        self.imageURLStrings = [user.profileImageURLString]
    }

    init(song: SongModel) {
        self.title = "Hi 今聴いた素敵な曲、\(song.user.name)\(song.name)をシェアします。"
        self.urlString = "https://zhgchg.li/user/\(song.user.id)/song/\(song.id)"
        self.imageURLStrings = [song.coverImageURLString]
    }

    init(playlist: PlaylistModel) {
        self.title = "Hi このプレイリストがずっと聴き続けています \(playlist.name)。"
        self.urlString = "https://zhgchg.li/user/\(playlist.user.id)/playlist/\(playlist.id)"
        self.imageURLStrings = playlist.songs.map({ $0.coverImageURLString })
    }

    func shareToFacebook() {
        // FacebookシェアSDKを呼び出す...
        print("Facebookにシェアします...")
        print("[![\(self.title)](\(String(describing: self.imageURLStrings.first))](\(self.urlString))")
    }

    func shareToInstagram() {
        // InstagramシェアSDKを呼び出す...
        print("Instagramにシェアします...")
        print(self.imageURLStrings.joined(separator: ","))
    }

    func shareToLine() {
        // LineシェアSDKを呼び出す...
        print("Lineにシェアします...")
        print("[\(self.title)](\(self.urlString))")
    }
}

特に言うことはありません。つまり、0のアーキテクチャがすべて混ざり合っています。もし新しい共有プラットフォームを追加したり、あるプラットフォームの共有情報を変更したり、共有可能なモデルを増やしたりすると、必ず ShareManager を変更しなければなりません。また、imageURLStrings の設計は、Instagram がプレイリストを共有する際に画像の集合が必要なため配列として宣言しましたが、これは因果が逆転して要件に合わせてアーキテクチャを設計してしまい、画像集合を必要としない他のタイプまで影響を受けてしまっています。

少し最適化する

少しロジックを分離しましょう。

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
protocol Shareable {
    func getShareText() -> String
    func getShareURLString() -> String
    func getShareImageURLStrings() -> [String]
}

extension UserModel: Shareable {
    func getShareText() -> String {
        return "Hi とても素敵なアーティスト \(self.name) をシェアします。"
    }

    func getShareURLString() -> String {
        return "https://zhgchg.li/user/\(self.id)"
    }

    func getShareImageURLStrings() -> [String] {
        return [self.profileImageURLString]
    }
}

extension SongModel: Shareable {
    func getShareText() -> String {
        return "Hi ちょうど聴いた素晴らしい曲、\(self.user.name)\(self.name) をシェアします。"
    }

    func getShareURLString() -> String {
        return "https://zhgchg.li/user/\(self.user.id)/song/\(self.id)"
    }

    func getShareImageURLStrings() -> [String] {
        return [self.coverImageURLString]
    }
}

extension PlaylistModel: Shareable {
    func getShareText() -> String {
        return "Hi このプレイリストがずっと聴き続けています \(self.name)。"
    }

    func getShareURLString() -> String {
        return "https://zhgchg.li/user/\(self.user.id)/playlist/\(self.id)"
    }

    func getShareImageURLStrings() -> [String] {
        return [self.coverImageURLString]
    }
}

protocol ShareManagerProtocol {
    var model: Shareable { get }
    init(model: Shareable)
    func share()
}

class FacebookShare: ShareManagerProtocol {
    let model: Shareable

    required init(model: Shareable) {
        self.model = model
    }

    func share() {
        // FacebookシェアSDKを呼び出す...
        print("Facebookにシェアしています...")
        print("[![\(model.getShareText())](\(String(describing: model.getShareImageURLStrings().first))](\(model.getShareURLString())")
    }
}

class InstagramShare: ShareManagerProtocol {
    let model: Shareable

    required init(model: Shareable) {
        self.model = model
    }

    func share() {
        // InstagramシェアSDKを呼び出す...
        print("Instagramにシェアしています...")
        print(model.getShareImageURLStrings().joined(separator: ","))
    }
}

class LineShare: ShareManagerProtocol {
    let model: Shareable

    required init(model: Shareable) {
        self.model = model
    }

    func share() {
        // LineシェアSDKを呼び出す...
        print("Lineにシェアしています...")
        print("[\(model.getShareText())](\(model.getShareURLString())")
    }
}

私たちは CanShare プロトコルを抽出しました。このプロトコルに準拠するモデルはすべて共有をサポートできます。共有部分も ShareManagerProtocol に分離しており、新しい共有機能はプロトコルを実装するだけで追加でき、修正や削除も他の ShareManager に影響を与えません。

しかし、getShareImageURLStrings は依然として奇妙です。さらに、例えば新しく追加された共有プラットフォームのモデルデータが全く異なる場合、例えばWeChatの共有では再生回数や作成日などの情報が必要で、それがそのプラットフォームだけの場合、ここで混乱が始まります。

ビジター

Visitor パターンを使った解決方法。

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// Visitorバージョン
protocol Shareable {
    func accept(visitor: SharePolicy)
}

extension UserModel: Shareable {
    func accept(visitor: SharePolicy) {
        visitor.visit(model: self)
    }
}

extension SongModel: Shareable {
    func accept(visitor: SharePolicy) {
        visitor.visit(model: self)
    }
}

extension PlaylistModel: Shareable {
    func accept(visitor: SharePolicy) {
        visitor.visit(model: self)
    }
}

protocol SharePolicy {
    func visit(model: UserModel)
    func visit(model: SongModel)
    func visit(model: PlaylistModel)
}

class ShareToFacebookVisitor: SharePolicy {
    func visit(model: UserModel) {
        // Facebook共有SDKを呼び出す...
        print("Facebookに共有します...")
        print("[![Hi 素敵なアーティスト\(model.name)をシェアします。](\(model.profileImageURLString)](https://zhgchg.li/user/\(model.id)")
    }
    
    func visit(model: SongModel) {
        // Facebook共有SDKを呼び出す...
        print("Facebookに共有します...")
        print("[![Hi 今聴いた素敵な曲、\(model.user.name)\(model.name)、再生方法です。](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)")
    }
    
    func visit(model: PlaylistModel) {
        // Facebook共有SDKを呼び出す...
        print("Facebookに共有します...")
        print("[![Hi このプレイリストはずっと聴いています \(model.name)。](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)")
    }
}

class ShareToLineVisitor: SharePolicy {
    func visit(model: UserModel) {
        // Line共有SDKを呼び出す...
        print("Lineに共有します...")
        print("[Hi 素敵なアーティスト\(model.name)をシェアします。](https://zhgchg.li/user/\(model.id)")
    }
    
    func visit(model: SongModel) {
        // Line共有SDKを呼び出す...
        print("Lineに共有します...")
        print("[Hi 今聴いた素敵な曲、\(model.user.name)\(model.name)、再生方法です。]](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)")
    }
    
    func visit(model: PlaylistModel) {
        // Line共有SDKを呼び出す...
        print("Lineに共有します...")
        print("[Hi このプレイリストはずっと聴いています \(model.name)。](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)")
    }
}

class ShareToInstagramVisitor: SharePolicy {
    func visit(model: UserModel) {
        // Instagram共有SDKを呼び出す...
        print("Instagramに共有します...")
        print(model.profileImageURLString)
    }
    
    func visit(model: SongModel) {
        // Instagram共有SDKを呼び出す...
        print("Instagramに共有します...")
        print(model.coverImageURLString)
    }
    
    func visit(model: PlaylistModel) {
        // Instagram共有SDKを呼び出す...
        print("Instagramに共有します...")
        print(model.songs.map({ $0.coverImageURLString }).joined(separator: ","))
    }
}

// 使用例
let shareToInstagramVisitor = ShareToInstagramVisitor()
user.accept(visitor: shareToInstagramVisitor)
playlist.accept(visitor: shareToInstagramVisitor)

私たちは行ごとに何をしたか見ていきます:

  • まず、Shareable という Protocol を作成しました。これは単に Model が共有をサポートしていることを管理しやすくするためであり、Visitor に統一されたインターフェースを持たせるためです(定義しなくても構いません)。

  • UserModel/SongModel/PlaylistModel は Shareable の func accept(visitor: SharePolicy) を実装し、今後共有対応の Model が増えた場合もプロトコルを実装するだけで済みます。

  • SharePolicy を定義し、対応する Model を列挙する
    (must be concrete type) なぜ visit(model: Shareable) と定義しないのか疑問に思うかもしれませんが、そうすると前のバージョンの問題を繰り返してしまいます。

  • 各 Share メソッドは SharePolicy を実装し、それぞれ source に応じて必要なリソースを組み合わせる

  • もし今日、WeChat共有機能が追加されたとして、その共有に必要なデータが特別(再生回数、作成日)であっても、既存のコードには影響しません。なぜなら、WeChatは具体的なモデルから自身が必要とする情報を取得できるからです。

低結合・高凝集のプログラム開発目標を達成する。

以上はクラシックな Visitor Double Dispatch の実装ですが、日常の開発ではこのような状況に遭遇することはあまりありません。一般的には Visitor が一つだけの場合が多いです。しかし、このパターンの組み合わせは非常に適しています。例えば、SaveToCoreData という要件がある場合、accept(visitor: SaveToCoreDataVisitor) を直接定義し、Policy Protocol を多く宣言しなくても、良い構造として使えます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protocol Saveable {
  func accept(visitor: SaveToCoreDataVisitor)
}

class SaveToCoreDataVisitor {
    func visit(model: UserModel) {
        // UserModelをCoreDataにマッピングする
    }
    
    func visit(model: SongModel) {
        // SongModelをCoreDataにマッピングする
    }
    
    func visit(model: PlaylistModel) {
        // PlaylistModelをCoreDataにマッピングする
    }
}

その他の応用例:保存、いいね、tableview/collectionview の cellForRow…

原則

最後にいくつかの共通原則について述べます。

  • Code は人が読むもの、過剰設計は避けるべきです

  • 統一は非常に重要であり、同じ状況では同じコードベースで同じアーキテクチャや方法を使用すべきです。

  • もし範囲が制御可能で他の状況が発生し得ない場合、その時点でさらに分割を進めるのは過剰設計と考えられます。

  • 多くの応用、少ない発明;デザインパターンはソフトウェア設計の分野で何十年も使われており、新しいアーキテクチャを作るよりも考慮されたシナリオがはるかに充実しています。

  • デザインパターンは学べますが、自分で作ったアーキテクチャだと他人に学んでもらうのは難しいです。なぜなら、それを学んでも特定のケースでしか使えない可能性があり、一般的な常識にはならないからです。

  • コードの重複は必ずしも悪いことではありません。過度にカプセル化を追求するとオーバーデザインになる可能性があります。前述のポイントに戻りますが、コードは人が読むものなので、読みやすく、かつ低結合・高凝集であれば良いコードと言えます。

  • パターンを無理に改変しないでください。設計には必ず理由があり、むやみに改変すると特定の場面で問題が発生する可能性があります。

  • 回り道を始めると、どんどん遠回りになり、コードがどんどん汚くなっていきます。

@saidayさんにインスパイアされました

参考資料

関連記事

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

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 に基づき公開されています。