Post

Visitor Pattern in iOS (Swift)

Design Pattern Visitor 在 iOS 開發的實際應用場景分析

Visitor Pattern in iOS (Swift)

ℹ️ℹ️ℹ️ Click here to view the English version of this article, translated by OpenAI.


Visitor Pattern in Swift (Share Object to XXX Example)

Visitor Pattern 的實際應用場景分析 (在分享 商品、歌曲、文章… 到 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

前言

「Design Pattern」從知道有這個東西到現在也超過 10 年了依然沒辦法有自信的說能完全掌握,一直以來都是矇矇懂懂的,也好幾次從頭到尾把所有模式都看過一遍,但看了沒內化、沒在實務上應用很快就忘了。

我真的廢。

內功與招式

曾經看到的一個很好的比喻 ,招式部分如:PHP、Laravel、iOS、Swift、SwiftUI…之類的應用,其實在其中切換學習門檻都不算高;但內功部分如:演算法、資料結構、設計模式…等等都屬於內功;內功與招式之間有著相輔相成的效果;但是招式好學,內功難練;招式厲害的內功不一定厲害,內功厲害的也可以很快學會招式,所以與其說相輔相成不如說內功才是基礎,搭配招式才能所向披靡。

找到適合自己的學習方式

基於之前的學習經驗,我認為適合我自己的學習 Design Pattern 方式是 — 先精再通;先著重於精通幾個模式,要能內化跟靈活運用,還要培養出嗅覺,能判斷什麼場景適合什麼場景不適合;再一步一步的累積新模式,直到全部掌握;我覺得最好的方式就是多找實務場境,從應用中學習。

學習資源

推薦兩個免費的學習資源

Visitor — Behavioral Patterns

第一章紀錄的是 Visitor Pattern,這也是在街聲工作一年挖到的金礦之一,在 StreetVoice App 中有諸多善用 Visitor 解決架構問題的地方;我也在這段經歷之中席的了 Visitor 的原理精髓;所以第一章就來寫它!

Visitor 是什麼

首先請先了解 Visitor 是什麼?想要解決什麼問題?組成結構是什麼?

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

圖片取自 refactoringguru

詳細內容這邊不再重複贅述,請先直接參考 refactoringguru 對於 Visitor 的講解

iOS 實務場景 — 分享功能

假設今天我們有以下幾個 Model:UserModel、SongModel、PlaylistModel 這三個 Model,現在我們要實作分享功能,可以分享到:Facebook、Line、Instagram,這三個平台;每個 Model 需要呈現的分享訊息皆為不同、每個平台需要的資料也各有不同:

組合場景如上圖,第一個表格顯示各 Model 的客製化內容、第二個表格顯示各分享平台需要的資料。

尤其 Instagram 在分享 Playlist 時要多張圖片,跟其他分享要的 source 不一樣。

定義 Model

首先把各個 Model 有哪些 Property 定義完成:

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
// Model
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
}

// Data

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() {
        // call Facebook share sdk...
        print("Share to Facebook...")
        print("[![\(self.title)](\(String(describing: self.imageURLStrings.first))](\(self.urlString))")
    }

    func shareToInstagram() {
        // call Instagram share sdk...
        print("Share to Instagram...")
        print(self.imageURLStrings.joined(separator: ","))
    }

    func shareToLine() {
        // call Line share sdk...
        print("Share to Line...")
        print("[\(self.title)](\(self.urlString))")
    }
}

沒啥好說的,就是 0 架構全攪和在一起,如果今天要新加一個分享平台、更改某個平台的分享資訊、增加一個可分享的 Model 都要動到 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() {
        // call Facebook share sdk...
        print("Share to 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() {
        // call Instagram share sdk...
        print("Share to Instagram...")
        print(model.getShareImageURLStrings().joined(separator: ","))
    }
}

class LineShare: ShareManagerProtocol {
    let model: Shareable

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

    func share() {
        // call Line share sdk...
        print("Share to Line...")
        print("[\(model.getShareText())](\(model.getShareURLString())")
    }
}

我們抽離出一個 CanShare Protocol,凡是 Model 有遵循這個協議都能支援分享;分享的部分也抽離出 ShareManagerProtocol,有新的分享只要實現協議內容即可、要修改刪除也都不會影響其他 ShareManager。

但 getShareImageURLStrings 依然詭異,另外假設今天新增的分享平台需求的 Model 資料天壤之別,例如微信分享還需要播放次數、創建日期…等資訊,只有他要,這時候就會開始變得混亂。

Visitor

使用 Visitor Pattern 的解法。

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 Version
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) {
        // call Facebook share sdk...
        print("Share to Facebook...")
        print("[![Hi 跟你分享一位很讚的藝人\(model.name)。](\(model.profileImageURLString)](https://zhgchg.li/user/\(model.id)")
    }
    
    func visit(model: SongModel) {
        // call Facebook share sdk...
        print("Share to Facebook...")
        print("[![Hi 與你分享剛剛聽到一首很讚的歌,\(model.user.name)\(model.name),他被播方式。](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)")
    }
    
    func visit(model: PlaylistModel) {
        // call Facebook share sdk...
        print("Share to Facebook...")
        print("[![Hi 這個歌單我聽個不停 \(model.name)。](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)")
    }
}

class ShareToLineVisitor: SharePolicy {
    func visit(model: UserModel) {
        // call Line share sdk...
        print("Share to Line...")
        print("[Hi 跟你分享一位很讚的藝人\(model.name)。](https://zhgchg.li/user/\(model.id)")
    }
    
    func visit(model: SongModel) {
        // call Line share sdk...
        print("Share to Line...")
        print("[Hi 與你分享剛剛聽到一首很讚的歌,\(model.user.name)\(model.name),他被播方式。]](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)")
    }
    
    func visit(model: PlaylistModel) {
        // call Line share sdk...
        print("Share to Line...")
        print("[Hi 這個歌單我聽個不停 \(model.name)。](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)")
    }
}

class ShareToInstagramVisitor: SharePolicy {
    func visit(model: UserModel) {
        // call Instagram share sdk...
        print("Share to Instagram...")
        print(model.profileImageURLString)
    }
    
    func visit(model: SongModel) {
        // call Instagram share sdk...
        print("Share to Instagram...")
        print(model.coverImageURLString)
    }
    
    func visit(model: PlaylistModel) {
        // call Instagram share sdk...
        print("Share to Instagram...")
        print(model.songs.map({ $0.coverImageURLString }).joined(separator: ","))
    }
}

// Use case
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 去組合需要的資源
  • 假設今天多一個微信分享,他要的資料比較特別(播放次數、創建日期),也不會影響現有程式碼,因為他能從 concrete model 拿到他自己需要的資訊。

達成低耦合、高聚合的程式開發目標。

以上是經典的 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) {
        // map UserModel to coredata
    }
    
    func visit(model: SongModel) {
        // map SongModel to coredata
    }
    
    func visit(model: PlaylistModel) {
        // map PlaylistModel to coredata
    }
}

其他應用:Save、Like、tableview/collectionview cellforrow….

原則

最後講一下一些共通原則

  • Code 是給人讀的,切勿 Over Designed
  • 統一很重要,同樣的場境同個 Codebase 應該使用同個架構方法
  • 如果範圍是可控的或不可能出現其他狀況,這時候如果還繼續往下拆分就可以認為是 Over Designed
  • 多應用、少發明;Design Pattern 已經在軟體設計領域好幾十年,他所考量到的場景一定比我們創造一個新的架構還來的完善
  • 看不懂 Design Pattern 可以學,但如果是自己創造的架構就比較難說服別人學,因為學了可能也只能用在這個 Case 上,他就不是一個 Common sense
  • 程式碼重複不代表不好,如果一昧追求封裝可能導致 Over Designed;一樣回到前面幾點,程式是給人讀的,所以只要是好讀加上低耦合高聚合都是好的 Code
  • 勿魔改 Pattern,人家設計一定有他的道理,如果亂魔改可能導致某些場景出現問題
  • 只要開始繞路就會越繞越遠,程式會越來越髒

inspired by @saiday

參考資料

延伸閱讀

有任何問題及指教歡迎 與我聯絡


本文首次發表於 Medium ➡️ 前往查看

ZMediumToMarkdownMedium-to-jekyll-starter 提供自動轉換與同步技術。

Improve this page on Github.

Buy me a beer

1,130 Total Views
Last Statistics Date: 2025-03-29 | 1,065 Views on Medium.
This post is licensed under CC BY 4.0 by the author.