真刀真枪面向协议编程
原文出处:真刀真枪 面向协议编程
真刀真枪 面向协议编程
“我们如何在每天的开发过程中使用面向协议编程?Natasha 回答了这个问题,并专门针对 POP的实际应用开发给出了解决方案,包含视图,视图控制器和网络的实例。关注本篇在 App Builders CH 大会上的演讲,你将从面向对象编程转向面向协议编程,这样能使你的 Swift编程更加清晰、更加易读!
回到现实 – 我们假设 Swift 是最棒的编程语言。
今天,我将谈谈基于 Swift 的面向协议编程,我会侧重在如何实现上。让我们称它 POP。
基于 Swift 的面向协议编程 (00:37);)
当 Swift 刚刚出现的时候,学习新东西都是令人兴奋的。第一年,我很高兴能学习它,我之前在 Swift 里面使用我的 Objective C 代码(有的时候用些值类型和更加有趣的东西)。但是直到去年的 WWDC,协议扩展出现了。
Dave Abrahams (让你大开眼界的教授) 做了一次令人大开眼界的演讲 “基于 Swift 的面向协议编程”。他声称 “Swift就是一个面向协议的编程语言。” 如果你看看 Swift 的标准库,那有超过 50个协议。这就是这门语言的成形之处,它使用了许多的协议而且这也是我们想借鉴的地方。Dave 还给了一个如何使用协议来改进我们现有代码的例子。他使用了drawables的例子,比如正方形、三角形,圆形。使用协议能够让它们的实现变得特别令人吃惊。我是被震撼到了,但是对于我来说我却无法直接使用,因为我在每天的工作中不使用drawables。
回去以后,我冥思苦想,我该如何在每天的程序中使用面向协议编程呢。我们都有些从 Objective-C和其他编程语言继承下来的编程模式,所以从面向对象转变到面向协议是一件很难的事情。
实践 POP! (03:05);)
过去一年,我终于有机会实验一下使用协议,我想分享些我改进代码的例子。因为这是实践面向协议编程,我将会讲到 View、(UITable)ViewController 和 Networking。希望这能帮助你们考虑如何在你们的实际工作中使用协议。
Views (03:24);)
让我们假设你的产品经理过来和你说,“我们想在点击那个按钮时候出现一个视图,而且它会抖动。” 这是一个非常常见的动画,比如,在你的密码输入框上 – 当用户输入���错误密码时,它就会抖动。
我们常常都是从 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
(10:09);)
这是一个应用,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 协议扩展和泛型来实现复用
(17:28);)
这部分源自 Guille Gonzalez,他把这个原则���用到 collection view 上,你也可以把这个方法运用到其他你有问题的 UIKit 的组件上,例如 Swift 中面向协议的 Segue 标识符。你可以在每天的编程中都像那样使用协议,这样也会安全些。它也是源自 Apple 去年 WWDC 上的例子。面向协议编程真的很棒。
网络 (18:25);)
使用网络的时候,你一般要调用 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 测试?!!!
(20:54);)
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)