文章

Visitor Pattern 在 iOS Swift 分享功能应用|设计模式实务解析与最佳架构优化

iOS 开发者面对多平台分享功能需求,透过 Visitor Pattern 解决资料结构与分享逻辑混乱问题,提升程式码低耦合高聚合,实现灵活扩充与维护,避免过度设计带来的困扰。

Visitor Pattern 在 iOS Swift 分享功能应用|设计模式实务解析与最佳架构优化

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。

文章目录


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

参考资料

延伸阅读

有任何问题及指教欢迎 与我联络


🍺 Buy me a beer on PayPal

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

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

本文由作者以 CC BY 4.0 授权。