基于 Swift 的面向协议编程
当 Swift 刚刚出现的时候,学习新东西都是令人兴奋的。第一年,我很高兴能学习它,我之前在 Swift 里面使用我的 Objective C 代码 (有的时候用些值类型和更加有趣的东西)。但是直到去年的 WWDC,协议扩展出现了。
Dave Abrahams (让你大开眼界的教授) 做了一次令人大开眼界的演讲 “基于 Swift 的面向协议编程”。他声称 “Swift 就是一个面向协议的编程语言。” 如果你看看 Swift 的标准库,那有超过 50 个协议。这就是这门语言的成形之处,它使用了许多的协议而且这也是我们想借鉴的地方。Dave 还给了一个如何使用协议来改进我们现有代码的例子。他使用了 drawables 的例子,比如正方形、三角形,圆形。使用协议能够让它们的实现变得特别令人吃惊。我是被震撼到了,但是对于我来说我却无法直接使用,因为我在每天的工作中不使用 drawables。
回去以后,我冥思苦想,我该如何在每天的程序中使用面向协议编程呢。我们都有些从 Objective-C 和其他编程语言继承下来的编程模式,所以从面向对象转变到面向协议是一件很难的事情。
实践 POP!
过去一年,我终于有机会实验一下使用协议,我想分享些我改进代码的例子。因为这是实践面向协议编程,我将会讲到 View、 (UITable)ViewController 和 Networking。希望这能帮助你们考虑如何在你们的实际工作中使用协议。
Views
让我们假设你的产品经理过来和你说,“我们想在点击那个按钮时候出现一个视图,而且它会抖动。” 这是一个非常常见的动画,比如,在你的密码输入框上 – 当用户输入���错误密码时,它就会抖动。
我们常常都是从 Stack Overflow 开始的(笑)。一些人可能已经有了 Swift 抖动对象的基础代码。一些人甚至都有 Swift 的抖动对象的代码,我想都不用想,只要稍稍修改一下。最难的部分当然是架构:我在哪里集成这些代码呢?
// FoodImageView.swift
import UIKit
class FoodImageView: UIImageView {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
我将创建一个 UIImageView
的子类,创建我的 FoodImageView
然后增加一个抖动的动画:
// ViewController.swift
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var foodImageView: FoodImageView!
@IBAction func onShakeButtonTap(sender: AnyObject) {
foodImageView.shake()
}
}
在我的 view controller 里面,在 interface builder 里我连接我的 view,把它做为 FoodImageView
的子类,我有一个 shake 函数,然后 完成了!。10 分钟我就完成了这个功能。我很开心,我的代码工作得很正常。
然后,你的产品经理过来说,”你需要在抖动视图的时候抖动按钮。” 然后我回去对按钮做了同样的事情。
// ShakeableButton.swift
import UIKit
class ActionButton: UIButton {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
子类,创建一个按钮,增加一个 shake()
函数,和我的 ViewController
。现在我能抖动我的 food 图像视图和按钮了,完成了。
// ViewController.swift
class ViewController: UIViewController {
@IBOutlet weak var foodImageView: FoodImageView!
@IBOutlet weak var actionButton: ActionButton!
@IBAction func onShakeButtonTap(sender: AnyObject) {
foodImageView.shake()
actionButton.shake()
}
}
幸运的是,这会给你一个警告:我在两个地方重复了抖动的代码。如果我想改变抖动的幅度,我需要改两处代码,这很不好。
// UIViewExtension.swift
import UIKit
extension UIView {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
作为一个优秀的程序员,我们马上会意识到这点,而且试图重构。如果你以前使用过 Objective-C,我会创建一个 UIView
的类别,在 Swift 里面,这就是扩展。
我能这样做,因为 UIButton
和 UIImageView
都是 UI 视图。我能扩展 UI 视图而且增加一个 shake 函数。现在我仍然可以给我的按钮和图像视图都加上其他的逻辑,但是 shake 函数就到处都是了。
class FoodImageView: UIImageView {
// other customization here
}
class ActionButton: UIButton {
// other customization here
}
class ViewController: UIViewController {
@IBOutlet weak var foodImageView: FoodImageView!
@IBOutlet weak var actionButton: ActionButton!
@IBAction func onShakeButtonTap(sender: AnyObject) {
foodImageView.shake()
actionButton.shake()
}
}
马上我们就能发现可读性很差了。例如,对于 foodImageView
和 actionButton
来说,你看不出来任何抖动的意图。整个类里面没有任何东西能告诉你它需要抖动。这不清楚,是因为别处会随机存在一个抖动函数。
如果你常常为类别和 UI view 的扩展这样做的话,你可能会有更好的办法。这就是所谓的 科学怪人的垃圾地点,你增加了一个 shake 函数然后有人来和你说, “我想要一个可调暗的视图”。然后你增加一个 dim 函数和其他别处随机的调用函数。这样,文件会变得越来越长,不可读,很难找到垃圾,因为这些随机调用的事情都可以在 UI 视图里面完成,尽管有些时候也许只有一两个地方需要这么做。
意图是什么并不清晰。我们如何改变这点呢?
这是一次面向协议编程的演讲,我们当然会用到协议。让我们创建一个 Shakeable
的协议:
// Shakeable.swift
import UIKit
protocol Shakeable { }
extension Shakeable where Self: UIView {
func shake() {
// implementation code
}
}
在协议扩展的帮助下,你可以把它们限制在一个特定的类里面。在这个例子里面,我能抽出我的 shake 函数,然后用类别,我能说这是我们需要遵循的唯一的东西,只有 UI 视图会有这个函数。
你仍然可以使用你原来想用的同样强大的扩展功能,但是你有协议了。任何遵循协议的非视图不会工作。只有视图才能有这个 shake 的默认实现。
class FoodImageView: UIImageView, Shakeable {
}
class ActionButton: UIButton, Shakeable {
}
我们可以看到 FoodImageView
和 ActionButton
会遵循 Shakeable
协议。它们会有 shake
函数,现在的可读性强多了 –- 我可以理解 shaking 是有意存在的。如果你在别处使用视图,我需要想想,”在这也需要抖动吗?”。它增强了可读性,但是代码还是闭合的和可重用的。
假设我们想抖动和调暗视图。我们会有另外一个协议,一个 Dimmable
协议,然后我们可以为了调暗做一个协议扩展。再强调一遍,通过看类的定义来知晓这个类的用途,这样,意图就会很明显了。
class FoodImageView: UIImageView, Shakeable, Dimmable {
}
关于重构,当我们说 “我不想要抖动了”的时候,你只需要删除相关的 Shakeable
协议就好了。
class FoodImageView: UIImageView, Dimmable {
}
现在它只能调暗了。插入是非常容易的,通过使用协议我们很容易获得乐高似的架构。看看 这篇文章 如果你想学习使用协议的其他更强大的方式的话,试试创建一个有过渡效果的可调暗的视图。
现在我们高兴了,可以去吃 Pop-tarts 了。
(UITable)ViewControllers
这是一个应用,Instagram 食物:它给你展示不同地点的美食照片。
// FoodLaLaViewController
override func viewDidLoad() {
super.viewDidLoad()
let foodCellNib = UINib(NibName: "FoodTableViewCell", bundle: nil)
tableView.registerNib(foodCellNib,
forCellReuseIdentifier: "FoodTableViewCell")
}
这是一个 tableView
。这是我们一直都会编写的基础代码。当视图加载的时候,我们会从 Nib
中加载 cell;我们定制 NibName
,然后我们使用一个 ReuseIdentifier 来注册 Nib
。
let foodCellNib = UINib(NibName: String(FoodTableViewCell), bundle: nil)
tableView.registerNib(foodCellNib,
forCellReuseIdentifier: String(FoodTableViewCell))
不幸的是,因为 UIKit 创建方式的限制,我们不得不使用字符串。我喜欢为我的 cell 使用相同的 identifiers 来作为 cell 的名字。
我们立刻就能看到低效的地方。如果你以前使用的是 Objective-C,我常常使用 NSString
作为类。在 Swift 里面,你可以使用 String
(稍好一点),相较 Objective-C 而言,我们已经足够好了。我们常常就使用 String
,但是如果一个没有做过 iOS 开发的实习生来到我们的项目,这个函数对他来说就是天书。你会随机的字符串化一些名字,”为什么你这样做呢?”。同时,如果你不在 storyboard 里指定 identifier 的话,现在它会 crashing 而且他们还不知道什么原因。我们该如何改进呢?
protocol ReusableView: class {}
extension ReusableView where Self: UIView {
static var reuseIdentifier: String {
return String(self)
}
}
extension UITableViewCell: ReusableView { }
FoodTableViewCell.reuseIdentifier
// FoodTableViewCell
因为我们不再使用 Objective-C 了,我们可以为这些 cell 重用视图协议。
let foodCellNib = UINib(NibName: "FoodTableViewCell",
bundle: nil)
tableView.registerNib(foodCellNib,
forCellReuseIdentifier: FoodTableViewCell.reuseIdentifier)
protocol NibLoadableView: class { }
extension NibLoadableView where Self: UIView {
static var NibName: String {
return String(self)
}
}
再说一次,表格视图里面每一个单独的复用 identifier 都会变成类的字符串版本。我们可以对每一个视图使用协议扩展。这对UICollectionView
UITableView
Cell
也适用。这是我们的可复用的 identifier。我们可以把这个不得不用的讨厌逻辑封装起来. 因为 UIKit
需要它。现在我们能扩展每一个单独的 UITableViewCell
了。
extension FoodTableViewCell: NibLoadableView { }
FoodTableViewCell.NibName
// "FoodTableViewCell"
我们可以对 UICollectionViewCell
做同样的事情来扩展可复用的视图协议。每一个单独的 cell 都有一个默认的 reuseIdentifier
,我们再不需要输入一遍或者担心了。我们说, FoodTableViewCell
、 reuseIdentifier
,它将会通过字符串化类来帮助我们完成这件事情。
这依旧很长,但是更易读:
let foodCellNib = UINib(NibName: FoodTableViewCell.NibName, bundle: nil)
tableView.registerNib(foodCellNib,
forCellReuseIdentifier: FoodTableViewCell.reuseIdentifier)
extension UITableView {
func register<T: UITableViewCell where T: ReusableView, T: NibLoadableView>(_: T.Type) {
let Nib = UINib(NibName: T.NibName, bundle: nil)
registerNib(Nib, forCellReuseIdentifier: T.reuseIdentifier)
}
}
我们也可以对 NibName
做同样的事情,因为我们不想处理字符串。我们能创建一个 NibLoadableView
(任何能从 Nib 里加载的类)。我们会有一个 NibName
,而且它会返回类名的字符串版本。
let foodCellNib = UINib(NibName: "FoodTableViewCell", bundle: nil)
tableView.registerNib(foodCellNib,
forCellReuseIdentifier: "FoodTableViewCell")
如何从 Nib
里面加载的视图,比如我们的 TableViewCell
,将会遵循 Nib
可加载视图协议。它会自动地有一个 NibName
的属性,而且会字符串化类名。至少实习生能明白我们现在有了 cell 的 NibName
,它是这个 cell 的 reuseIdentifier
,而且每一次我们注册这个类的时候,每一个 TableViewCell
都是这样的。
我们现在能再进一步,使用泛型来注册我们的 cells,然后提取这两行代码。
tableView.register(FoodTableViewCell)
我们可以扩展我们的 tableView
然后创建一个注册类,这个类可以接收一个类型包含这两种协议。它有一个可重用的标识符和一个从那些协议里面获取的 Nib
名字。现在我们可以完整地从遵循 NibLoadableView
要求的 Nib
名字的位置,抽取这两行代码的逻辑出来。我们知道它有一个叫做 NibName
的属性,而且 cell 会遵循可重用的视图协议 (它们会有可重用的标识符属性)。这两行代码,本来我们需要在每一个单独的表格视图里面都输入一遍,现在被抽取出来了。只需要一行代码,我们就完成 cell 的注册,这看起来会干净许多。你不需要再处理字符串了。
我们可以更进一步。我们不得不注册 cells,我们也不得不清理 cells。我们可以用泛型和协议来代替这些本来很丑的代码:当你需要清理的时候,你需要指明 reuseIdentifier
。在 Swift 里面,这只需要三行代码,因为我们有 optionals。
extension UITableView {
func dequeueReusableCell<T: UITableViewCell where T: ReusableView>(forIndexPath indexPath: NSIndexPath) -> T {
guard let cell = dequeueReusableCellWithIdentifier(T.reuseIdentifier, forIndexPath: indexPath) as? T else {
fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)")
}
return cell
}
}
当你清理一个 cell 的时候,你需要这个保证声明,这是一个 cell 的清理;如果没有这个声明,你或者有一个严重的错误,或者一个 explicit unwrapping。
guard let cell = tableView.dequeueReusableCellWithIdentifier(“FoodTableViewCell", forIndexPath: indexPath)
as? FoodTableViewCell
else {
fatalError("Could not dequeue cell with identifier: FoodTableViewCell")
}
当你输入这行代码的时候,你都会觉得丑陋。它源自 Objective-C,我们从 UIKit 里面开始有它,我们对它没有太多的办法。但是使用协议,我们可以抽取这些丑陋的地方,因为我们对每一个单独的表格视图 cell 都有 reuseIdentifier
。
let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as FoodTableViewCell
我们能如下实现上面这段代码的功能:
if indexPath.row == 0 {
return tableView.dequeueReusableCell(forIndexPath: indexPath) as DesertTableViewCell
}
return tableView.dequeueReusableCell(forIndexPath: indexPath) as FoodTableViewCell
每一次我们清理一个 cell,我们都能这样做。我们在 forIndexPath
里清理 cell,而且我们说明 cell 是哪个。如果你有多个 cells,你可以把它转换成你注册的那个 cell,它马上就会知道它的类型是什么了。
这太神奇了! 这是个替代原来我们在 Objective-C 里方式的好方法,这个方法混合了 Swift 和 optionals,并采用了协议和泛型,给我们的项目带来更好看的代码。
iOS Cell 注册 & 用 Swift 协议扩展和泛型来实现复用
这部分源自 Guille Gonzalez,他把这个原则���用到 collection view 上,你也可以把这个方法运用到其他你有问题的 UIKit 的组件上,例如 Swift 中面向协议的 Segue 标识符。你可以在每天的编程中都像那样使用协议,这样也会安全些。它也是源自 Apple 去年 WWDC 上的例子。面向协议编程真的很棒。
网络
使用网络的时候,你一般要调用 API。 我常常这样做:我有一些服务 (比如 我从服务器那获取食物),我有一个 get
函数,它会调用 API 然后得到结果。我想使用 Swift 的错误处理,但是它是异步的,我不能抛出错误。
struct FoodService {
func get(completionHandler: Result<[Food]> -> Void) {
// make asynchronous API call
// and return appropriate result
}
}
我将使用结果枚举,这是在Swift里面源自 Haskel 的常见模式。
结果枚举很简单。当服务器返回结果的时候,我们能把它解析为成功然后返回一个食物条目的数组。如果失败了,我们能返回一个错误码,然后完成句柄中的 view controller 会知道如何处理这些情况。
enum Result<T> {
case Success(T)
case Failure(ErrorType)
}
当服务器异步返回结果的时候,这使用了我们的完成句柄,我们将传入食物条目的结果。
struct FoodService {
func get(completionHandler: Result<[Food]> -> Void) {
// make asynchronous API call
// and return appropriate result
}
}
现在 view controller 将会解析它们。我们在 view controller 里有一个 dataSource
。当视图加载的时候,我们将调用异步 API,然后再完成句柄中得到结果。如果结果是一组食物,太棒了:我们重置数据,重新加载表格视图。如果结果是个错误,我们会给用户一个错误提示,然后处理它。
// FoodLaLaViewController
var dataSource = [Food]() {
didSet {
tableView.reloadData()
}
}
override func viewDidLoad() {
super.viewDidLoad()
getFood()
}
private func getFood() {
FoodService().getFood() { [weak self] result in
switch result {
case .Success(let food):
self?.dataSource = food
case .Failure(let error):
self?.showError(error)
}
}
}
这是一个典型的调用 API 的模式。但是整个 view controller 依赖上食物数组的加载了:如果没有数据或者数据错误,它会失败。确认 view controller 是按预期正确处理了数据的最好方式是……测试。
View Controller 测试?!!!
View Controller 测试很痛苦。在这个例子中,因为我们有了服务,异步 API 调用,一个完成代码块,和一些结果枚举,测试就会更加痛苦。这些都使得测试 view controller 是否按预期工作变得更加困难。
// FoodLaLaViewController
var dataSource = [Food]() {
didSet {
tableView.reloadData()
}
}
override func viewDidLoad() {
super.viewDidLoad()
getFood()
}
private func getFood() {
FoodService().getFood() { [weak self] result in
switch result {
case .Success(let food):
self?.dataSource = food
case .Failure(let error):
self?.showError(error)
}
}
}
首先,我们需要对 food 服务有更多的控制;我们需要能够给它注入一个食物的数组或者一个错误。我们能看到问题了:当 getFood()
实例化一个 food 服务的时候,我们的测试没有机会能注入。第一个测试是增加依赖注入。
// FoodLaLaViewController
func getFood(fromService service: FoodService) {
service.getFood() { [weak self] result in
// handle result
}
}
// FoodLaLaViewControllerTests
func testFetchFood() {
viewController.getFood(fromService: FoodService())
// now what?
}
现在我们的 getFood()
函数接收 FoodService
参数,这样我们就有了更多的控制权了,之后我们才能做更多的测试。我们有 controller
,叫做 getFood
函数,然后我们给它传入 FoodService
。当然,我们想要对于 FoodService
完整的控制。 我们如何实现呢?
struct FoodService {
func get(completionHandler: Result<[Food]> -> Void) {
// make asynchronous API call
// and return appropriate result
}
}
这是一个值类型:你不能有子类。相反,你需要用协议。 FoodService
有一个 get 函数,completionHandler
会给出结果。你可以想象你应用里面的每一个服务,每一个 API 调用都需要一个 get 函数 (比如 dessert),也会有类似的东西。它有一个完成句柄能够接收结果,然后解析它。
我们马上能让它变得更通用:
protocol Gettable {
associatedtype T
func get(completionHandler: Result<T> -> Void)
}
我们能使用协议和相关的类型 (Swift 里面使用泛型的方式)。我们说每一个遵循 Gettable
协议的地方都有 get 函数,而且它接收一个完成句柄和这个类型的结果。在我们的例子中,这会是 food (但是在 dessert 服务中,它会是 dessert;这是能互相交换的)。
struct FoodService: Gettable {
func get(completionHandler: Result<[Food]> -> Void) {
// make asynchronous API call
// and return appropriate result
}
}
回到 food 服务,唯一的改变就是它需要遵循 Gettable
协议。 get 函数已经实现了。它只需要接收一个 completionHandler
,这个句柄接收结果的条目……因为相关类型的协议是智能的 (结果是 food 数组,相关类型就是 food 数组)。你不需要描述它。
回到 view controller,这基本上就是一样的了。
// FoodLaLaViewController
override func viewDidLoad() {
super.viewDidLoad()
getFood(fromService: FoodService())
}
func getFood<S: Gettable where S.T == [Food]>(fromService service: S) {
service.get() { [weak self] result in
switch result {
case .Success(let food):
self?.dataSource = food
case .Failure(let error):
self?.showError(error)
}
}
}
唯一的区别就是你需要的相关类型只能是 food 数组 (你可不希望我们的 food view controller 调用 dessert 服务)。你想限制它,然后说明这个 getFood()
函数只能获得 food 条目的结果。否则,它就是其他遵循 Gettable
协议的东西。这使得我们能对传入的,诸如 FoodService
的参数有更强的控制 – 因为它不需要一定是 FoodService
,我们能注入其他的东西。
在我的测试中,我们创建了一个 Fake_FoodService
。它有两个事情: 1) 遵循 Gettable
协议,2) 相关类型需要是 food 数组。
// FoodLaLaViewControllerTests
class Fake_FoodService: Gettable {
var getWasCalled = false
func get(completionHandler: Result<[Food]> -> Void) {
getWasCalled = true
completionHandler(Result.Success(food))
}
}
它遵循 Gettable
,它接收 food 的结果,然后返回一个 food 数组。因为这是测试,我们想确定 Gettable
的 get 函数被调用了,因为它能返回,而且函数理论上可以分配一个从任意地方获取的 food 条目的数组。我们需要保证它被调用到;在这个例子里面是成功的例子,但是你能通过注入失败来完成同样的对 view controller 的测试,来保证你的 view controller 的行为在你的输入结果的条件下是正常的。测试如下:
// FoodLaLaViewControllerTests
func testFetchFood() {
let fakeFoodService = Fake_FoodService()
viewController.getFood(fromService: fakeFoodService)
XCTAssertTrue(fakeFoodService.getWasCalled)
XCTAssertEqual(viewController.dataSource.count, food.count)
XCTAssertEqual(viewController.dataSource, food)
}
我们有 fakeFoodService
:我们能注入我们的 fakeFoodService
(这个我们有更强的控制力),而且我们能测试 get 函数能被调用到,而且通过我们的 FoodService
注入的数据源和我们赋给 view controller 的数据源是同组数据。通过增加 Gettable
协议,我们有了一个对 view controller 的强大测试,我们有了对所有服务的测试框架。我们能实现一个可删除的,可更新的,可创建的协议;关于服务,你能马上看出哪个函数需要被实现而且容易注入,然后测试它们。
我用协议写了一个注入 storyboards 的例子,而且我强烈推荐 Alexis Gallagher 的这篇演讲 相关类型的协议. 我简化了它,但是相关类型的协议也会常常出乎意料。使用它时,你可能会感到沮丧,这篇文章会使你平静些,因为他解释了它的限制。
然后,你可以回来,享受爆米花了。
POP 实践!结论
我们讨论了协议是如何在实际工作中运用的,特别是在每天编码过程中是如何把它使用到视图控制器,视图和网络中去的。本篇演讲帮助你编写出安全的,可维护的,可重用的,更统一的,模块化代码。更加易于测试的代码。相比于子类而言,协议更棒。
然而,协议也会被滥用。我可能使用了过多的协议了:我学习它,它是个全新的东西而且吸引眼球,我希望无时不刻都使用它……但是这是不必要的。在我的第一个例子里面,当我有一个抖动的视图和抖动函数的时候,这就很棒。只有在两个视图都需要抽象的时候,我需要重构它们的时候,放到协议里才是合理的。不要疯狂地使用协议。
作为结束,两个非常有趣的演讲:
Beyond Crusty: 真实世界的协议 作者:Rob Napier,基于真实世界的协议。他一开始介绍了些坏的代码,用协议重构了它们……使用了 10 个协议和 20 行代码。他通过结构解决了问题,仅仅用了四行代码。在 Swift 里,我们有不同的新的东西,包括强大的枚举,结构和协议。基于具体问题,可能需要不同的解决方案。我推荐用协议实验,但是还是要想想,”这个问题结构能行吗,或者组合就足够了?”。
Blending Cultures: 函数式,面向协议和面向对象编程的最佳实践 作者: Daniel Steinberg。他从你如何使用每个方法开始,因为我们不受限于协议,我们仍然有面向对象的思想和函数式思想。这是一个很棒的演讲,它展示了如何使用一切手段来完成你的代码中不变部分和变化部分的抽取。
在你每天编程的时候,希望你能考虑使用协议!