使用 UIKit 進行面向協議的編程
來自: http://chengway.in/shi-yong-uikit-jin-xing-mian-xiang-xie-yi-de-bian-cheng/
Swift 中令人耳目一新的『面向協議編程』在 2015 年 WWDC 上一經推出,街頭巷尾都在熱情洋溢地討論著 協議擴展 (protocol extensions)---這一激動人心的語言新特性,既然是新特性,第一次接觸總要走點彎路。英文 原文
我已經閱讀過無數篇關于 Swift 協議和協議擴展來龍去脈的文章,這些文章無疑都表達了同一個觀點:在 Swift 新版圖中 協議擴展 擁有絕對主力位置。蘋果官方甚至推薦默認使用協議(protocol)來替換類(class),而實現這種方式的關鍵正是面向協議編程。
但是我讀過的這些文章只是把『什么是協議擴展』講清楚了,并沒有揭開『面向協議編程』真正的面紗。尤其是針對日常 UI 的開發,大部分實例代碼并沒有切合實際的使用場景,也沒有利用任何框架。
我想要明確的是: 協議擴展 如何影響現有構建的工程,并且利用這一新特性更好地與 UIKit 協同工作。
現在我們已經擁有了協議擴展,那么在以類為主的 UIKit 中改用基于協議的實現方式是否更有價值。這篇文章我嘗試將 Swift 的協議擴展與真實世界的 UI 完美結合,但隨著我們進一步探索,就會發現二者的匹配度并不如我們所期望的那樣。
協議的優勢
協議并不是什么新技術,但我們可以使用內置的函數擴展他們,共享內部邏輯,很神奇不是嗎?真是個美妙的想法,協議越多代表靈活性越好。一個協議擴展代表可被部署的單一功能模塊,并且該模塊可以被重載(或不可以)和通過 where 子句與特定類型的代碼交互。
協議 Protocols 存在的目的讓編譯器滿意就好,但協議擴展 extensions 是一段代碼片段,可在整個代碼庫里共享的有形資產
雖然只可能從一個父類繼承,但只要我們需要,可以盡可能多地部署協議擴展。部署一個協議就像是添加一個指令到 Angular.js 里的元素中,我們通過向某些對象注射邏輯從而改變這些對象的行為。協議不再僅僅是一份合同,通過擴展成為了一種可被部署的功能。
如何使用擴展協議
協議擴展的用法非常簡單,這篇文章不會教你用法,而是引領你們手握 協議擴展 這一利器在 UKIit 開發領域做一些有價值的嘗試。如果你需要火速熟悉基本用法,請參考蘋果的官方文檔 Official Swift Documentation on Procotol Extensions
協議擴展的局限
在我們開始前,先讓我們澄清下 協議擴展 不是什么,有很多事情 協議擴展 是做不了的,這種限制取決于語言自身設計。不過我還是很期待蘋果在未來的 Swift 版本更新中解除一些限制。
- 不能在協議擴展里調用來自 Objective-C 的成員
- 不能使用 where 字句限定 struct 類型
- 不能定義多個以逗號分隔的 where 從句,類似于 if let 語句
- 不能在協議擴展內部存儲動態變量
- 該規則同樣適用于非泛型擴展
- 靜態變量應該是允許的,但截至 Xcode 7.0 還會打印 "靜態存儲屬性不支持泛型類型" 的錯誤。
與非泛型擴展不同,不能調用 super 來執行一個協議擴展@ketzusaka 指出可以通過 (self as MyProtocol).method() 來調用- 因為這個原因,協議擴展沒有真正意義上的繼承概念
- 不能在多個協議擴展中部署重名的成員方法
- Swift 的運行時只會選擇最后部署的協議,而忽略其他的
- 舉個例子,如果你有兩個協議擴展都實現了相同的方法,那么只有后部署的協議方法的會被實際調用,不能從其他擴展里執行該方法
- 不能擴展可選的協議方法
- 可選協議要求 @objc 標簽,不能和協議擴展一起使用
- 不能在同一時刻聲明一個協議和他的擴展
- 如果你真的想要聲明實現放在一起,那就使用 extension protocol SomeProtocol {} 吧,因為聲明實現都在同一位置,只提供協議實現就好,聲明可以省略。
Part 1: 擴展現有UIKit協議
當我第一次學習協議擴展時,首先想到的就是 UITableViewDataSource 這個廣為人知的數據源協議。我琢磨著如果能向所有部署了 UITableViewDataSource 協議的對象都提供默認的實現,豈不是很酷?
如果每個 UITableView 都有一組 sections,那么為什么不擴充 UITableViewDataSource ,然后在同一個位置實現 numberOfSectionsInTableView: 方法?如果在所有的 tables 上都需要滑動刪除的功能,為什么不在協議擴展里實現 UITableViewDelegate 的相關方法?
但就目前來說,這都是不可能的
我們不能做什么:為 Objective-C 協議提供一個默認的實現
UIKit 依舊采用 Objective-C 編譯,況且 Objective-C 沒有協議擴展的概念。這意味著在真實項目中盡管我們有能力在 UIKit 協議里聲明擴展,但是 UIKit 對象并不能看到我們擴展里的方法。
舉個例子,如果我們擴充了 UICollectionViewDelegate 來實現 collectionView:didSelectItemAtIndexPath: 。但是當你點擊 cell 并不會觸發該協議方法,這是因為在 Objective-C 上下文環境中 UICollectionView 自己是看不到我們實現的協議方法。如果我們將一個必須實現的 delegate 方法( collectionView:cellForItemAtIndexPath: )放到協議擴展中,編譯器會向我們抱怨:『聲明實現協議的對象』沒有遵守 UICollectionViewDelegate 協議(因為看不到)
Xcode 嘗試在我們的協議擴展方法前添加 @objc 來解決這一問題,只能說想象總是美好的,現實卻很殘酷。又冒出一個新錯誤:『協議擴展中的方法不能應用于 Objective-C』,這才是根本問題所在--協議擴展只適用于 Swift 2.0 以上的版本
我們能做什么添加一個新方法到現有的 Objective-C 協議中
我們能夠在 Swift 中直接調用 UIKit 協議擴展里的方法,即使 UIKit 看不見他們。這就意味著盡管我們不能覆蓋 override UIKit 已有的協議方法,但是我們能為現有的協議添加新的便利方法。
我承認,不那么令人興奮,任何屬于 Objective-C 的框架代碼都不能調用這些方法。但別灰心,我們還有機會。下面一些例子嘗試將協議擴展和 UIKit 里存在的協議結合起來。
UIKit協議擴展示例
擴展 UICoordinateSpace
有時候需要在 Core Graphics 和 UIKit 的坐標系之間進行轉換,我們可以添加一個 helper 方法到協議 UICoordinateSpace 中,UIView 也遵守該協議
extension UICoordinateSpace { func invertedRect(rect: CGRect) -> CGRect { var transform = CGAffineTransformMakeScale(1, -1) transform = CGAffineTransformTranslate(transform, 0, -self.bounds.size.height) return CGRectApplyAffineTransform(rect, transform) } }
現在我們的 invertedRect 方法可以應用在任何遵守 UICoordinateSpace 協議的對象上,我們在繪圖代碼中使用他:
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 } }
UIView 遵守 UICoordinateSpace 協議
擴展 UITableViewDataSource
盡管我們不能提供關于 UITableViewDataSource 默認的實現方法,但我們依舊可以將全局邏輯放進協議中方便遵守 UITableViewDataSource 的對象使用。
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: 方法可以快速統計 table view 中有多少條目(item),特別是 cell 分散在各個 sections 之中,而又想快速得到一個總條目數時尤其有用。調用該方法的一個絕佳位置就在 tableView:titleForFooterInSection: 里:
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
或許你已拜讀過我在 iOS 7 出來時寫的關于自定義導航欄的 文章 ,也嘗試開始自定義導航欄過渡。這里有一組之前文章使用的方法,讓我們統統放進 UIViewControllerContextTransitioning 協議里。
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 對象( UIViewControllerContextTransitioning )中執行這些方法,該對象一般作為參數傳遞給我們的 animation coordinator ( UIViewControllerAnimatedTransitioning ):
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 } }
比方說我們的應用程序有多個 UIPageControl 實例,然后我們復制粘貼一些代碼在 UIScrollViewDelegate 的實現里讓其工作。通過協議擴展我們可以構建全局一種邏輯,調用時仍然使用 self
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)))); } }
此外,如果我們知道 Self 就是 UICollectionViewController ,那么可以去掉 參數 scrollView
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 設計模式部署自定義的協議擴展。
Part 2: 擴展自定義協議
MVC 中使用面向協議編程
一個 iOS 應用程序從其核心來看執行三個基本功能,通常描述為 MVC(模型-視圖-控制器)模型。所有的 App 所做的不過是對數據進行一些操作并將其顯示在屏幕上。
下面三個例子中,我將會向你們安利 面向協議編程 的設計模式思想,并嘗試使用 協議擴展 依次改造 MVC 模式下的三個組件 Model -> Controller -> View。
Model 管理中的協議(M)
假設我們要做一個音樂 App,叫做鴨梨音樂。也就是有一堆關于藝術家、專輯、歌曲和播放列表的 model 對象,接下來我們要構建一些 基于的標識符代碼 來從網絡下載這些 models(標識符已經預先載入)
實踐協議最好的方式是從高等級的抽象開始。最原始的想法是我們有一個資源需要通過遠端服務器 API 獲取,來吧少年!開始創建一個協議
// Any entity which represents data which can be loaded from a remote source. protocol RemoteResource {}
但是別急,這還只是一個空協議! RemoteResource 并不是用來直接部署的,他不是一份合同契約,而是一組用來執行網絡請求的功能集合。因此 RemoteResource 真正的價值在于他的協議擴展。
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 子協議。
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 } }
讓我們實現這些協議
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) } } } }
我們提供了一個默認主機值,一個生成完整 URL 的請求方法,以及一個從 RemoteResource 載入加載資源的 load: 方法。我們稍后會依賴以上實現來提供正確的解析方法 jsonPath
MediaResource 的實現遵循類似模式:
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)即可。
美中不足的是,我們的協議并不是相互排斥的,我們希望有一個對象能同時滿足 JSONResource 和 MediaResource 。記住協議擴展是彼此相互覆蓋的,除非我們采用不同的屬性或方法,不然每次都是最后部署的協議才會被調用
讓我們來專門研究下數據訪問方法
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 有能力訪問圖像數據,那么應用該協議的對象也能很輕松地提供一個 imageValue ,而不用考慮特定類型或上下文環境。
前面提到我們將會基于已知的標識符加載 models,所以讓我們為『具有唯一標識的實體』創建一個協議
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 } }
由于不能在擴展 extension 中創建存儲屬性,我們還是需要依賴遵守 Unique 協議的對象來聲明 id 屬性。加之,你或許注意到了我僅在 Self: NSObject 時擴展了 Unique ,否則,我們不能調用 self.init() ,這是因為沒有他的聲明。一個變通的解決方案就是在該協議中聲明一個 init() ,但是需要遵守協議的對象來顯式實現他, 因為我們所有的 models 都是基于 NSObject 的,所幸這并不成問題。
好了,我們已經得到了一個從網絡獲取資源的基本方案,讓我們開始創建遵守這些協議的 models。下面是我們的 Song 模型的樣子:
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 邏輯之前的代碼外,我們將下面的代碼放進 RemoteResource.swift 文件,
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 相關的代碼整合在同一個位置好處多多。首先在同一個地方完成協議實現,擴展的作用域很清晰。當聲明一個將要擴展的協議時,我建議將擴展代碼和聲明的協議放在同一文件中
下面是加載歌曲 Song 的實現,多虧了 JSONResource 和 Unique 協議擴展
let s = Song(id: "abcd12345") let artistLabel = UILabel() s.loadJSON { (success) -> () in artistLabel.text = s.artist }
我們的歌曲 Song 對象是一些元數據的簡單封裝,他本該如此,所有的苦差事都應交給協議擴展去做。
下面例子中的 Playlist 對象同時遵守了 JSONResource 和 MediaResource 協議
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 之前,先回退一步,我們注意到我們的媒體 API 只需要遠端的標識,并沒有指定協議應用者的類型,這就意味只要我們知道標識符,我們就能構建 mediaPath 。讓我們使用一個 where 從句來限定 MediaResource 聰明到只在 Unique 下工作
extension MediaResource where Self: Unique { var mediaPath: String { return String(format: "/images/%@", self.id) } }
因為 Playlist 已經遵循了 Unique ,因此我們不需要再做字面上的處理,就可以和 MediaResource 一起愉快地工作!同樣的邏輯反過來也成立(遵循了 MediaResource ,也必然適配于 Unique 協議),即只要對象的標識對應媒體 API 中的一張圖片,就能正常工作。(創建 mediaPath )
下面演示如何載入 Playlist 圖像
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 }
我們現在擁有一種通用方式來定義遠程資源,能夠被程序中的任意實體使用,而不僅僅局限于這些模型對象。我們能夠很方便地擴展 RemoteResource 來處理不同類型的 REST 操作,并針對更多的數據類型添加額外的子協議。
數據格式化的協議
現在我們已經構造了一種加載模型對象的方式,繼續深入到下一個階段吧。我們需要格式化來自對象的元數據,并以一致的方式顯示在用戶面前。
鴨梨音樂是一個大工程,擁有相當數量不同類型的模型,每一個模型都可能在不同位置顯示。比如,如果我們有一個以 Artist 為標題的 view controller,我們會只顯示藝術家名字 {name}。但是,如果我們擁有額外的空間,比如一個存在 UITableViewCell ,我們就會使用 "{name} ({instrument})"。再進一步,如果在 UILabel 里有更大空間,則會使用 "{name} ({instrument}) {bio}"。
雖然將這些格式化代碼放到 view controllers, cells 和 labels 中也可以正常工作,但是如果我們能將這些分散的邏輯提取出來供整個 app 使用,會提高整個應用的可維護性。
我們可以將字符串格式化代碼就放在模型對象中,但當我們真要顯示字符串時,需要確定 model 的類型。
我們可以在基類中定義一些便利方法,然后每個子類模型都提供自己的格式化方法,但是在面向協議編程中,我們應該思考更加通用的方式。
讓我們將這種想法抽象成另一個協議,指定一些可以表現為字符串的實體。然后將會針對各種 UI 方案,提供不同長度的字符串
// 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 :
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 文件。
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 協議的對象顯示在屏幕上,在該協議提供了 containerSize 和 containerFont 用來計算。
protocol StringDisplay { var containerSize: CGSize { get } var containerFont: UIFont { get } func assignString(str: String) }
我推薦在協議中只聲明方法,而具體實現放到遵循協議的對象中。在協議擴展中,我們將添加真正的實現代碼。 displayStringValue: 方法會決定哪個字符串會被使用,然后傳遞給指定類型的 assignString: 方法
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 協議的模型對象,還擁有可以自動選擇字符串的協議。此協議一旦成功部署,會自動幫助我們選擇正確的字符串,那么接下來該如何整合進 UIKit 中呢?
先拿最簡單的 UILabel 開刀吧。傳統的方式是創建 UILabel 的子類,然后部署該協議,接下來在需要使用 StringRepresentable 的地方使用這個自定義的 UILabel 。但更好的選擇是使用一個指定類型(UILable 類)的擴展讓所有的 UILabel 實例自動部署 StringDisplay 協議:
這種方式就不需要創建 UILable 的子類了
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 協議就能正常工作了,是不是很神奇呢?
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 協議。
let a = Artist() a.name = "Bob Marley" a.instrument = "Guitar / Vocals" a.bio = "Every little thing's gonna be alright."
因為 UIButton 的所有實例都通過擴展的方式部署了 StringDisplay 協議,媽媽再也不用擔心我們直接調用他們的 displayStringValue: 方法了
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 大小靈活顯示標題了。
當用戶點擊一個 Album 唱片,我們為其壓棧(push)一個 AlbumDetailsViewController 。此刻我們的協議能夠依照協定找到一個合適字符串作為導航欄標題。這是因為在 StringDisplay 協議擴展中的定義, UINavigationBar 會在 iPad 上顯示長的標題,而在 iPhone 上顯示短標題。
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) } }
我們可以將模型 models 中有關字符串格式化的代碼全部集中轉移到一個協議擴展里面,之后再根據具體的 UI 元素靈活顯示。這種模式可以在將來的模型對象上重復使用,應用在各種 UI 元素上。此外這種協議具備良好的擴展性,還可以推廣到更多非 UI 的場景。
在樣式中使用協議 (V)
我們已經完成了用協議擴展對模型、格式化字符串的改造,現在讓我們來看一個純粹的前端示例,學習下協議擴展如何增強我們的UI開發
我們可以將協議看做類似于 CSS 的東西,并且使用他們來定義我們 UIKit 對象的樣式。通過部署這些樣式協議,來自動更新顯示外觀。
首先,我們將定義一個基礎協議,用來表示一個應用樣式的實體;聲明一個方法,用于最終的應用樣式。
// Any entity which supports protocol-based styling. protocol Styled { func updateStyles() }
接著我們將會創建一些子協議,這些協議會定義各種類型的樣式。
protocol BackgroundColor : Styled { var color: UIColor { get } } protocol FontWeight : Styled { var size: CGFloat { get } var bold: Bool { get } }
我們讓這些子協議繼承自 Styled ,這樣遵守這些子協議的對象就不用再顯式調用了。
現在我們可以將具體的樣式分類,并使用協議擴展返回需要的值。
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 協議
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 ,之后就可以直接調用 updateStyles 了。
public override func awakeFromNib() { super.awakeFromNib() self.updateStyles() } }
現在我們創建了自己的 cell,接下來就可以部署我們需要的樣式了
class PurpleHeaderCell : UITableViewCell, BackgroundColor_Purple, FontWeight_H1 {}
我們已經在 UIKit 元素上創建了類似于 CSS 樣式風格的聲明。使用協議擴展,我們甚至可以為 UIKit 山寨一個 Bootstrap 樣式。這種方式可以在很多場景下都能增強我們的開發體驗,特別是在應用開發中,當擁有數量繁多的視覺元素,且樣式高度動態時尤其有用。
想象一下,一個 App 擁有 20 個以上不同的 view controllers,每個都遵守 2~3 個通用的視覺樣式,比起強迫我們創建一個基類或使用一組數量持續增長的全局方法來定義樣式,現在僅需要遵守一些樣式協議,然后順手實現就好。
我們得到了什么?
我們目前為止做了很多有趣的事情,那么通過使用協議和協議擴展我們最終得到了什么?可能有人覺得我們跟本沒必要創建這么多協議。
面向協議編程并不完美匹配所有基于 UI 的場景。
當我們需要在應用中添加共享代碼和通用的功能時,協議和協議擴展將變得非常有價值。并且代碼的組織結構也更加清晰有條理。
隨著數據類型的增多,協議就越能發揮其用武之地。特別是當 UI 需要顯示多種格式的信息時,使用協議會讓我們身輕如燕。但是這并不意味著我們需要添加六個協議和一大堆擴展,只是為了讓一個紫色的單元格顯示一個藝術家的名字。
讓我們擴充鴨梨音樂場景,來見識一下『面向協議編程』真正的價值所在。
添加復雜度
我們已經在 Pear Music 上下了很大功夫,現在擁有界面美觀的專輯列表、藝術家、歌曲和播放列表,我們還使用了美妙的協議和協議擴展來優化 MVC 的原有結構。現在鴨梨公司 CEO 要求我們構建鴨梨音樂 2.0 的版本,希望可以和 Apple Music 一爭高下。
我們需要一項酷炫的新特性來脫穎而出,經過頭腦風暴后,我們決定添加:『長按預覽』這個新特性。聽上去是個大膽的創意,我們的 Jony Ive(黑的漂亮)似乎已經在攝像機前娓娓而談了。讓我們使用面向協議編程配合 UIKit 來完成任務。
創建 Modal Page
下面來闡述下新特性的工作原理,當用戶 長按 藝術家、專輯、歌曲或播放列表時,一個模態視圖會以動畫的形式出現在屏幕上,展示從網絡載入的條目圖像,以及描述信息和一個 非死book 分享按鈕。
我們先來構建一個 UIViewController ,用做用戶長按手勢后的模態展示的 VC。從一開始我們就能讓初始化方法更加通用,傳入的參數僅需遵守 StringRepresentable 和 MediaResource 即可。
class PreviewController: UIViewController { @IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var imageView: UIImageView! // The main model object which we're displaying var modelObject: protocol<stringrepresentable>! init(previewObject: protocol<stringrepresentable>) { self.modelObject = previewObject super.init(nibName: "PreviewController", bundle: NSBundle.mainBundle()) } }</stringrepresentable></stringrepresentable>
下一步,我們可以使用內建的協議擴展方法分配數據給我們的 descriptionLabel 和 imageView
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 } }
最后,我們可以使用相同的方法來從 非死book 函數獲取元數據
// Called when tapping the 非死book share button. @IBAction func tapShareButton(sender: UIButton) { if SLComposeViewController.isAvailableForServiceType(SLServiceType非死book) { let vc = SLComposeViewController(forServiceType: SLServiceType非死book) // 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 中根據不同的類型,分別創建初始化方法。通過協議的方式,不僅保持了 view controller 的絕對簡潔,還保證了其在未來的可擴展性。
最后只剩一個輕量級的、清爽的 PreviewController ,可以接受一個 Artist , Album , Song , Playlist 或任意匹配了我們協議的 model 。 PreviewController 沒有一行關于特定模型的代碼。
集成第三方代碼
當我們使用協議和協議擴展構建 PreviewController 時,這里還有最后一個特別棒的應用場景。我們融入了一個新的框架,該框架在我們的 App 中可以用來載入音樂家的 推ter 信息。我們想要在主頁面顯示 tweets 列表,通常會指定一個 model 對象對應一條 tweet:
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 上來預覽這些 tweets。而我們所要做的就是擴展這些現有協議。
extension TweetObject : StringRepresentable, MediaResource { // MARK: - MediaResource var mediaHost: String { return "api.推ter.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 中,對于 PreviewController 來講,他甚至不知道我們正在工作的外部框架
let tweet = TweetObject() let vc = PreviewController(previewObject: tweet)
課程總結
在 WWDC 2015 的開發者大會上,蘋果官方推薦使用協議來替代類,但是我認為這條規則忽視了協議擴展工作在某些重型框架(UIKit)下的局限性。只有當協議擴展被廣泛使用,而且不需要考慮遺產代碼時,才能發揮他的威力。雖然最初的例子看上去較為瑣碎,但隨時間的增長,應用的尺寸和復雜度都會成倍增長,這種通用設計就會變得格外有效。
這是一個代碼解釋性的成本收益問題。在一個的 UI 占大頭的大型應用中,協議 & 擴展并不那么實用。如果你有一個單獨的頁面只展示一種類型的信息(今后也不會改變),那么就不要考慮用協議來實現了。但是如果你的應用界面在不同的視覺樣式、表現風格間游走,那么將協議和協議擴展作為連接數據和外觀之間的橋梁是極其有用的,你會在未來的開發中受益匪淺。
最后,我并不是想把協議看成一種銀彈,而是將其看做是在某些開發場景中的一把利器。盡管如此,面向協議編程都是值得開發者們學習的--只有你真正按照協議的方式,重新審視、重構之前的代碼,才能體會其中的精妙之處。
如果你有任何問題,或想了解更多的細節,請務必聯系我 email ,這是我的 推ter !