Home Visitor Pattern in iOS (Swift)
Post
Cancel

Visitor Pattern in iOS (Swift)

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

參考資料

===

View the English version of this article here.

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



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

Slack 打造全自動 WFH 員工健康狀況回報系統

Leading Snowflakes 閱讀筆記