使用 Swift 实战 iOS 中的设计模式(翻译 Design Patterns On iOS)

使用 Swift 实战 iOS 中的设计模式翻译

原文: https://www.raywenderlich.com/477-design-patterns-on-ios-using-swift-part-1-2

使用 Swift 实战 iOS 中的设计模式 Part 1/2

通过这个教程, 学习构建 iOS Apps 中常见的设计模式, 以及如何运用到你自己的 Apps 中

iOS 设计模式 ——你可能听说过这个词,但是你知道它的意思吗? 虽然大多数开发人员可能都同意设计模式非常重要,但是关于这个主题的文章并不多,而且我们开发人员在编写代码时有时也不太注意设计模式。

设计模式是在软件设计中对常见问题的可复用的解决方案。它们是旨在帮助你编写易于理解和重用的代码的模板。 它们还帮助你创建低耦合的代码,使你可以更改或替换代码中的组件,而不会造成太多麻烦

如果你是设计模式的新手,那么我有好消息要告诉你! 首先,由于 Cocoa 的构建方式和鼓励使用的最佳实践,你已经使用了大量的 iOS 设计模式。 其次,本教程将使你了解在 Cocoa 中所有主要(和不那么主要)的 iOS 设计模式。

你将创建一个音乐库app,该app将显示你的专辑及其相关信息。

在开发这个应用程序的过程中,你将熟悉最常见的 Cocoa 设计模式:

  • 创建型 (Creational):单例模式 (Singleton)
  • 结构型 (Structural):MVC、装饰者模式 (Decorator)、适配器模式 (Adapter)、外观模式 (Facade)
  • 行为型 (Behavioral):观察者模式 (Observer)、备忘录模式 (Memento)

不要误以为这是一篇关于理论的文章; 你会在你的音乐应用程序中使用大部分的设计模式。 在教程结束的时候,你的应用会看起来像这样:

![](https://koenig-media.raywenderlich.com/uploads/2017/07/Fi
nalApp-180x320.png)

我们开始吧!

开始

下载 Starter 项目,解压缩 ZIP 文件的内容,并在 Xcode 打开 RWBlueLibrary.xcodeproj

项目中注解以下:

1.ViewController 有三个 IBOutlet 连接着 storyboard 中的 TableView, Undo 按钮, 和垃圾桶按钮

2.storyboard 有三个组件已经添加了约束, 顶部组件将展示专辑封面, 专辑封面底下是一个 tableview 展示专辑封面对应的列表信息, 最后一个 tool bar 工具栏 有两个按钮, 一个用来撤销动作,另一个用来删除你选择的相册. Storyboard 如下所示:

3.一个简单的 HTTP 客户端类 (HTTPClient) ,里面还没有什么内容,需要你去完善

注意:其实当你创建一个新的 Xcode 的项目的时候,你的代码里就已经有很多设计模式的影子了: MVC、委托、代理、单例

MVC - 设计模式之王

Model-view-controller (MVC)是 Cocoa 的构建模块之一,无疑是所有模式中使用最多的设计模式. 它根据应用程序中角色职责对对象进行划分和归类,并鼓励基于角色职责代码分离

这三个角色分别是:

  • Model: 模型, 对象保存数据以及定义如何操作数据, 例如项目中的 Album struct, 你可以从 Album.swift 中找到, 大多数应用中将有一个以上的类型作为其模型的一部分
  • View: 视图, 负责Model 的可视化的展示和用户可交互的控件对象, 所有的 UIView 对象基本上都是. 例如项目中的 AlbumView, 可以从 AlbumView.swift 中找到,
  • Controller: 控制器, 控制器是协调所有工作的中间人。 它访问来自模型的数据,并用视图显示数据,根据需要监听事件和操作数据。 你能猜出哪个类是你的控制器吗? 没错 ViewController

这种设计模式在应用程序中的良好实现意味着每个对象都属于这些组中的一个。

通过控制器实现的视图到模型之间的通信可以用下面的图表进行最好的描述:

当数据发生变化时,模型通知控制器,控制器反过来更新视图中的数据。 然后,视图可以通知控制器用户执行的操作,控制器将在必要时更新模型或检索任何请求的数据。

你可能想知道,为什么不直接丢弃 Controller,在同一个类中实现 ViewModel,因为这看起来容易得多。

这一切都归结为代码分离和可重用性。 理想情况下,视图应该与模型完全分离。如果视图不依赖于模型的特定实现,那么可以使用不同的模型重用它来呈现一些其他数据。

例如,如果将来你还想向图书馆添加电影或书籍,你仍然可以使用相同的 AlbumView 来显示你的电影和书籍对象。此外,如果你想创建一个与相册有关的新项目,你可以简单地重用你的相册结构,因为它不依赖于任何视图。 这就是 MVC 的强大之处!

如何使用 MVC 模式

首先,你需要确保项目中的每个类要么是 Controller,要么是 Model,要么是 View; 不要在一个类中结合两个角色的功能。
其次,为了确保你符合这种工作方法,你应该创建三个文件夹来保存代码,每个类别对应一个文件夹
点击 File New Group (或按 Command + Option + n)并将组名为 Model。 重复相同的过程来创建 View 和 Controller 组。
现在将 Album.swift 拖到 Model 组。 将 AlbumView.swift 拖拽到 View 组,最后将 ViewController.swift 拖拽到 Controller 组。

然后项目结构应该是这样的:

整理一下, 你的项目看起来已经好多了。 显然,你可以有其他组和类,但目前应用程序的核心包含在这三个类别中

现在你的组件已经组织好了,你需要从某个地方获取相册数据。 将创建一个 API 类,以便在整个代码中使用它来管理数据(这为讨论你的下一个设计模式提供了一个机会)。

单例模式

Singleton 设计模式确保给定类只存在一个实例,并且该实例具有全局访问点。 它通常使用延迟加载在第一次需要时创建单个实例。

注意: 苹果经常使用这种方法。 例如: UserDefaults.standard、 UIApplication.shared、 UIScreen.main、 filemanager. default 都返回一个 Singleton 对象。

你可能想知道为什么关心类是否有多个实例. 毕竟代码和内存很便宜,对吧?

在某些情况下,一个类只有一个实例是有意义的。 例如,你的应用程序只有一个实例,设备只有一个主屏幕,因此你只需要每个实例中的一个。 或者,使用一个全局配置处理程序类: 实现对单个共享资源(如配置文件)的线程安全访问比同时使用多个类修改配置文件更容易。

你应该注意什么?

单例模式很容易被滥用。

如果你遇到一种情况,你想使用一个单例模式,首先考虑其他方式来完成你的任务。
例如,如果你只是试图将信息从一个视图控制器传递到另一个视图控制器,那么单例模式就不合适。 相反,应该考虑通过初始值设定项或属性传递模型

如果你确定你确实需要一个单例,那么考虑一下单例是否更有意义。

多个实例会导致问题吗? 拥有自定义实例有用吗? 这些答案将决定你是否使用单例模式

单例模式存在问题的最常见原因是测试, 如果你将状态存储在全局对象(如单例对象)中,那么测试的顺序可能很重要,模拟它们可能会很痛苦。 这两个原因都让测试变得很痛苦。

最后,因为 “代码的味道”, 表明你的情况根本不适合作为单例, 举个例子, 如果你经常需要自定义实例, 你的用例最好使用常规对象

如何使用单例模式

为了确保你的单例模式只有一个实例,你必须使其他人不可能创建实例。 通过将初始化器标记为私有,Swift 允许你这样做。 然后,可以为共享实例添加静态属性,该属性在类中初始化。

你将通过创建一个单例类来管理所有的相册数据来实现这个模式。

你会注意到项目中有一个名为 API 的组; 你将把所有为你的应用程序提供服务的类放在这里。 通过右键单击组并选择 New File 在组中创建一个新文件。 选择 iOS Swift 文件。 将文件名设置为 LibraryAPI.swift 并单击 Create。

现在访问 LibraryAPI.swift 并插入以下代码:

1
2
3
4
5
final class LibraryAPI {
static let shared = LibraryAPI()
private init () {
}
}

以下是详细数据:

  • 用 static 修饰的 shared 静态常量, 给予其他对象访问 singleton 对象的权限
  • 用 private 实例化给外界提供一个新的 LibraryAPI 实例

现在有一个 Singleton 对象作为管理相册的入口点。 再进一步,创建一个类来处理 library 数据的持久性。

现在在 API 组中创建一个新文件。 选择 iOS > Swift File, 将类名设置为 PersistencyManager.swift,然后单击 Create。

打开 PersistencyManager.swift 并添加以下代码。

1
2
3
final class PersistencyManager {

}

卷曲的小括号里面写着:

private var albums = [Album]()

这里声明一个保存相册数据的私有属性。 该数组是可变的,所以你可以轻松地添加和删除相册。

现在将下面的初始化式添加到类中:

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
init() {
//Dummy list of albums
let album1 = Album(title: "Best of Bowie",
artist: "David Bowie",
genre: "Pop",
coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_david_bowie_best_of_bowie.png",
year: "1992")

let album2 = Album(title: "It's My Life",
artist: "No Doubt",
genre: "Pop",
coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_no_doubt_its_my_life_bathwater.png",
year: "2003")

let album3 = Album(title: "Nothing Like The Sun",
artist: "Sting",
genre: "Pop",
coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_sting_nothing_like_the_sun.png",
year: "1999")

let album4 = Album(title: "Staring at the Sun",
artist: "U2",
genre: "Pop",
coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_u2_staring_at_the_sun.png",
year: "2000")

let album5 = Album(title: "American Pie",
artist: "Madonna",
genre: "Pop",
coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_madonna_american_pie.png",
year: "2000")

albums = [album1, album2, album3, album4, album5]
}

在初始化器中,你将使用五个示例相册来填充数组。 如果你不喜欢以上的专辑,可以用你喜欢的音乐来代替。 :]

现在将下面的函数添加到类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func getAlbums() -> [Album] {
return albums
}

func addAlbum(_ album: Album, at index: Int) {
if (albums.count >= index) {
albums.insert(album, at: index)
} else {
albums.append(album)
}
}

func deleteAlbum(at index: Int) {
albums.remove(at: index)
}

这些方法允许你获取、添加和删除相册。

此时,你可能想知道 PersistencyManager 类从何而来,因为它不是 Singleton。 在下一节中,你将看到 LibraryAPI 和 PersistencyManager 之间的关系,在这一节中你将看到 Facade 外观设计模式。

Facade 外观模式

外观设计模式提供了一个到复杂子系统的单一接口。 你只公开一个简单的统一 API,而不是向用户公开一组类及其 API

下图解释了这个概念:

Api 的用户完全没有意识到其背后的复杂性。 当处理大量的类时,特别是当它们使用起来很复杂或者很难理解的时候,这种模式非常理想。

Facade 外观模式将使用系统的代码与你隐藏的类的接口和实现分离开来; 它还减少了外部代码对子系统内部工作的依赖性。 如果 facade 下的类可能发生更改,这也是有用的,因为 Facade 类可以保留相同的 API,而事情在幕后发生了变化。

例如,如果有一天你想要替换你的后端服务,你不需要改变使用你的 API 的代码,只需要改变你的 Facade 中的代码。

如何使用外观设计模式

目前,PersistencyManager 可以在本地保存相册数据,HTTPClient 可以处理远程通信。 项目中的其他类不应该知道这个逻辑,因为它们将隐藏在 LibraryAPI 的外观之后。

要实现这种模式,只有 LibraryAPI 应该拥有 PersistencyManagerHTTPClient 的实例。 然后,LibraryAPI 将公开一个简单的 API 来访问这些服务。

设计如下:

Libraryapi 将会暴露其他的代码,但是会隐藏 HTTPClientPersistencyManager 的复杂性,不会暴露应用程序的其他部分。

打开 LibraryAPI.swift,在该类中添加以下常量属性:

1
2
3
private let persistencyManager = PersistencyManager()
private let httpClient = HTTPClient()
private let isOnline = false

isOnline 决定是否应该对服务器的相册列表进行更新,例如添加或删除相册。 Http 客户端实际上并不使用真正的服务器,它只是用来演示 facade 模式的使用方法,因此 isOnline 总是false的。

接下来,将以下三种方法添加到 LibraryAPI.swift:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func getAlbums() -> [Album] {
return persistencyManager.getAlbums()
}

func addAlbum(_ album: Album, at index: Int) {
persistencyManager.addAlbum(album, at: index)
if isOnline {
httpClient.postRequest("/api/addAlbum", body: album.description)
}
}

func deleteAlbum(at index: Int) {
persistencyManager.deleteAlbum(at: index)
if isOnline {
httpClient.postRequest("/api/deleteAlbum", body: "\(index)")
}
}

看一下 addAlbum (: at:)。 这个类首先在本地更新数据,然后如果有互联网连接,它会更新远程服务器。 这就是 Facade 设计模式的真正优点; 当系统之外的某个类添加了一个新专辑时,它不知道, 也不需要知道, 隐藏在其下面的复杂性

注意: 在为子系统中的类设计 Facade 时,请记住,除非你构建一个单独的模块并使用访问控制,否则没有任何东西可以阻止客户端直接访问这些”隐藏”类。 所以不要吝啬于编写防御性的代码,也不要认为所有的客户都一定会以 Facade 相同方式使用你的类

构建并运行你的应用程序。 你将看到两个空视图和一个工具栏。 顶部视图将用于显示你的专辑封面,底部视图将用于显示与该专辑相关的信息表。

你将需要一些东西在屏幕上显示唱片集数据,这是你下一个设计模式的完美用法: Decorator 装饰者设计模式

The Decorator Design Pattern 装饰者设计模式

装饰者设计模式动态地将行为和职责添加到对象中,而不需要修改它的代码。这是一种替代方法,可以通过用另一个对象包装类来修改类的行为。

在 Swift 中有两个非常常见的实现: 扩展和授权(Extensions and Delegation)

Extensions 扩展

添加扩展是一种非常强大的机制,它允许你向现有的类、结构或枚举类型添加新功能,而无需添加子类。

真正了不起的是,你可以扩展无法访问的代码,并增强它们的功能。 这意味着你可以将自己的方法添加到 Cocoa 类中,例如 UIView 和 UIImage!

Swift 扩展与 decorator 装饰者模式的经典定义略有不同,因为扩展不能拥有类中的实例

如何使用扩展

假设你有一个专辑实例,你想在一个表格视图中呈现:

这些专辑的标题将从何而来? 相册Album是一个模型,所以它不关心你如何呈现数据。 你需要一些外部代码来将这个功能添加到相册结构中。

你将创建一个 Album 结构体(struct)的扩展; 它将定义一个返回数据结构的新方法,这个数据结构可以很容易地与 UITableView 一起使用

进入 Album.swift ,在文件末尾添加以下代码:

1
typealias AlbumData = (title: String, value: String)

这个 typealias 定义了一个元组,该元组包含表视图显示一行数据所需的所有信息。 现在添加以下扩展以访问此信息:

1
2
3
4
5
6
7
8
9
10
extension Album {
var tableRepresentation: [AlbumData] {
return [
("Artist", artist),
("Album", title),
("Genre", genre),
("Year", year)
]
}
}

AlbumData 数组将更容易在表视图中显示!

注意: 类当然可以覆盖超类的方法,但是如果使用扩展则不能。 扩展中的方法或属性不能与原始类中的方法或属性具有相同的名称。

想一想这个模式有多么强大:

  • 你从 Album直接使用属性
  • 你添加了属性到 Album 结构体中, 但不是通过修改它的方式
  • 这个简单的加法允许你返回一个 UITableView – (类似于Album).

Delegation 代理

装饰器设计模式的另一个实现方式是代理, 它是一种机制, 一个对象可以代表另一个对象, 或者与另一对象协同工作. UITableView 非常贪心的拥有两个 代理类型的属性, 一个叫做 data source 一个是 delegate. 它们做的事情略有不同——例如,表视图tableview询问其data source在特定区域中应该有多少行,但是它询问delegate在选择行时应该做什么

你不能期望 UITableView 知道你希望在每个部分中有多少行,因为这是特定于应用程序的。 因此,计算每个部分中的行数量的任务被传递到data source。 这允许 UITableView 类独立于它所显示的数据。

下面是一个伪解释对话,当你创建一个新的 UITableView 时会发生什么:

  • Table: hey我在这, 我想显示 cells, 我有多少个 sections?
  • Data source: 1个
  • Table: OK, 简单, 我的第一个sections 多少个 cells ?
  • Data source: 4 个
  • Table: 谢谢, 好的, 我要 section 0, row 0 的 cell
  • Data source: 给你
  • Table: 然后是 section 0, row 1? 等等

Uitableview 对象执行显示表视图的工作。 然而,最终它将需要一些它没有的信息。 然后,它转向它的data sourcedelegate,并发送一条要求提供附加信息的消息。

或许子类化对象和覆盖必要的方法可能看起来更容易,但是要考虑到你只能基于单个类进行子类化。 如果你希望一个对象是两个或更多其他对象的委托,那么你将无法通过子类化实现这一点。

注意: 这是一个重要的模式。 苹果在 UIKit 类中大多数都使用了这种方法: UITableView,UITextView,UITextField,UIWebView,UICollectionView,UIPickerView,uigesturecognuizer,UIScrollView。 这样的例子不胜枚举。

如何使用代理模式

打开 ViewController.swift 并将这些私有属性添加到类中:

1
2
3
private var currentAlbumIndex = 0
private var currentAlbumData: [AlbumData]?
private var allAlbums = [Album]()

从 Swift 4开始,标记为私有的变量可以在类型和该类型的任何扩展之间共享相同的访问控制范围。 如果你想浏览由 Swift 4推出的新功能,请看看什么是 Swift 4 的新功能

将 ViewController 作为表视图的data source。 将这个扩展添加到 ViewController.swift 的末尾,在类定义的大括号后面:

1
2
3
extension ViewController: UITableViewDataSource {

}

编译器将警告你,因为 UITableViewDataSource 有一些强制函数。 在扩展中添加下面的代码,让它变得更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let albumData = currentAlbumData else {
return 0
}
return albumData.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
if let albumData = currentAlbumData {
let row = indexPath.row
cell.textLabel!.text = albumData[row].title
cell.detailTextLabel!.text = albumData[row].value
}
return cell
}

tableView(_:numberOfRowsInSection:) 返回要在表视图中显示的行数,这与相册的”装饰”表示中的项数相匹配。

tableView(_:cellForRowAtIndexPath:) 创建Cell并返回标题及其值。

注意: 实际上,你可以将方法添加到主类声明或扩展中; 编译器并不关心数据源方法是否实际位于扩展。 不过,对于阅读代码的人来说,这种组织方式确实有助于提高可读性

接下来,用下面的代码替换 viewDidLoad () :

1
2
3
4
5
6
7
8
override func viewDidLoad() {
super.viewDidLoad()

//1
allAlbums = LibraryAPI.shared.getAlbums()
//2
tableView.dataSource = self
}

以下是上述代码的详细说明:

  1. 通过 API 获取一个所有 ablums 的列表, 记住, 我们计划使用 LibraryAPI 而不是直接使用 PersistencyManager
  2. 设置 UITableView, 你声明了 view controller 是 UITableView 的 data source, 因此,所有UITabieView需要的信息由 view controller 提供. 注意如果你从storyboard创建, 你也可以在 storyboard 中设置

现在, 添加下面的方法到 ViewController 类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func showDataForAlbum(at index: Int) {

// defensive code: make sure the requested index is lower than the amount of albums
if (index < allAlbums.count && index > -1) {
// fetch the album
let album = allAlbums[index]
// save the albums data to present it later in the tableview
currentAlbumData = album.tableRepresentation
} else {
currentAlbumData = nil
}
// we have the data we need, let's refresh our tableview
tableView.reloadData()
}

showDataForAlbum(at:) 从专辑数组中获取所需的专辑数据. 当你想要显示新数据时,只需要在 UITableView 上调用 reloadData 即可。 这会让表视图询问其 data source,比如应该在表视图中出现多少个部分,每个部分中有多少行,以及每个单元格的外观等等。

将以下行添加到 viewDidLoad ()的末尾

1
showDataForAlbum(at: currentAlbumIndex)

这将在应用程序启动时加载当前的相册。 因为 currentAlbumIndex 设置为0,所以这里显示了集合中的第一张专辑。

构建并运行你的项目。 你的应用程序应该启动并显示以下屏幕:

Table view data source 成功~~!

最后润色

为了不让硬编码的值污染代码,比如字符串 Cell,转到 ViewController 中,在类定义的开放括号后添加以下内容:

1
2
3
private enum Constants {
static let CellIdentifier = "Cell"
}

在这里,你将创建一个枚举,用作常数的容器。

注意: 使用分大小写的枚举的优点是,枚举不会意外地被实例化,并且枚举作为纯粹的命名空间

现在把”Cell”换成 Constants.CellIdentifier

接下来该怎么办?

到目前为止,情况看起来还不错! 你已经准备好了 MVC 模式,并且已经看到了单例模式、 facade 模式和 decorator 模式的运行。 你可以看到 Apple 如何在 Cocoa 中使用这些模式,以及如何将这些模式应用到自己的代码中。

如果你想看一下或者比较一下,这是这部分的最终项目

在本教程的第二部分中,仍然需要讨论适配器、观察器和记忆模式。 如果这还不够,我们还有一个后续的教程,在重构一个简单的 iOS 游戏的过程中,将会涵盖更多的设计模式。

使用 Swift 的 iOS 设计模式-第 2/2 部分

欢迎回到这篇介绍 iOS 设计模式的教程的第二部分! 在第一部分中,你了解了 Cocoa 中的一些基本模式,比如 MVC、单例和装饰者模式

在最后一部分中,你将了解 iOS 和 OS x 开发中出现的其他基本设计模式: 适配器模式 (Adapter) 观察者模式 (Observer)、备忘录模式 (Memento)

开始

你可以从第1部分的末尾下载项目源代码开始
下面是第一部分末尾的音乐库示例应用程序:

该应用程序的原始计划包括在屏幕顶部的水平滚轮切换之间的相册, 与其编写一个用途单一的水平滚轮,为什么不让它可重复使用的任何视图?

为了使该视图可重用,有关其内容的所有决策都应该留给其他两个对象: 一个 data source 和 delegate。 水平滚轮应该声明其data source 和 delegate 实现的方法,以便与滚轮一起工作,类似于 UITableView 委托方法的工作方式。 当我们讨论下一个设计模式时,你将实现这个。

适配器模式

Adapter 允许具有不兼容接口的类协同工作。 它包裹在一个对象,并公开一个标准接口来与该对象进行交互。

如果你熟悉 Adapter 模式,那么你将注意到苹果实现它的方式略有不同——苹果使用协议来完成这项工作。 你可能熟悉 UITableViewDelegate、 UIScrollViewDelegate、 NSCoding 和 nscoping 等协议。 例如,使用 NSCopying 协议,任何类都可以提供标准的复制方法。

如何使用适配器模式

前面提到的水平滚筒看起来是这样的:

要开始实现它,右键单击 Project Navigator 中的 View 组,选择 New File… 并选择 iOS > Cocoa Touch class,然后单击 Next。 将类名设置为 HorizontalScrollerView 并将其作为 UIView 的一个子类。

打开 HorizontalScrollerView.swift 并插入下面的代码在 HorizontalScroller 类上面

1
2
3
4
5
6
protocol HorizontalScrollerViewDataSource: class {
// Ask the data source how many views it wants to present inside the horizontal scroller
func numberOfViews(in horizontalScrollerView: HorizontalScrollerView) -> Int
// Ask the data source to return the view that should appear at <index>
func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, viewAt index: Int) -> UIView
}

这定义了一个名为 HorizontalScrollerViewDataSource 的协议,它可以执行两个操作: 它要求在水平滚轮中显示的视图数和应该为特定索引显示的视图

在这个协议定义的下面添加另一个名为 HorizontalScrollerViewDelegate 的协议。

1
2
3
4
protocol HorizontalScrollerViewDelegate: class {
// inform the delegate that the view at <index> has been selected
func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, didSelectViewAt index: Int)
}

这将让水平滚轮通知一些其他对象,视图已被选中。

注意: 将需要关注的领域划分为单独的协议使得事情变得更加清晰。 通过这种方式,你可以决定遵循特定的协议,并避免使用 @objc 声明可选方法的标记

HorizontalScrollerView.swift 中,在 HorizontalScrollerView 类定义中添加以下代码:

1
2
weak var dataSource: HorizontalScrollerViewDataSource?
weak var delegate: HorizontalScrollerViewDelegate?

delegate 和 data source 是可选的, 所以不必提供它们,但是在这里设置的任何对象都必须符合适当的协议。

1
2
3
4
5
6
7
8
9
10
11
12
// 1
private enum ViewConstants {
static let Padding: CGFloat = 10
static let Dimensions: CGFloat = 100
static let Offset: CGFloat = 100
}

// 2
private let scroller = UIScrollView()

// 3
private var contentViews = [UIView]()

依次解释每个注释块:

  1. 定义一个私有的 enum 以便于在设计时修改布局。 该视图的尺寸在滚轮内将是 100 x 100,与其封闭矩形的 10 point的边距
  2. 创建滚轮试图包含views
  3. 创建一个 array数组包含所有的 album 封面

下一步你需要实现这个实例. 方法如下:

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
override init(frame: CGRect) {
super.init(frame: frame)
initializeScrollView()
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initializeScrollView()
}

func initializeScrollView() {
//1
addSubview(scroller)

//2
scroller.translatesAutoresizingMaskIntoConstraints = false

//3
NSLayoutConstraint.activate([
scroller.leadingAnchor.constraint(equalTo: self.leadingAnchor),
scroller.trailingAnchor.constraint(equalTo: self.trailingAnchor),
scroller.topAnchor.constraint(equalTo: self.topAnchor),
scroller.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])

//4
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(scrollerTapped(gesture:)))
scroller.addGestureRecognizer(tapRecognizer)
}

在 initializeScrollView() 中完成, 下面是注释:

  1. 添加UIScrollView 实例到父试图中
  2. 关闭 translatesAutoresizingMaskIntoConstraints, 这样可以设置自己的约束
  3. 对滚动视图应用约束. 整个滚动试图填充HorizontalScrollerView
  4. 创建一个手势识别, 点击手势识别检测触摸的滚动视图和检查如果专辑封面已点击, 将通知HorizontalScrollerView delegate.这里有一个编译器错误. 因为 tap 方法尚未实现.稍后再做.

现在添加这个方法:

1
2
3
4
5
6
func scrollToView(at index: Int, animated: Bool = true) {
let centralView = contentViews[index]
let targetCenter = centralView.center
let targetOffsetX = targetCenter.x - (scroller.bounds.width / 2)
scroller.setContentOffset(CGPoint(x: targetOffsetX, y: 0), animated: animated)
}

此方法检索特定索引的视图并将其居中。 它由以下方法使用(也将其添加到类中) :

1
2
3
4
5
6
7
8
9
@objc func scrollerTapped(gesture: UITapGestureRecognizer) {
let location = gesture.location(in: scroller)
guard
let index = contentViews.index(where: { $0.frame.contains(location)})
else { return }

delegate?.horizontalScrollerView(self, didSelectViewAt: index)
scrollToView(at: index)
}

这个方法在滚动视图中找到 tap 的位置,然后是包含该位置(如果有的话)的第一个内容视图的索引。

如果点击了内容视图,则会通知委托并将视图滚动到中间。

接下来添加以下内容,以便从 scroller 中访问专辑封面:

1
2
3
func view(at index :Int) -> UIView {
return contentViews[index]
}

View (at:) 只返回特定索引处的视图。 你将使用这个方法来突出你点击的专辑封面。

现在添加下面的代码来重新加载 scroller:

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
func reload() {
// 1 - Check if there is a data source, if not there is nothing to load.
guard let dataSource = dataSource else {
return
}

//2 - Remove the old content views
contentViews.forEach { $0.removeFromSuperview() }

// 3 - xValue is the starting point of each view inside the scroller
var xValue = ViewConstants.Offset
// 4 - Fetch and add the new views
contentViews = (0..<dataSource.numberOfViews(in: self)).map {
index in
// 5 - add a view at the right position
xValue += ViewConstants.Padding
let view = dataSource.horizontalScrollerView(self, viewAt: index)
view.frame = CGRect(x: CGFloat(xValue), y: ViewConstants.Padding, width: ViewConstants.Dimensions, height: ViewConstants.Dimensions)
scroller.addSubview(view)
xValue += ViewConstants.Dimensions + ViewConstants.Padding
return view
}
// 6
scroller.contentSize = CGSize(width: CGFloat(xValue + ViewConstants.Offset), height: frame.size.height)
}

Reload 方法在 UITableView 中的 reloadData 之后建模; 它重新加载用于构造水平滚轮的所有数据。

逐一介绍代码注释:

  1. 在执行任何重新加载之前,检查是否有数据源
  2. 由于你正在清除专辑封面,因此还需要删除任何现有视图
  3. 所有视图都从给定的偏移量开始定位。 目前它是100,但在文件的顶部它可以通过改变常数来轻松调整
  4. 向数据源查询视图的数量,然后使用它创建新的内容视图数组
  5. 每次向其数据源请求一个视图,并将它们放在另一个视图旁边,使用以前定义的 padding
  6. 所有视图就位后,设置滚动视图的内容偏移量,以允许用户滚动浏览所有相册封面

当数据更改时执行reload。

最后一个关于 HorizontalScrollerView 的难题是确保你正在查看的相册总是位于滚动视图的中心。 要做到这一点,你需要在用户用手指拖动滚动视图时执行一些计算。

添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private func centerCurrentView() {
let centerRect = CGRect(
origin: CGPoint(x: scroller.bounds.midX - ViewConstants.Padding, y: 0),
size: CGSize(width: ViewConstants.Padding, height: bounds.height)
)

guard let selectedIndex = contentViews.index(where: { $0.frame.intersects(centerRect) })
else { return }
let centralView = contentViews[selectedIndex]
let targetCenter = centralView.center
let targetOffsetX = targetCenter.x - (scroller.bounds.width / 2)

scroller.setContentOffset(CGPoint(x: targetOffsetX, y: 0), animated: true)
delegate?.horizontalScrollerView(self, didSelectViewAt: selectedIndex)
}

上面的代码考虑了滚动视图的当前偏移量以及视图的维度和填充,以便计算当前视图到中心的距离。 最后一行很重要: 一旦视图位于中间,你就通知委托方所选视图已更改。

为了检测用户是否已经完成了在滚动视图中的拖动,你需要实现一些 UIScrollViewDelegate 方法。 将以下类扩展名添加到文件的底部; 请记住,必须在主类声明的花括号之后添加此扩展名!

1
2
3
4
5
6
7
8
9
10
11
extension HorizontalScrollerView: UIScrollViewDelegate {
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
centerCurrentView()
}
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
centerCurrentView()
}
}

当用户完成拖动时 scrollViewDidEndDragging(_:willDecelerate:) 通知 delegate, 如果scroll view 没有完全停止的时候, decelerate 参数为真.当滚动操作结束时,系统调用 scrollviewdiddendating (:) 在这两种情况下,你都应该调用 centerCurrentView方法来居中显示当前视图,因为在用户拖动滚动视图之后,当前视图可能已经更改。

最后,不要忘记设置代表。 在 initializeScrollView() 的开头添加以下行:

1
scroller.delegate = self

你的HorizontalScrollerView可以使用了! 浏览刚才编写的代码; 你会发现没有一处提到 Album 或 AlbumView 类。 这很好,因为这意味着新的scroller是真正独立和可重复使用的。

构建你的项目,以确保一切都编译正确。

现在,HorizontalScrollerView 已经完成,是时候在你的应用程序中使用它了。 首先,打开 main.storyboard。 点击顶部灰色矩形视图并点击Identity Inspector。 将类名更改为 HorizontalScrollerView,如下所示:

接下来, 打开 Assistant Editor 按住control 拖拽灰色视图到 ViewController.swift 创建一个 outlet, 命名为 horizontalScrollerView

下一步打开 ViewController.swift 是时候开始实现一些 HorizontalScrollerViewDelegate 方法了!

在文件底部添加以下扩展名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension ViewController: HorizontalScrollerViewDelegate {
func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, didSelectViewAt index: Int) {
//1
let previousAlbumView = horizontalScrollerView.view(at: currentAlbumIndex) as! AlbumView
previousAlbumView.highlightAlbum(false)
//2
currentAlbumIndex = index
//3
let albumView = horizontalScrollerView.view(at: currentAlbumIndex) as! AlbumView
albumView.highlightAlbum(true)
//4
showDataForAlbum(at: index)
}
}

这就是调用这个 delegate 方法时发生的情况:

  1. 首先获取之前选定的专辑,并取消选择专辑封面
  2. 存储你刚点击的当前专辑封面索引
  3. 抓取当前被选中的相册封面,并突出显示所选内容
  4. 在表视图中显示新相册的数据

接下来,是时候实现 HorizontalScrollerViewDataSource 了。 在文件末尾添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension ViewController: HorizontalScrollerViewDataSource {
func numberOfViews(in horizontalScrollerView: HorizontalScrollerView) -> Int {
return allAlbums.count
}

func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, viewAt index: Int) -> UIView {
let album = allAlbums[index]
let albumView = AlbumView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), coverUrl: album.coverUrl)
if currentAlbumIndex == index {
albumView.highlightAlbum(true)
} else {
albumView.highlightAlbum(false)
}
return albumView
}
}

如你所知,numberOfViews(in:) 是返回滚动视图视图数量的 protocol 方法。 由于滚动视图将显示所有唱片集数据的封面,因此计数是唱片集记录的数量。 在 horizontalScrollerView(:viewAt:)中,创建一个新的 AlbumView,如果它是选定的相册,则突出显示它,然后将其传递到 horizontalScrollerView。

三个短方法就能显示漂亮的水平滚轮。 现在需要连接数据源和委托。 在 viewDidLoad 的 showDataForAlbum(at:) 之前添加下面的代码:

1
2
3
horizontalScrollerView.dataSource = self
horizontalScrollerView.delegate = self
horizontalScrollerView.reload()

构建并运行你的项目,看看你的新的水平滚轮:

等等。 horizontal scroller 已经就位,但是封面呢在哪里呢?

啊,这就对了——你还没有实现下载封面的代码。 要做到这一点,你需要添加一种下载图像的方法。 因为你所有的服务都是通过 LibraryAPI 访问的,新方法也通过它来访问。 然而,首先要考虑以下几点:

  1. AlbumView 不应该直接与 LibraryAPI 交互, 你不希望将视图逻辑与通信逻辑混合在一起
  2. 处于同样的原因 LibraryAPI 不应该知道 AlbumView
  3. LibraryAPI 需要通知 AlbumView 一定封面被下载, 以为 AlbumView 需要展示封面

听起来像个难题? 不要绝望,你会学到如何做到这一点,使用观察者模式! :]

观察者模式

在观察者模式中,一个对象通知其他对象任何状态的变化。 所涉及的对象不需要了解彼此——提倡的解耦设计。 此模式通常用于在属性更改时通知感兴趣的对象。

通常的实现要求观察者注册另一个对象的状态。 当状态发生变化时,所有的观察对象都会收到变化通知。

如果你想坚持 MVC 概念(提示: 你需要这样做) ,你需要允许 Model 对象与 View 对象通信,但是它们之间不需要直接引用。 这就是观察者模式的作用所在

Cocoa 通过两种方式实现观察者模式: 通知和键值观察(KVO)。

通知

不要和用户通知搞混淆了. Notifications 是一个 基于 subscribe-and-publish 的模型.

该模型允许对象(发布者)向其他对象(订阅者 / 监听者)发送消息。 出版商永远不需要知道任何关于订户的事情。该模型允许对象(发布者)向其他对象(订阅者 / 监听者)发送消息。 发送者永远不需要知道任何关于订户的事情。

苹果公司大量使用通知功能。 例如,当显示 / 隐藏键盘时,系统分别发送一个 UIKeyboardWillShow / UIKeyboardWillHide 当你的应用进入后台时,系统会发送一个 UIApplicationDidEnterBackground 通知。

如何使用通知

右键单击 RWBlueLibrary 选择 New Group, 命名 Extension 并右击选择 New File… 选择 iOS > Swift File 设置名字为 NotificationExtension.swift

在文件中复制一下代码:

1
2
3
extension Notification.Name {
static let BLDownloadImage = Notification.Name("BLDownloadImageNotification")
}

你正在通过自定义通知名称扩展Notification.Name。 从现在开始,可以 .BLDownloadImage 这样来访问新通知,就使用系统通知一样。

进入 AlbumView.swift 并在 init(frame:coverUrl:) 的末尾插入以下方法:

1
NotificationCenter.default.post(name: .BLDownloadImage, object: self, userInfo: ["imageView": coverImageView, "coverUrl" : coverUrl])

这一行通过 NotificationCenter 单例发送一个通知。 通知信息包含要填充的 UIImageView 和要下载的封面图像的 URL。 这就是执行封面下载任务所需的全部信息。

在 LibraryAPI.swift 中的 init 下面添加以下代码行,作为当前空 init 的实现:

1
NotificationCenter.default.addObserver(self, selector: #selector(downloadImage(with:)), name: .BLDownloadImage, object: nil)

这是等式的另一边: 观察者。 每当一个 AlbumView 发送一个 .BLDownloadImage 通知,因为 LibraryAPI 已经注册为相同通知的观察者,系统通知 LibraryAPI。 然后 LibraryAPI 调用 downloadImage (使用:)作为响应。

在实现 downloadImage (with:)之前,还有一件事要做。 在本地保存下载的封面可能是个好主意,这样应用程序就不需要一遍又一遍地下载相同的封面。

打开 PersistencyManager.swift。 在导入 Foundation 之后,添加下面一行:

import UIKit

这个导入很重要,因为你要处理 UI 对象,比如 UIImage。
将这个计算属性添加到类的末尾:

1
2
3
private var cache: URL {
return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}

这个变量返回缓存目录的 URL,这是一个存储你可以随时重新下载的文件的好地方。

现在添加这两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func saveImage(_ image: UIImage, filename: String) {
let url = cache.appendingPathComponent(filename)
guard let data = UIImagePNGRepresentation(image) else {
return
}
try? data.write(to: url)
}

func getImage(with filename: String) -> UIImage? {
let url = cache.appendingPathComponent(filename)
guard let data = try? Data(contentsOf: url) else {
return nil
}
return UIImage(data: data)
}

这段代码非常简单。 下载的图像将保存在 Cache 目录中,如果在 Cache 目录中没有找到匹配的文件,getImage(with:) 将返回 nil。

现在打开 LibraryAPI.swift,在第一次可用导入之后添加 import UIKit

在类的末尾添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@objc func downloadImage(with notification: Notification) {
guard let userInfo = notification.userInfo,
let imageView = userInfo["imageView"] as? UIImageView,
let coverUrl = userInfo["coverUrl"] as? String,
let filename = URL(string: coverUrl)?.lastPathComponent else {
return
}

if let savedImage = persistencyManager.getImage(with: filename) {
imageView.image = savedImage
return
}

DispatchQueue.global().async {
let downloadedImage = self.httpClient.downloadImage(coverUrl) ?? UIImage()
DispatchQueue.main.async {
imageView.image = downloadedImage
self.persistencyManager.saveImage(downloadedImage, filename: filename)
}
}
}

下面是以上代码的解释:

  1. downloadImage通过通知执行,因此该方法接收通知对象作为参数。 从通知中检索 UIImageView 和图像 URL。
  2. 如果先前已下载,则从PersistencyManager中检索图像。
  3. 如果尚未下载图像,则使用HTTPClient检索它。
  4. 下载完成后,在图像视图中显示图像,并使用PersistencyManager将其保存在本地。

再一次,你使用外观模式来隐藏从其他类下载图像的复杂性。 通知发送者不在乎图像是来自网络还是文件系统。

构建和运行你的应用程序,看看你的收藏视图里漂亮的封面:

停止你的应用程序并重新运行它。 注意, 加载封面没有延迟,因为它们已经在本地保存了。 你甚至可以断开互联网,你的应用程序将运行完美。 然而,这里有一个奇怪的地方: spinner一直不停止旋转! 怎么回事?

你启动了 spinner 当下载图像的时候, 但是下载完之后你没有停止spinner的逻辑. 你可以在每次下载图片的时候发送一个通知, 不过你可以使用一下下面这个观察者模式 KVO

键值观察 KVO

在 KVO中,一个对象可以要求通知特定属性的任何变化; 无论是它自己的还是另一个对象的。 如果你有兴趣,你可以在苹果的 Apple’s KVO Programming Guide. 上阅读更多关于这方面的内容。

如何使用 KVO 模式

如上所述,KVO 机制允许对象观察属性的变化。 在你的示例中,你可以使用 KVO 来观察 UIImageView 的 image 属性的变化。

打开 AlbumView.swift,添加下面属性到 private var indicatorView: UIActivityIndicatorView! 声明下面

1
private var valueObservation: NSKeyValueObservation!

现在在 commonInit 中添加以下代码,然后将 cover image 视图作为子视图添加:

1
2
3
4
5
6

valueObservation = coverImageView.observe(\.image, options: [.new]) { [unowned self] observed, change in
if change.newValue is UIImage {
self.indicatorView.stopAnimating()
}
}

这段代码将图像视图添加为封面图像的 image 属性的观察器。 \.image 是启用此机制的关键路径表达式。

在 Swift 4中,一个关键路径表达式(key path expression)具有以下形式:

1
\<type>.<property>.<subproperty>

编译器通常可以推断类型,但至少需要提供1个属性。 在某些情况下,使用属性的属性是有意义的。 在本例中,指定了属性名称 image,而省略了类型名称 UIImageView。

尾随闭包指定每次观察到的属性更改时执行的闭包。 在上面的代码中,当 image 属性更改时停止微调器。 这样,当加载一个图像时,spinner 就会停止旋转。

构建并运行你的项目。 旋转器应该消失:

注意: 永远记住,在 deinited 的时候移除 observers,否则对象发送消息给这些不存在的observers时你的应用程序将崩溃.这个例子中 valueObservation 将deinited, 所以监察将停止.

如果你玩了一会儿你的应用程序并终止它,你会注意到你的应用程序的状态没有被保存。 当应用程序启动时,你看到的最后一个相册不会是默认相册。

要纠正这一点,你可以使用列表上的下一个模式: 备忘录模式 Memento

The Memento Pattern 备忘录模式

备忘录模式捕获并外化一个对象的内部状态。 换句话说,它把你的东西保存在某个地方。 稍后,可以在不破坏封装的情况下恢复这种外部化状态; 也就是说,私有数据仍然是私有的。

如何使用备忘录模式

iOS 使用备忘录模式作为状态恢复(State Restoration)的一部分。 你可以通过阅读我们的教程了解更多关于它的信息,但本质上它存储和重新应用你的应用程序的状态,以便用户返回他们留下的东西。

要激活应用程序中的状态恢复,请打开 Main.storyboard。 选择Navigation Controller,然后在Identity Inspector中找到 Restoration ID 字段并键入 NavigationController。

选择 Pop Music 场景并为相同的字段输入 ViewController。 这些 ID 告诉 iOS,当应用程序重新启动时,你有兴趣恢复这些视图控制器的状态。

添加以下代码到 AppDelegate.swift:

1
2
3
4
5
6
7
func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
return true
}

func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
return true
}

这段代码为应用程序整体打开状态恢复。 现在,将下面的代码添加到 ViewController.swift 中的常量Constants枚举中:

1
static let IndexRestorationKey = "currentAlbumIndex"

此键将用于保存和恢复当前的相册索引。 添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
override func encodeRestorableState(with coder: NSCoder) {
coder.encode(currentAlbumIndex, forKey: Constants.IndexRestorationKey)
super.encodeRestorableState(with: coder)
}

override func decodeRestorableState(with coder: NSCoder) {
super.decodeRestorableState(with: coder)
currentAlbumIndex = coder.decodeInteger(forKey: Constants.IndexRestorationKey)
showDataForAlbum(at: currentAlbumIndex)
horizontalScrollerView.reload()
}

在这里你保存索引(这将发生在你的应用程序进入后台)并恢复它(这将发生在应用程序启动时,在你的视图控制器的视图被加载后)。 恢复索引后,更新table和 scroller 以反映更新后的选定内容。 还有一件事情要做-你需要移动滚轮到正确的位置。 如果你把滚轮移到这里看起来就不对了,因为视图还没有布置好。 添加以下代码将滚轮移动到正确的位置:

1
2
3
4
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
horizontalScrollerView.scrollToView(at: currentAlbumIndex, animated: false)
}

构建并运行你的应用程序。 导航到其中一个相册,按Home 按钮到后台(如果你在模拟器上Command + Shift + h) ,然后从 Xcode关闭你的应用程序。 重新启动,并检查之前选择的专辑是否在中间:

如果查看 PersistencyManager 的 init,你会注意到每次创建 PersistencyManager 时都重新创建相册数据并且写死的。 但是最好只创建一次专辑列表并将其存储在一个文件中。 如何将相册数据保存到文件中?

一种选择是循环访问相册的属性,将它们保存到一个 plist 文件中,然后在需要时重新创建相册实例。 这不是最好的选择,因为它要求你根据每个类中的数据 / 属性编写特定的代码。 例如,如果稍后创建了具有不同属性的 Movie 类,则保存和加载该数据将需要新的代码。

此外,你将无法为每个类实例保存私有变量,因为外部类无法访问它们。 这正是苹果创建归档和序列化机制(archiving and serialization)的原因。

Archiving and Serialization 存档和序列化

苹果的备忘录模式的一个专门实现方式可以通过归档和序列化来实现。 在 Swift 4之前,要序列化和存档自定义类型,你必须经过许多步骤。 对于类类型,你需要子类化 NSObject 并遵守 NSCoding 协议。

像结构和枚举这样的值类型需要一个子对象,这个子对象可以扩展 NSObject 并符合 NSCoding。

Swift 4解决了所有这三种类型的问题: 类、结构和枚举[SE-0166]

如何使用归档和序列化

打开Album.swift 并声明 Album 实现 Codable。 这个协议是唯一需要的东西,使Swift类型可编可译(Encodable Decodable)。 如果所有属性都可编译,则协议实现将由编译器自动生成。

现在你的代码应该是这样的:

1
2
3
4
5
6
7
struct Album: Codable {
let title : String
let artist : String
let genre : String
let coverUrl : String
let year : String
}

要实际编码对象,你需要使用编码器。 打开 PersistencyManager.swift 并添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private var documents: URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}

private enum Filenames {
static let Albums = "albums.json"
}

func saveAlbums() {
let url = documents.appendingPathComponent(Filenames.Albums)
let encoder = JSONEncoder()
guard let encodedData = try? encoder.encode(albums) else {
return
}
try? encodedData.write(to: url)
}

在这里,你定义了一个 URL,你将在其中保存文件(就像你对缓存caches所做的那样),一个文件名的常量,然后是一个将你的相册写入文件的方法。 而且你不需要写很多代码!

流程的另一部分是将数据解码回具体对象。 你将替换长方法,即生成相册并从文件加载它们。 下载并解压缩此 JSON 文件,并将其添加到你的项目中

现在用下面的代码替换 PersistencyManager.swift 中的 init:

1
2
3
4
5
6
7
8
9
10
11
let savedURL = documents.appendingPathComponent(Filenames.Albums)
var data = try? Data(contentsOf: savedURL)
if data == nil, let bundleURL = Bundle.main.url(forResource: Filenames.Albums, withExtension: nil) {
data = try? Data(contentsOf: bundleURL)
}

if let albumData = data,
let decodedAlbums = try? JSONDecoder().decode([Album].self, from: albumData) {
albums = decodedAlbums
saveAlbums()
}

现在,你正在从 documents 目录中的文件加载相册数据(如果它存在的话)。 如果它不存在,你可以从前面添加的 starter 文件中加载它,然后立即保存它,以便下次启动时将其保存在 documents 目录中。 JSONDecoder 非常聪明——你告诉它你希望文件包含的类型,它就会为你完成剩下的所有工作!

每次应用程序进入后台时,你可能还希望保存相册数据。 我将把这一部分留给你作为一个挑战——你在这两个教程中学到的一些模式和技术将会派上用场!

接下来该怎么办?

你可以在这里下载完成的项目。

在本教程中,你了解了如何利用 iOS 设计模式的力量以简单的方式执行复杂的任务。 你已经学习了很多 iOS 设计模式和概念: Singleton、 MVC、 Delegation、 Protocols、 Facade、 Observer 和 Memento。

最终的代码是松散耦合的、可重用的和可读的。 如果其他开发人员看了你的代码,他们很容易就能理解发生了什么,以及每个类在你的应用程序中做了什么。

重点不是对你写的每一行代码都使用设计模式。 相反,当你考虑如何解决一个特定的问题时,要注意设计模式,尤其是在设计应用程序的早期阶段。 它们将使你作为开发人员的生活更加轻松,并且使你的代码更加优秀!

关于这个主题的长期经典著作是《设计模式: 可重用的面向对象软件元素》。 关于代码示例,请查看 Swift 在 GitHub 上实现的很棒的项目 Design Patterns,以了解更多 Swift 编写的设计模式。