Post

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

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

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

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 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

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

Improve this page on Github.

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