iOS 9 系列教程 在UIKit中实践面向协议编程

iOS 9 系列教程 在UIKit中实践面向协议编程

原文在此 翻译 by skyfly.xyz

在 WWDC2015 上那次关于 Swift 面向协议编程的发人深省的演讲后(是的,“很难啃”的那部分)似乎每个人都在谈论协议扩展这个令人兴奋的新的语言特性,同时它也使每个人都感到过困惑。

我曾经阅读过不计其数的关于 Swift 协议和协议扩展细节的文章,很明显,协议扩展将会成为 Swift 这道大餐里面的一剂猛料,苹果甚至建议,在可能的情况下尽量使用协议来替代类 —— 这个方法是面向协议编程的关键。

但是这些文章仅仅告诉了我什么是协议扩展,他们并没有明确的告诉我面向协议编程对于 UI 开发的意义是什么。现在大多数可用的示例代码构建的场景都是从来没有利用过任何框架。

我想要知道协议扩展应该如何应用于我已经构建的那些应用,我如何才能更加有力地在 iOS 中最重要的框架 — UIKit 中去利用这些方法。

既然我们有协议扩展,那么基于协议的方法在充满了类的 UIKit 框架中是不是更有价值呢?这篇文章中,我尝试对 Swift 协议扩展在真实世界的 UI 场景中的作用作一个合理的说明,并且也是记录我发现并没有完全的认识到协议扩展作用的流水账。

使用协议的好处

协议并没有什么新鲜的,但我们可以使用内置的功能,共享的逻辑,神奇的力量扩展它们的这个想法……嗯,这是一个有趣的想法。更多个协议 == 更好的灵活性。每个协议扩展都是一小块可以被采用,重写(或者不)的模块化功能,并且可以通过 where 子句与特定类型的代码进行交互。

协议确实存在的目的确实只是为了讨编译器的欢心,但是协议扩展是真正可以在代码集之中共享的逻辑片段。

虽然我们只可以继承自一个父类,但是我们可以按照意愿遵守任意多数量的协议,遵守一个扩展后的协议就类似于在 Angular.js 中向 element 添加一个指令一样 —— 我们将一段改变对象行为的逻辑注射进去。协议不再仅仅是一个约定,结合扩展,他们可以是对功能的接受。

如何去使用协议扩展

协议扩展的用法十分简单,这篇文章不是一个指南,但是如果你需要尽快得知它是如何工作的而不是讨论协议扩展之于 UIKit 开发的适用性,请你去参考官方的 Swift 文档中关于协议扩展的那部分内容。

协议扩展的局限性

在我们开始之前,需要弄清楚协议扩展不能做什么。有很多我们不能使用协议扩展来做的事,它们之中有许多是因为语言的设计原因导致的。然而我希望看到的是,在Swift的后续版本中,苹果可以对这些局限性做相应的处理。

  • 不能在 Objective-C 中调用协议扩展中的成员。

  • 不能对结构体类型使用 where 语句。

  • 不能定义由多个逗号分隔的 where 语句,类似于 if let 声明。

  • 不能将动态变量存储在协议扩展中。

  • 这也适用于非泛型的扩展中

  • 静态变量应该是允许存在的,但是按照目前来看, Xcode 7.0 中将这种做法看作是一个错误,错误信息是 “static stored properties not yet supported in generic types”。

  • 不能通过super来调用一个协议扩展,不像非泛型扩展那样,不过 @ketzusaka 指出这可以通过这样的 (self as MyProtocol).method() 调用来实现。好想法!由于这个原因,协议扩展并没有真正的继承的概念。

  • 不能遵守多个其中有重复成员的协议扩展。Swift 运行时会遵守最后一个协议而忽略其他的协议。

    举个例子,如果我们有2个实现了相同方法的协议扩展,只有我们遵守的最后一个协议中的那个方法会被调用。无法调用其它扩展中的方法。

  • 不能继承可选的协议方法。

    可选协议方法需要使用 @objc 标签,然而这个标签并不能和协议扩展一起工作。

  • 协议和它的扩展不能同时声明。

如果能够在声明协议扩展协议部分的同时实现它的扩展,那就太好了。因为当扩展中包含了所有重要的逻辑时,协议中并不总是会有成员存在。

第一部分:扩展现有的 UIKit 协议

当我第一次了解到协议扩展的时候,首先映入我脑海的协议是 UITableViewDataSource ,可以说在iOS平台上这是被最广泛实现的一个协议。我想,我是不是可以在我的应用中为所有的 UITableViewDataSource 提供一个默认的实现呢?这个貌似很有趣。

如果每个 UITableView 有很多的 sections,为什么不在同一个地方继承 UITableViewDataSource 并且实现 numberOfSectionsInTableView: 呢?如果我要在所有的列表中实现滑动删除功能的话,为何不在协议扩展中去实现 UITableViewDelegate 呢?

到现在为止,这件事还是不可能的。

我们不能做的:为 Objective-C 协议提供默认实现。

UIKit 仍然是从 Objective-C 编译过来的,而且 Objective-C 中没有可扩展协议这个概念。在实践中,这意味着尽管我们可以声明对 UIKit 协议的扩展,但是 UIKit 对象并不能够发现我们写在协议扩展中的方法。

举一个例子:如果我们在 UICollectionViewDelegate 的扩展中实现 collectionView:didSelectItemAtIndexPath: 方法,结果就是当 cell 被点击的时候,我们写在协议扩展中的这个方法并不会被调用,因为处于 Objective-C 语境中的 UICollectionView 发现不了这个方法。如果我们将一个非可选方法写到协议扩展里面,比如 collectionView:cellForItemAtIndexPath:,编译器将会抱怨说,我们没有遵守 UICollectionViewDelegate 协议。

Xcode 尝试着去给我们的协议扩展方法添加 @objc 前缀去修复这个问题,但是这是白费劲,这样做只会导致一个新的错误:"Method in protocol extension cannot be represented in Objective-C." 这是底层的问题 —— 协议扩展只在 Swift2.x 以上版本的代码中才是可用的。

我们不能做的:为一个现存的 Objective-C 协议添加新的方法。

我们可以通过 Swift 直接调用 UIKit 的协议扩展方法,就算 UIKit 无法发现他们。这意味着尽管我们不能重写现存的 UIKit 协议方法,但是当我们需要使用那些协议的时候,可以为他们添加新的便利方法。

我承认,这好像没有那么令人激动。并且任何传统的 Objective-C 代码依旧无法调用这些方法。但这里还是留着一些机会。下面是一些简单的例子,这些例子告诉我们当前可以如何将现有的 UIKit 协议和协议扩展结合使用。

UIKit 协议扩展示例

扩展 UICoordinateSpace

是否曾经有过必须在 Core Graphics 和 UIKit 坐标系中做转换的经历?我们可以为 UICoordinateSpace 添加辅助方法,一个让 UIView 遵守的协议。

1
2
3
4
5
6
7
extension UICoordinateSpace {
func insvertedRect(rect: CGRect) -> CGRect {
var transform = CGAffineTransformMakeScale(1, -1)
transform = CGAffineTransformTranslate(transform, 0, -self.bounds.size.height)
return CGRectApplyAffineTransform(rect, transform)
}
}

现在我们的 invertedRect 方法在任何 UICoordinateSpace 协议的遵守者中都是可用的了,我们可以在我们的绘图方法中使用它。

1
2
3
4
5
6
7
class DrawingView : UIView {
// Example -- Referencing custom UICoordinateSpace method inside UIView drawRect.
override func drawRect(rect: CGRect) {
let invertedRect = self.invertedRect(CGRectMake(50.0, 50.0, 200.0, 100.0))
print(NSStringFromCGRect(invertedRect)) // 50.0, -150.0, 200.0, 100.0
}
}

扩展 UITableViewDataSource

尽管我们不能为 UITableViewDataSource 提供默认实现,但我们仍然可以将那些可以被应用中任何 UITableViewDataSource 用到的通用逻辑放入协议中。

1
2
3
4
5
6
7
8
9
10
11
12
extension UITableViewDataSource {
// Returns the total # of rows in a table view.
func totalRows(tableView: UITableView) -> Int {
let totalSections = self.numberOfSectionsInTableView?(tableView) ?? 1
var s = 0, t = 0
while s < totalSections {
t += self.tableView(tableView, numberOfRowsInSection: s)
s++
}
return t
}
}

上面的 totalRows: 方法是一个能够计算出我们 TableView 中 cell 个数的快捷方法,当 TableView 被分成很多 section 但是需要显示条目总数的时候很有用。tableView:titleForFooterInSection: 是使用这个方法的最佳位置。

1
2
3
4
5
6
7
8
9
class ItemsController: UITableViewController {
// Example -- displaying total # of items as a footer label.
override func tableView(tableView: UITableView, titleForFooterInSection section: Int) -> String? {
if section == self.numberOfSectionsInTableView(tableView)-1 {
return String("Viewing %f Items", self.totalRows(tableView))
}
return ""
}
}

扩展 UIViewControllerContextTransitioning

在阅读过我的《自定义导航过场动画 & 更多(胡乱塞入的)》这篇文章后,也许你正在忙于制作一个自定义的导航过场动画。这里有一些我本应该在那个教程中用到的方法,是通过
UIViewControllerContextTransitioning 协议提供的。

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
extension UIViewControllerContextTransitioning {
// Mock the indicated view by replacing it with its own snapshot. Useful when we don't want to render a view's subviews during animation, such as when applying transforms.
func mockViewWithKey(key: String) -> UIView? {
if let view = self.viewForKey(key), container = self.containerView() {
let snapshot = view.snapshotViewAfterScreenUpdates(false)
snapshot.frame = view.frame

container.insertSubview(snapshot, aboveSubview: view)
view.removeFromSuperview()
return snapshot
}

return nil
}


// Add a background to the container view. Useful for modal presentations, such as showing a partially translucent background behind our modal content.
func addBackgroundView(color: UIColor) -> UIView? {
if let container = self.containerView() {
let bg = UIView(frame: container.bounds)
bg.backgroundColor = color

container.addSubview(bg)
container.sendSubviewToBack(bg)
return bg
}
return nil
}
}

我们能在传入我们动画协调者的 transitionContext 对象中调用这些方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AnimationCoordinator : NSObject, UIViewControllerAnimatedTransitioning {
// Example -- using helper methods during a view controller transition.
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
// Add a background
transitionContext.addBackgroundView(UIColor(white: 0.0, alpha: 0.5))

// Swap out the "from" view
transitionContext.mockViewWithKey(UITransitionContextFromViewKey)

// Animate using awesome 3D animation...
}

func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return 5.0
}
}

扩展 UIScrollViewDelegate

我们现在来看这种情况,在我们的应用中有许多个 UIPageControl 实例,为了使他们工作,我们往往要在各个 UIScrollViewDelegate 的实现之间复制和粘贴一些代码。协议扩展可以让这些逻辑变成通用化,而所有的调用都可以通过 self 来进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension UIScrollViewDelegate {
// Convenience method to update a UIPageControl with the correct page.
func updatePageControl(pageControl: UIPageControl, scrollView: UIScrollView) {
pageControl.currentPage = lroundf(Float(scrollView.contentOffset.x / (scrollView.contentSize.width / CGFloat(pageControl.numberOfPages))));
}
}
Additionally, if we know Self is a UICollectionViewController we can eliminate the method's scrollView parameter.

extension UIScrollViewDelegate where Self: UICollectionViewController {
func updatePageControl(pageControl: UIPageControl) {
pageControl.currentPage = lroundf(Float(self.collectionView!.contentOffset.x / (self.collectionView!.contentSize.width / CGFloat(pageControl.numberOfPages))));
}
}

// Example -- Page control updates from a UICollectionViewController using a protocol extension.
class PagedCollectionView : UICollectionViewController {
let pageControl = UIPageControl()

override func scrollViewDidScroll(scrollView: UIScrollView) {
self.updatePageControl(self.pageControl)
}
}

没错,这些例子都很做作,事实证明,当涉及到扩展现有的 UIKit 协议时,并没有太多的选择,所做的事情都是非常不值一提的。然而,我们还是要问,如何才能在现有的 UIKit 设计模式上去使用自定义的协议扩展。

第二部分:扩展自定义的协议

使用 MVC 模式的面向协议编程
一个 iOS 应用一定会实现3个最重要的功能,这也是 iOS 应用开发的核心。这种设计模式通常被称为 MVC (Model-View-Controller)。其实,一个 app 真正所做的事情就是将某种数据可视化的表示出来。

MVC详解

在下面三个例子中,我将会通过协议扩展来演示一些面向协议的设计模式,MVC模式的3个组成部分按照 Model -> Controller -> View 的顺序来工作。

利用协议来管理Model(M)

就拿我们正在开发的音乐 app Pear Music 来讲。涉及到了艺人,唱片,歌曲和播放列表这些 Model。我们需要构建一些代码来根据一个已经载入的标志从网络上获取这些 model。

当使用协议来进行开发的时候,最好从一个最高层次的抽象来入手。一个基本的原理是通过 API 按照资源的远程表示填充到 model 中。

1
2
// Any entity which represents data which can be loaded from a remote source.
protocol RemoteResource {}

不过,稍等一下,这只不过是一个空的协议!我们不打算直接使用 RemoteResource 。这不是一个合约,而是一组功能,其中包括发起网络请求。因此,RemoteResource 的真正价值在于它的协议扩展。

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
extension RemoteResource {
func load(url: String, completion: ((success: Bool)->())?) {
print("Performing request: ", url)

let task = NSURLSession.sharedSession().dataTaskWithURL(NSURL(string: url)!) { (data, response, error) -> Void in
if let httpResponse = response as? NSHTTPURLResponse where error == nil && data != nil {
print("Response Code: %d", httpResponse.statusCode)

dataCache[url] = data
if let c = completion {
c(success: true)
}
} else {
print("Request Error")
if let c = completion {
c(success: false)
}
}
}
task.resume()
}

func dataForURL(url: String) -> NSData? {
// A real app would require a more robust caching solution.
return dataCache[url]
}
}
public var dataCache: [String : NSData] = [:]

现在我们有了一个具有内建了加载远程数据并且检索数据功能的协议,任何这个协议的接受者将会自动得到这些方法。

我们已经给出了两个 API 用于交互,一个是表示 JSON 数据的(api,pearmusic.com)另一个是表示媒体的(media.pearmusic.com)。为了处理这个,我们将会为每种类型的数据设计一个 RemoteResource 的子协议。

1
2
3
4
5
6
7
8
9
10
protocol JSONResource : RemoteResource {
var jsonHost: String { get }
var jsonPath: String { get }
func processJSON(success: Bool)
}

protocol MediaResource : RemoteResource {
var mediaHost: String { get }
var mediaPath: String { get }
}

一起来实现这几个协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
extension JSONResource {
// Default host value for REST resources
var jsonHost: String { return "api.pearmusic.com" }

// Generate the fully qualified URL
var jsonURL: String { return String(format: "http://%@%@", self.jsonHost, self.jsonPath) }

// Main loading method.
func loadJSON(completion: (()->())?) {
self.load(self.jsonURL) { (success) -> () in
// Call adopter to process the result
self.processJSON(success)

// Execute completion block on the main queue
if let c = completion {
dispatch_async(dispatch_get_main_queue(), c)
}
}
}
}

我们提供了默认的 host,一个用来生成完整的 URL,另一个用来让他自己通过 RemoteResourseload: 方法载入资源。一会我们会依靠我们的协议接受者来提供正确的 jsonPath。

MediaResource 的模式与之相似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
extension MediaResource {
// Default host value for media resources
var mediaHost: String { return "media.pearmusic.com" }

// Generate the fully qualified URL
var mediaURL: String { return String(format: "http://%@%@", self.mediaHost, self.mediaPath) }

// Main loading method
func loadMedia(completion: (()->())?) {
self.load(self.mediaURL) { (success) -> () in
// Execute completion block on the main queue
if let c = completion {
dispatch_async(dispatch_get_main_queue(), c)
}
}
}
}

你可能注意到了这些实现是十分相似的。事实上将这些方法提升到 RemoteResource 它本身中是更加明智的,我们的子协议只需要返回正确的 host 就够了。

美中不足的是,我们的协议之间并不是互斥的 —— 我们想要一个能够同时表示 JSONResourceMediaResource 的对象。记住,协议扩展彼此之间会相互覆盖。除非我们想要显式地将这些属性和方法分开,只有哪些在最后一个被接受的协议中的方法和属性才能被调用。

通过给我们一些数据访问,来让他们更加特化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extension JSONResource {
var jsonValue: [String : AnyObject]? {
do {
if let d = self.dataForURL(self.jsonURL), result = try NSJSONSerialization.JSONObjectWithData(d, options: NSJSONReadingOptions.MutableContainers) as? [String : AnyObject] {
return result
}
} catch {}
return nil
}
}

extension MediaResource {
var imageValue: UIImage? {
if let d = self.dataForURL(self.mediaURL) {
return UIImage(data: d)
}
return nil
}
}

这是一个展现协议扩展价值的经典的例子。一个传统的协议说:“我保证我是这种类型的东西,拥有这些特性“,而一个协议扩展说:”我有这些特性,因此我可以做这些特别的事情”。 MediaResource 访问了图像数据,它可以不去管它的具体类型或者是上下文,而是很简单的提供一个图像的值。

我提到我们将要根据一个标志(identifiers)加载我们的 model ,所以我们来为那些具有一个唯一标志的实体写一个协议。

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
protocol Unique {
var id: String! { get set }
}

extension Unique where Self: NSObject {
// Built-in init method from a protocol!
init(id: String?) {
self.init()
if let identifier = id {
self.id = identifier
} else {
self.id = NSUUID().UUIDString
}
}
}

// Bonus: Make sure Unique adopters are comparable.
func ==(lhs: Unique, rhs: Unique) -> Bool {
return lhs.id == rhs.id
}
extension NSObjectProtocol where Self: Unique {
func isEqual(object: AnyObject?) -> Bool {
if let o = object as? Unique {
return o.id == self.id
}
return false
}
}

由于我们不能在扩展中存储属性,我们还是不得不去依赖我们 Unique 的接受者声明 id 类型的属性。另外,你可能注意到了,我只为那些那些继承自 NSObject 类型的对象扩展了 Unique 协议。否则我们就不能调用 self.init() 因为它没有被声明。我们用一个变通的办法来解决这个问题,这个办法就是在协议中声明 init() 方法,但是这就需要接受者来实现这个方法。由于我们所有的 model 都是继承自 NSObject 类型的,所以这个情况下,这不成问题。

Ok,我们已经得到了一个从网络加载数据的基本策略。我们开始让 model 遵守这些协议。这是 Song model 的代码。

1
2
3
4
5
6
7
8
9
10
11
class Song : NSObject, JSONResource, Unique {
// MARK: - Metadata
var title: String?
var artist: String?
var streamURL: String?
var duration: NSNumber?
var imageURL: String?

// MARK: - Unique
var id: String!
}

但是,稍等,JSONResource 的实现去哪了?

与其直接在我们的类中去实现 JSONResource 协议,不如利用一个条件化的协议扩展。这让我们只需在同一个地方稍作调整就可以组织所有基于 RemoteResource 的格式化逻辑,同时保持我们 model 实现的简洁。因此我们需要将下面的代码放进 RemoteResource.swift 文件中,而其他之前基于这个协议的逻辑则不用修改。

1
2
3
4
5
6
7
8
9
10
11
12
extension JSONResource where Self: Song {
var jsonPath: String { return String(format: "/songs/%@", self.id) }

func processJSON(success: Bool) {
if let json = self.jsonValue where success {
self.title = json["title"] as? String ?? ""
self.artist = json["artist"] as? String ?? ""
self.streamURL = json["url"] as? String ?? ""
self.duration = json["duration"] as? NSNumber ?? 0
}
}
}

保持在一个地方的所有的东西和 RemoteResource 相关对程序的组织有好处。协议的实现都在同一个地方,而且扩展的范围很清晰。当声明一个需要被扩展的协议时,我建议将扩展写在同一个文件内。

多亏了 JSONResource,Unique 的协议扩展,加载一首歌曲现在看起来是这个样子:

1
2
3
4
5
6
let s = Song(id: "abcd12345")
let artistLabel = UILabel()

s.loadJSON { (success) -> () in
artistLabel.text = s.artist
}

转眼间我们的 Song 对象是一个队员数据的简单封装了,这也正是它应该变成的样子。我们的协议扩展正在为它做所有麻烦的事情。

这里有一个例子,它解释了一个同时遵守了 JSONResourceMediaResourceMediaResource 协议的 Playlist 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Playlist: NSObject, JSONResource, MediaResource, Unique {
// MARK: - Metadata
var title: String?
var createdBy: String?
var songs: [Song]?

// MARK: - Unique
var id: String!
}

extension JSONResource where Self: Playlist {
var jsonPath: String { return String(format: "/playlists/%@", self.id) }

func processJSON(success: Bool) {
if let json = self.jsonValue where success {
self.title = json["title"] as? String ?? ""
self.createdBy = json["createdBy"] as? String ?? ""
// etc...
}
}
}

在之前我们不假思索地为 Playlist 实现了 MediaResource 协议,是时候向后退回一点。我们注意到 media API 只要求提供 identifier ,并没有说明任何的具体类型。那意味着我们只需要知道 identifier 就可以构建 meidaPath。我们来使用一个 where 子句来让 MediaResource “聪明地” 和 Unique 一起工作。

1
2
3
extension MediaResource where Self: Unique {
var mediaPath: String { return String(format: "/images/%@", self.id) }
}

由于 Playlist 已经遵守了 Unique 协议,这里已经不需要实现什么东西,就可以是它与 MediaResource 一起工作了!同样的逻辑可以应用到任何同样既是 Unique 也是 MediaResource 的对象上 —— 只要对象的 identifier 和我们 media API 中的图像符合就可以。他就将可以正常的工作。

Here’s how it looks to load our Playlist image:

看看它是怎么加载我们播放列表图片的:

1
2
3
4
5
6
let p = Playlist(id: "abcd12345")
let playlistImageView = UIImageView(frame: CGRectMake(0.0, 0.0, 200.0, 200.0))

p.loadMedia { () -> () in
playlistImageView.image = p.imageValue
}

我们现在有一个通用的方法去定义那些可以被我们 app 中任何实体用到的远程资源了,不只是这些 model 对象。我们可以通过简单的扩展 RemoteResource 协议来处理不同类型的 REST 操作,并且可以为其它类型的数据构建更多的子协议。

数据格式化协议 (C)

我们已经创建了一个加载 model 对象的方法,让我们将视线转移到下一步。为了将数据以一致的方式显示出来,我们需要格式化对象的元数据。

Pear Music 是一个庞大的 app,而且我们有许多不同类型的 model。每一个 model 都可以被显示在很多不同的地方。举个例子,我们想用一个艺人的名字作为 view controller 的标题,我们就想要让它简单地显示类似于 “{name}” 这样的东西。然而如果我们有一些额外的空间,比如在 UITableViewCell 中,我们就要换做用 “{name} ({instrument})” 这样的东西来显示。更进一步,如果我们有更大的空间,比如在一个很大的 UILabel 里面,我们就要使用 “{name} ({instrument}) {bio}”。

我们可以将这些格式化代码放入 view controller , cell ,label 之中。这会工作得很好,但是这样做就会将逻辑散布在应用之中从而降低了可维护性。

将字符串格式化的工作可以放在 model 对象自身中来进行,但是当这些字符串真的需要被显示的时候,就不得不去做类型假设。

我们可以在一个基类中抛出一些便利方法,并且让每个 model 的子类提供他们自己的格式化方法,但是使用面向协议编程的方法,我们就应该以更加通用角度的考虑这些。

来把这些抽象的想法变成另一个协议好了,它可以描述任意一个可以被当成字符串来表示的对象。这里将会提供许多种长度的字符串,他们可以被用在不同的 UI 场景下。

1
2
3
4
5
6
7
8
9
10
11
// Any entity which can be represented as a string of varying lengths.
protocol StringRepresentable {
var shortString: String { get }
var mediumString: String { get }
var longString: String { get }
}

// Bonus: Make sure StringRepresentable adopters are printed descriptively to the console.
extension NSObjectProtocol where Self: StringRepresentable {
var description: String { return self.longString }
}

足够简单。这里有几个我们将要构建成符合 StringRepresentable 协议的 model 对象。

1
2
3
4
5
6
7
8
9
10
11
class Artist : NSObject, StringRepresentable {
var name: String!
var instrument: String!
var bio: String!
}

class Album : NSObject, StringRepresentable {
var title: String!
var artist: Artist!
var tracks: Int!
}

与我们组织 RemoteResource 的实现相似,我们将会把格式化的逻辑放进单独的 StringRepresentable.swift 文件中:

1
2
3
4
5
6
7
8
9
10
11
extension StringRepresentable where Self: Artist {
var shortString: String { return self.name }
var mediumString: String { return String(format: "%@ (%@)", self.name, self.instrument) }
var longString: String { return String(format: "%@ (%@), %@", self.name, self.instrument, self.bio) }
}

extension StringRepresentable where Self: Album {
var shortString: String { return self.title }
var mediumString: String { return String(format: "%@ (%d Tracks)", self.title, self.tracks) }
var longString: String { return String(format: "%@, an Album by %@ (%d Tracks)", self.title, self.artist.name, self.tracks) }
}

现在我们已经处理好了格式化问题。还需要以一种方式来选择哪个字符串会在我们的 UI 场景下用到。我们坚持一贯的做法,来为在屏幕上显示的所有 StringRepresentable 定义行为,给他们一个 containerSizecontainerFont 用于计算。

1
2
3
4
5
protocol StringDisplay {
var containerSize: CGSize { get }
var containerFont: UIFont { get }
func assignString(str: String)
}

我建议只在协议中声明那些将会被接受者实现的方法。而将那些真正包含了功能的方法写在协议扩展中。displayStringValue: 决定会用到哪个字符串,并且将它传到有具体类型的 assignString: 方法中。

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
extension StringDisplay {
func displayStringValue(obj: StringRepresentable) {
// Determine the longest string which can fit within the containerSize, then assign it.
if self.stringWithin(obj.longString) {
self.assignString(obj.longString)
} else if self.stringWithin(obj.mediumString) {
self.assignString(obj.mediumString)
} else {
self.assignString(obj.shortString)
}
}

#pragma mark - Helper Methods

func sizeWithString(str: String) -> CGSize {
return (str as NSString).boundingRectWithSize(CGSizeMake(self.containerSize.width, .max),
options: .UsesLineFragmentOrigin,
attributes: [NSFontAttributeName: self.containerFont],
context: nil).size
}

private func stringWithin(str: String) -> Bool {
return self.sizeWithString(str).height <= self.containerSize.height
}
}

现在我们有了几个 StringRepresentable model 对象,当接受协议的时候,它们将会自动选择正确的字符串来使用。那么,如何才能正确的将其插入 UIKit ?

我们从 UILabel 开始,最简单的例子。传统的选择会是子类化 UILabel 并且接受协议,然后使用自定义的 UILabel 实例自动接受 StringDisplay

1
2
3
4
5
6
7
extension UILabel : StringDisplay {
var containerSize: CGSize { return self.frame.size }
var containerFont: UIFont { return self.font }
func assignString(str: String) {
self.text = str
}
}

那确实是他全部的代码了,我们可以让其他 UIKit 类也这样做,只是返回 StringDisplay 需要的数据来施展这个魔法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
extension UITableViewCell : StringDisplay {
var containerSize: CGSize { return self.textLabel!.frame.size }
var containerFont: UIFont { return self.textLabel!.font }
func assignString(str: String) {
self.textLabel!.text = str
}
}

extension UIButton : StringDisplay {
var containerSize: CGSize { return self.frame.size}
var containerFont: UIFont { return self.titleLabel!.font }
func assignString(str: String) {
self.setTitle(str, forState: .Normal)
}
}

extension UIViewController : StringDisplay {
var containerSize: CGSize { return self.navigationController!.navigationBar.frame.size }
var containerFont: UIFont { return UIFont(name: "HelveticaNeue-Medium", size: 34.0)! } // default UINavigationBar title font
func assignString(str: String) {
self.title = str
}
}

那么,在实践中这个看起来是什么样子的呢?我们声明一个 Artist 对象,他遵守了 StringRepresentable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let a = Artist()
a.name = "Bob Marley"
a.instrument = "Guitar / Vocals"
a.bio = "Every little thing's gonna be alright."
Since all UIButton instances have been extended to adopt StringDisplay, we can call the displayStringValue: method on them.

let smallButton = UIButton(frame: CGRectMake(0.0, 0.0, 120.0, 40.0))
smallButton.displayStringValue(a)

print(smallButton.titleLabel!.text) // 'Bob Marley'

let mediumButton = UIButton(frame: CGRectMake(0.0, 0.0, 300.0, 40.0))
mediumButton.displayStringValue(a)

print(mediumButton.titleLabel!.text) // 'Bob Marley (Guitar / Vocals)'

按钮的标题现在显示出适合 frame 的字符串。

用户点击一个唱片,然后 push 一个 AlbumDetailsViewController。我们的协议能够判断导航栏标题的格式。因为我们的 StringDisplay 协议扩展,UINavigationBar 在 iPad 上面将会显示一个更长一点的字符串,而 iPhone上面会显示一个更短的字符串。

1
2
3
4
5
6
7
8
9
10
class AlbumDetailsViewController : UIViewController {
var album: Album!

override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)

// Display the right string based on the nav bar width.
self.displayStringValue(self.album)
}
}

我们保证所有对 model 中字符串格式化的操作都已经在统一的地方完成了,而且它们的显示也灵活地根据所使用的 UI 元素进行了相应的调整。这种模式在日后创建的 model 和各种各样的 UI 元素中都可以被复用。由于我们构建的协议具有灵活性,这种实现方式甚至可以被直接利用到无 UI 的环境中去。

样式的协议(V)

我们已经了解了协议扩展对于模型和字符串格式化所起的作用,现在我们看一下纯前端的例子,这将说明协议扩展是如何让 UI 开发变得更强大的。

我们可以把协议当作 CSS 的 classes 来看待,并使用它来定义 UIKit 对象的样式。然后,通过接受一个样式协议使对象的可视化的外观自动改变。

首先,我们定义一个基协议,这个协议表示一个样式化的实体,声明一个最终被用来使格式生效的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Any entity which supports protocol-based styling.
protocol Styled {
func updateStyles()
}
Then we'll make some sub-protocols which define different types of styles we'd like to use.

protocol BackgroundColor : Styled {
var color: UIColor { get }
}

protocol FontWeight : Styled {
var size: CGFloat { get }
var bold: Bool { get }
}

我们创建了这些继承的样式,所以我们的接受者就不需要显式地这么做了。

现在我们要把特殊的样式分支出来,并且使用协议扩展返回真正的值。

1
2
3
4
5
6
7
8
9
10
protocol BackgroundColor_Purple : BackgroundColor {}
extension BackgroundColor_Purple {
var color: UIColor { return UIColor.purpleColor() }
}

protocol FontWeight_H1 : FontWeight {}
extension FontWeight_H1 {
var size: CGFloat { return 24.0 }
var bold: Bool { return true }
}

这样一来,剩下的所有事情就是基于 UIKit 元素的类型实现 updateStyles 方法我们使用非泛型的扩展来让全部的 UITableViewCell 实例遵守 Styled 协议。

1
2
3
4
5
6
7
8
9
10
11
12
extension UITableViewCell : Styled {
func updateStyles() {
if let s = self as? BackgroundColor {
self.backgroundColor = s.color
self.textLabel?.textColor = .whiteColor()
}

if let s = self as? FontWeight {
self.textLabel?.font = (s.bold) ? UIFont.boldSystemFontOfSize(s.size) : UIFont.systemFontOfSize(s.size)
}
}
}

为了确认 updateStyles 被自动的调用,可以在扩展中重写 awakeFromNib 方法。有兴趣的人可以了解下,这个重写本质上会被插入继承链中,好像扩展是 UITableViewCell 的直接子类。在一个 UITableViewCell 的子类中调用 super 现在等于直接调用了这个方法。

1
2
3
4
5
public override func awakeFromNib() {
super.awakeFromNib()
self.updateStyles()
}
}

现在当我们创建 cell 的时候,只要接受我们想要的协议就可以了!

1
class PurpleHeaderCell : UITableViewCell, BackgroundColor_Purple, FontWeight_H1 {}

我已经在我们的 UIKit 元素上创建了一个类 CSS 的样式声明。我们甚至可以为 UIKit 山寨一个类似于 Bootstrap 的东西。这种实现在很多方面都有较大的提升,而且对于那些拥有很多界面元素并且样式是高度动态化的应用来说是很有价值的。

想象一个应用程序,有20个不同的视图控制器,每一个遵守2到3个共同的视觉风格。我们只需要使用样式协议,而不是强迫自己继承一个基类,或者使用不断增多的全局方法来配置我们的样式,采用的样式协议很有必要,这让我们更专注于对更重要细节的实现。

有什么收获?

到现在为止,我们已经做了很多有趣的事情,那么我们从使用协议和协议扩展的经验中收获了什么呢?可能会有人说,我们没必要去创建这协议。

面向协议的编程不能完美的适配所有基于 UI 的开发场景。

当我们向应用程序添加共享,通用的功能的时候,协议和协议的扩展通常是有价值的。此外,其价值在程序的组织结构上的体现胜过于功能上的体现。

数据类型越多,协议可能就会更加有效。无论何时,只要 UI 需要显示众多格式的信息,协议都可以带来很大的改善。但是这并不意味着我们仅仅为了实现一个现实艺人名字的紫色 cell 就要去使用6个协议和一堆扩展。

我们借助 Pear Music 的情况来看一下面向协议的实现是否变得更加重要。

增加复杂度

我们已经在 Pear Music 上工作了一段时间,而且我们的唱片,艺人,歌曲和播放列表有着很棒的 UI。我们仍然用着美妙的协议和扩展,让它们为 MVC 的所有层面上都提供着便捷。现在 Pear 的 CEO 已经要求我们着手构建2.0版本了…我们面临一个新奇的竞争对手的挑战,它叫 Apple Music。

我们需要一个非常酷的新特性来定义我们的产品,经过广泛的研究我们做了决定,这个特性就是长按预览功能。这很大胆,也很有革命性,Jony Ive 貌似已经在镜头前谈论这个。现在我们基于 UIKit 使用面向协议编程来实现它。

构建模态页面

以下讲述了它是如何运作的 —— 我们的用户长按一个艺人,唱片,歌曲或者是播放列表的时候一个有动画效果的模态视图会显示在屏幕上,从网络中载入图片并且显示这个项目的描述,另外还有一个分享到 Facebook 的按钮。

我们来构建这个将会在用户长按时模态显示的 UIViewController。只需要一些遵守了 StringRepresentableMediaResource 协议的东西的帮助,我们在一开始就可以通过初始化方法实现通用化,

1
2
3
4
5
6
7
8
9
10
11
12
13
class PreviewController: UIViewController {
@IBOutlet weak var descriptionLabel: UILabel!
@IBOutlet weak var imageView: UIImageView!

// The main model object which we're displaying
var modelObject: protocol!

init(previewObject: protocol) {
self.modelObject = previewObject

super.init(nibName: "PreviewController", bundle: NSBundle.mainBundle())
}
}

接下来,使用内建的协议扩展方法来把数据传给 descriptionLabelimageView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override func viewDidLoad() {
super.viewDidLoad()

// Apply string representations to our label. Will use the string which fits into our descLabel.
self.descriptionLabel.displayStringValue(self.modelObject)

// Load MediaResource image from the network if needed
if self.modelObject.imageValue == nil {
self.modelObject.loadMedia { () -> () in
self.imageView.image = self.modelObject.imageValue
}
} else {
self.imageView.image = self.modelObject.imageValue
}
}

最后,我们用相同的方法来获取可以通过 Facebook 分享的元数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Called when tapping the Facebook share button.
@IBAction func tapShareButton(sender: UIButton) {
if SLComposeViewController.isAvailableForServiceType(SLServiceTypeFacebook) {
let vc = SLComposeViewController(forServiceType: SLServiceTypeFacebook)

// Use StringRepresentable.shortString in the title
let post = String(format: "Check out %@ on Pear Music 2.0!", self.modelObject.shortString)
vc.setInitialText(post)

// Use the MediaResource url to link to
let url = String(self.modelObject.mediaURL)
vc.addURL(NSURL(string: url))

// Add the entity's image
vc.addImage(self.modelObject.imageValue!);

self.presentViewController(vc, animated: true, completion: nil)
}
}
}

使用协议是我们受益匪浅 —— 没有他们的话,我们也许已经在 PreviewController 中为每一种需要接收的对象都做了初始化方法。使用基于协议的方法是我们极佳地保持了视图控制器的简洁,并且可以灵活的应对未来的实现。

我们已经造出了一个轻便的,简洁的 PreviewController,我们可以把艺人,唱片,歌曲,播放列表或者其他任何基于协议模式构建的模型传进去。 PreviewController 里面却不会有一行关于具体模型的代码!

与第三方的代码整合

在构建 PreviewController 时,这是最后一个我们可以很好地应用协议和协议扩展的场景。我们整合了一个新的框架,这个框架可以为我们 app 中的音乐人加载对应的 Twitter 信息。我们想在主页显示推文列表,我们已经拿到了一个推文的 model 对象。

1
2
3
4
5
6
7
class TweetObject {
var favorite_count: Int!
var retweet_count: Int!
var text: String!
var user_name: String!
var profile_image_id: String!
}

我们并不拥有这个代码,我们也无法修改 TweetObject,但是我们仍然希望我们的用户可以使用长按手势来预览推文,并且使用和 PreviewController 相同的UI。我们需要做的所有事情就是扩展它,让他接受我们现有的协议。

1
2
3
4
5
6
7
8
9
10
extension TweetObject : StringRepresentable, MediaResource {
// MARK: - MediaResource
var mediaHost: String { return "api.twitter.com" }
var mediaPath: String { return String(format: "/images/%@", self.profile_image_id) }

// MARK: - StringRepresentable
var shortString: String { return self.user_name }
var mediumString: String { return String(format: "%@ (%d Retweets)", self.user_name, self.retweet_count) }
var longString: String { return String(format: "%@ Wrote: %@", self.user_name, self.text) }
}

现在我们可以将一个 TweetObject 对象传给 PreviewController 了,甚至不需要它知道我们使用的是一个外部的框架!

1
2
let tweet = TweetObject()
let vc = PreviewController(previewObject: tweet)

收获

在 WWDC 2015 上,苹果建议在以往需要用到类的地方去使用协议,但是我质疑这个规则忽视了协议扩展与那些重类的(class-heavy)的框架,比如 UIKit 一同使用时展现出的细节上的局限性。协议扩展只能在
它们本身具有广泛的使用性并且不需要支持传统代码的时候才会体现出真正的价值。尽管一些我提到的例子乍一听起来是微不足道的,但是当你的应用体积变的庞大而且逻辑变得复杂的时候,这种灵活的设计会变得极为有用。

这是个可以解释的代码的成本效益(cost-benefit)问题。在一个主要基于 UI 的应用中协议和扩展并不是总能占有一席之地。如果你只有一个页面并且页面上只要显示一种信息,这并不会带来什么改变,不要对它在协议的层面进行过度思考。但是如果你的应用对于同样的核心数据在不同的视觉状态,样式和表示上变换多样,使用协议和协议扩展作为你的数据和它的表现形式之间的桥梁是一个明智的方法,这么作将会在未来受益。

最后,我想把协议扩展称为一种通用的改变游戏规则的特性不如将其称为是一个在高精确度开发场景中的一种具有建设性的工具。还是那句话,我觉得任何开发者对面向协议的技术的尝试都是值得的 —— 如果你不在协议的层面重新调整现有代码,你将永远不会知道这么做的益处。广泛的使用它们吧。

如果你有任何疑问,或者你想和我探讨一些细节,给我发个 email 或者 在 Twitter 上直接找我!