What's a Design Pattern
是解决软件设计中常见问题的可重用的设计思维,是能帮助我们写出容易理解和好用的代码的模版,是前辈们在开发过程中的总结经验。
设计模式可能不是一个需要专门学习的内容,当你用 Xcode 创建一个新的项目时,其实它已经为你创建好设计模式了:MVC, Delegate, Protocol, Singleton…。但在我看来学习设计模式是非常有益处的,好处自不多言。
本文要介绍的最常用的几种 Cocoa 设计模式,可以分为以下几种:
- 创建型模式(Creational)
- 单例模式(Singleton Pattern)
- 结构型模式(Structural)
- MVC 模式
- 装饰模式(Decorator Pattern)
- 适配器模式(Adapter Pattern)
- 立面模式(Facade Pattern)
- 行为型模式(Behavioral)
- 观察者模式(Observer Pattern)
- 备忘录模式(Memento Pattern)
I. MVC 设计模式
iOS 的核心设计模式,就不过多介绍了。在平常的开发中,要注意 model,view,view controller 之间各个模块的解藕合。
II. 单例模式(Singleton Pattern)
为文件的管理配置、和 API 的调用提供可全局访问的入口。
特点
- 应用单例模式的类只有一个实例,因此只存在一个访问该类的入口(同时保障了线程安全);
- 该实例(入口)是可全局访问、面向所有对象的。
实现
- 设置静态常量(
shared
)允许单例对象全局访问; - 为其初始化器添加
private
关键字以防止在类外被实例化。
final class LibraryAPI {
static let shared = LibraryAPI()
private init() {
// ...
}
}
复制代码
Apple 官方用例:
UserDefaults.standard
,UIApplication.shared
,UIScreen.main
,FileManager.default
III. 立面模式(Facade Pattern)
有的地方也翻译作外观模式,但我更加偏好「立面」这个译法。
在一个 project 中,我们会用到各类的 API。比如在 Gins 这个项目里我需要用到 last.fm 的 API 获取音轨列表,用 Musixmatch 的 API 获取最新音轨的歌词摘要,另外,我们也需要一个类来单独处理对文件数据的操作。因此,一个集中统一的 API 机制是必要的。
优势
- 解藕代码。减少了外部代码对内部具体实现的依赖,接口内部实现的复杂性被隐藏起来,包括服务器的控制、数据库操作、文件系统和内存管理等;
- 逻辑简明。隐藏子系统的复杂性,对外部仅呈现一个简单的统一 API(unified API);
- 利于维护。日后当后端服务更换时,也不需要改变负责调用的部分,而只要更新立面内部的代码。
实现
- 在立面类中实例化负责各个 API 调用模块的类,
private
修饰; - 在立面类中调用各 API 类中的方法,这样该类就成了一个 unified API;
final class LibraryAPI {
// add private instances
private let persistencyManager = PersistencyManager()
private let lastfmClient = LastfmClient()
private let musixmatchClient = MusixmatchClient()
// method calls..
}
复制代码
与单例对象的关系
这时候回过去看,单例模式里创建的 LibraryAPI
同时又充当了立面类(unified interface)的角色。所以,这两种模式经常是相辅相成的。
为了使 API 的调用变得直观可控,使各个 API 类文件都只拥有唯一一个实例,并创建在一个单独的、可全局访问的类—— LibraryAPI
单例对象——中统一管理。
注意:虽然我们已经为子系统应用了立面模式,不过要明白的一点是,除非你是在建立分离的模块并且在使用访问控制,否则立面模式并没有保证客户端无法直接访问那些「隐藏」的类。
IV. 装饰模式(Decorator Pattern)
使用此模式允许向一个现有的对象添加新的功能,同时又不改变其结构。在 Swift 中,此模式有两种通用的应用:Extensions 和 Delegation。
优势
可以在为对象添加行为和功能的同时不改变其结构,它是类的继承的另一种替代选择。
实现一:Extensions
以 Model 类为例,我们可以为它扩展一个 description
属性,让它返回以特殊的形式字符串,这样就能很方便地作为 request body 给执行 post 请求的代码使用;
或者,如果想将 Model 属性以简单的 key-value 的形式在 table view 中展示,可以扩展一个 tableRepresentation
属性,使它返回一组二元组数组,每一个二元组就能对应到 table view 中的每一个单元格。
这种做法的巧妙之处在于,Model 对象原本是与数据在视图中如何展示无关的,但我们可以让它以更适合 View 对象的数据形式传递给后者,同时保持低耦合性。
注意:extensions 和通常定义下的装饰模式的定义还是有差别的——因为它不会实例化被扩展的类。另外,在 extensions 中不能重载方法。
实现二:Delegation
比较基础的东西,不多说。
UITableView 有两种代理类型:data source 和 delegate。通过前者,它能请求每个 section 要展示的行的数量,通过后者,能完成当一行被选中时需要做的事件。
V. 适配器模式(Adapter Pattern)
两个不兼容的接口之间的桥梁。在 Swift 中通过 protocol 来实现。
前面装饰模式提到,通过使扩展应用 delegate 和 data source,可以使 table view 完成数据的展示功能。实际上,这两种协议不是可选而是必须的,因为它们承担了衔接两个不兼容的对象的责任,使数据对象和视图对象能够配合完成工作,这便是最常见的适配器模式的栗子。
与装饰模式的关系
适配器(协议)对接兼容两个不同的类,于是类与类之间得以通过装饰模式(遵循协议)实现功能和职责的扩充,以使其能够一起工作。在 delegate 和 data source 的应用实例中,这两者是相辅相成的。
应用场景
想实现一个 iPod Coverflow 般的效果?Object library 中可没有叫做 HorizontalScrollView 的现成控件可供使用,那么我们就得继承一个 UIView 子类,并自行实现它与 Model 对象的协同工作,就像 table view 的 delegate 和 data source 一样。
VI. 观察者模式(Observer Pattern)
在观察者模式中,一个对象能够对另一个的对象的任何状态变化传递信息,同时能保持两个对象是解藕的(互相不建立引用关系)。
Cocoa 通过两种方式应用观察者模式:Notifications 和 Key-Value Observing (KVO)。
实现一:Notifications
一种订阅者(subscriber)-发布者(publisher)协调的机制,作为发布者的对象不需要知道任何订阅者的内部机制。发布者发布信息,接受者接收信息,并执行某特定函数。
Apple 官方用例:
UIKeyboardWillShow/UIKeyboardWillHide
:键盘的显示/隐藏;UIApplicationDidEnterBackground
:后台的通知机制。
1 . 设置 notification key。Notification key 其实就是一组自定义的字符串,可以将它理解为一类特定通知的频道;
extension Notification.Name {
static let mySpecialNotificationKey = Notification.Name("site.deans.specialNotificationKey")
}
复制代码
2 . 设置发布者。在信息发布处添加 post(name:object:userInfo:)
方法。将信息载体以字典的形式通过 userInfo
发布,在第 3 步中被封装成 NSNotification
作为参数传入触发方法;
NotificationCenter.default.post(name: .mySpecialNotificationKey, object: self, userInfo: SOME_DICTIONARY)
复制代码
3 . 设置订阅者。为接收的对象添加 addObserver(_:selector:name:object:)
。每当信息发布一次,就调用一次 SOME_METHOD(:)
方法;
NotificationCenter.default.addObserver(self, selector: #selector(SOME_METHOD(_:)), name: .mySpecialNotificationKey, object: nil)
复制代码
4 . 定义触发方法。
实现二:Key-Value Observing (KVO)
使用 KVO 监测某一对象下特定属性的更改。
使用条件和限制:
- 被监测的属性所在的类应该是 KVO 服从(KVO compliant)的。必须继承于
NSObject
; - 由于 Swift 默认禁用动态派发,因此还要将观测对象标记为
dynamic
(使其在 Objective-C 运行时间动态派发)。
优势:
- 监视者可以对某一特定属性的任何变化接受通知,无论这一属性和观察者是否处在同一个类中。
- 可以灵活地针对一个或多个属性设定一个或多个观察器。(对于不同的值,可以为其单独设置独立的键)。
应用概要:
- 确保 KVO compliant。对于自定义的类,应继承自
NSObject
+ 属性应用dynamic
修饰(像UIImageView
则直接满足条件); - 为独立的被观察属性设立独立的 key path 参数(
\.objectToObserve.myDate
); - 若使用 completion closure pattern,当程序执行完该作用域将自动释放,否则应编写
removeObserver(_:forKeyPath:)
在不需使用后移除观察器。
class MyObjectToObserve: NSObject {
@objc dynamic var myDate = NSDate()
func updateDate() {
myDate = NSDate()
}
}
class MyObserver: NSObject {
@objc var objectToObserve: MyObjectToObserve
var observation: NSKeyValueObservation?
init(object: MyObjectToObserve) {
objectToObserve = object
super.init()
observation = observe(\.objectToObserve.myDate) { object, change in
print("Observed a change to \(object.objectToObserve).myDate, updated to: \(object.objectToObserve.myDate)")
}
}
}
class Implementation {
static let shared = Implementation()
let observed = MyObjectToObserve()
let observer: MyObserver
private init() {
observer = MyObserver(object: observed)
}
}
Implementation.shared.observed.updateDate()
复制代码
Swift 4 中,key path 有以下的形式:
\<type>.<property>.<subproperty>
经常能通过编译器推断出来,但至少指定一个 是必要提供的。如,\.image
是\.UIImageView.image
的缩略写法。
但是,由于 KVC 依靠 Objective-C 逻辑支持,被观察的属性只能是继承自 NSObject
,这就是说结构体、枚举和泛型统统都不支持。考虑到这些历史遗留问题,寻找 KVO 的替代方案就成了一件很有必要的事情(ref 7)。
引用一句王巍的话:
KVO 是 Cocoa 中公认的最强大的特性之一,但是同时它也以烂到家的 API 和极其难用著称。
VII. 备忘录模式(Memento Pattern)
备忘录模式捕获一个对象的内部状态并将它外部化,简单说就是把内容存到别的地方。使用该模式可以记录用户的位置或浏览状态。
编/解码表示用户浏览状态的变量
1 . 在 iOS 中备忘录模式是状态恢复(State Restoration)的一部分,所以我们要先为需要启用状态恢复的视图控制器(或视图)分别设置好 Restoration ID;
2 . 在 AppDelegate.swift 中添加以下代码,这样就启用了应用整体的状态恢复;
func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
return true
}
func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
return true
}
复制代码
3 . 实现对某一个变量值的编/解码。定义静态字符串常量作为 restoration key,并重载方法;
override func encodeRestorableState(with coder: NSCoder) {
coder.encode(VARIABLE, forKey: RESTORATION_KEY)
super.encodeRestorableState(with: coder)
}
override func decodeRestorableState(with coder: NSCoder) {
super.decodeRestorableState(with: coder)
VARIABLE = coder.decodeInteger(forKey: RESTORATION_KEY)
// do some updates with encoded variable..
}
复制代码
数据的归档及序列化
如果想要为应用存储一些数据,最直接的方法是,遍历每一个 model 属性,保存至 plist 文件,在需要的时候再重新创建实例。
Archives and Serialization: Convert objects and values to and from property list, JSON, and other flat binary representations.
但以上方法有两个很大的局限:
- 当要存储的属性稍微复杂一点,代码就会很复杂。而且每当有新的类和属性需要存储,将要为其存取和加载编写新的代码;
- 你将不能存储私有属性;
归档及序列化机制(archiving and serialization)是苹果对备忘录模式的一种特有的实现。
在 Swift 4 之前,应用此机制的类需要继承
NSObject
并遵循NSCoding
协议;Swift 4 开始,不论类、结构体还是枚举类型都能够运用此机制了。
实现步骤
1 . 声明该对象应用 Codable
协议;
2 . 创建并应用 encoder 实例;
private var documents: URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
func encodeTracks() {
let url = documents.appendingPathComponent(JSON_FILE_NAME)
let encoder = JSONEncoder()
guard let encodedData = try? encoder.encode(DATA_TO_ENCODE) else {
return
}
try? encodedData.write(to: url)
}
复制代码
3 . 创建并应用 decoder 实例。应用启动时,若编码文件存在,解码数据。
let savedURL = documents.appendingPathComponent(JSON_FILE_NAME)
let data = try? Data(contentsOf: savedURL)
if let existingData = data, let decoded = try? JSONDecoder().decode(TARGET_TYPE.self, from: existingData) {
// assign decoded to private data property..
}
复制代码
4 . 在每次要进入后台时调用第 2 步的 encodeTracks()
方法。为了达到这一目的,将 UIApplicationDelegate 下的实例方法 applicationWillResignActive(_:)
作为 notification key。
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(yourMethod(with:)), name: .UIApplicationWillResignActive, object: nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NotificationCenter.default.removeObserver(self, name: .UIApplicationWillResignActive, object: nil)
}
@objc func yourMethod(with notification: Notification) {
// call encode method here..
}
复制代码
完成这些工作后,你的应用就能够记住上一次打开时的状态了。运行项目后,在模拟器中按下 Home 键(此时编码器工作),重新启动项目(此时解码器工作)。
ref:
- Design Patterns on iOS using Swift - Part 1/2 - raywenderlich
- Design Patterns on iOS using Swift – Part 2/2 - raywenderlich
- 设计模式 - 菜鸟教程
- KVO - Swifter
- Key-Value Observing Programming Guide - Apple
- Is key-value observation (KVO) available in Swift? - Stack Overflow
- Exploring KVO alternatives with Swift - Scott Logic