本文由CocoaChina譯者Leon(社區ID)翻譯
原文:iOS 9 Tutorial Series: Protocol-Oriented Programming with UIKit
轉載請保持所有內容和鏈接的完整性。
我閱讀了許多關於Swift中協議的文章,了解過了協議擴展(protocol extensions)的詳情。毫無疑問,協議擴展將是Swift這道菜中的一位重要調料。Apple甚至建議盡可能的使用協議(protocol)來替換類(class)--這是面向協議編程的關鍵。
我讀過許多文章,其中對協議擴展的定義講的很清晰。但都沒有說明面向協議編程真正能為UI開發帶來些什麼。當前可用的一些示例代碼並不是基於一些實際場景的,而且沒用應用任何框架。
我想要知道面向協議編程是如何影響已有的應用,以及該如何在一個最常用的iOS庫(例如UIKit)中最大化的發揮它的作用。
既然我們已經有了協議擴展,基於協議的方法是否在UIkit這個“類重地”上有更大的價值?這篇文章中我將嘗試在真實的UI使用場景中講述Swift協議擴展。通過研究的過程來說明協議擴展並不是我之前所想的樣子。
協議的好處
協議並不是什麼新事物,但使用內置功能、共享邏輯,甚至“魔法能力”來擴展協議的想法很迷人。更多的協議意味著更大的靈活性。協議擴展是模塊化功能的一部分,它可以被采用(adopted),被覆蓋(overriden),也可以通過where語句進行指定類型的訪問。
從編譯角度來說,協議本身只能迎合編譯器。但是協議擴展卻是實際的代碼塊,可以被整個代碼庫使用。
不同於從父類繼承子類,我們可以使用任意多個協議。使用擴展協議就像是在Angular.js中為一個元素添加一條指令--我們插入一段代碼邏輯來替換對象的行為。這裡,協議已經不單單是一種約定,通過擴展的方式我們可以使用實際的功能。
如何使用擴展協議
方法很簡單。本文不會介紹如何使用,而會討論在UIKit中的實際應用。如果你需要盡快了解協議是如何工作的,請參考:Official Swift Documentation on Procotol Extensions.
協議擴展的局限
開始之前,讓我們先搞清楚協議不能做什麼。許多協議不能做的事情是出於設計考慮。不過我也很希望看到Apple在未來的Swift版本中處理這些限制。
在Objective-C中不能調用擴展協議的成員。
不能對struct類型使用where語句
不能在一個if let語句中定義多個逗號分隔的where語句
不能在協議擴展中存儲動態變量
1.這條對非泛型擴展也同樣使用
2.靜態變量理論上是支持的,但是在Xcode 7.0上使用會報錯:“static stored properties not yet supported in generic types”
不能在擴展協議中調用super(這點不同於非泛型擴展) @ketzusaka
基於這個原因,沒有真正意義上的協議擴展繼承。
不能使用多個協議擴展中同名的成員。
1.Swift運行時環境會選擇最後一個協議中的成員並且忽略其他的。
2.例如:如果我們使用兩個擴展協議,其中實現了兩個同名方法,當調用該方法時,只有最後一個協議中的方法會被調用。其他擴展中的方法調用不到。
不能擴展可選(optional)的協議方法。
1.可選協議方法需要@objc的標記,這樣就無法同時使用協議擴展。
無法同時聲明協議和它的擴展。
1.最好聲明extension protocol SomeProtocol {},這樣就同時聲明了協議並且實現了擴展。
Part 1:擴展現有UIKit協議
剛開始研究協議擴展時,第一個想到的是UITableViewDataSource,它或許是iOS平台上使用最廣的協議。如果可以為UITableViewDataSource協議添加一個默認的實現,這不是很有意思嗎?
如果應用中每個UITableView都有固定的若干個section,為什麼不擴展UITableViewDataSource並且在其中實現numberOfSectionsInTableView: 方法?如果所有的table都有滑動刪除的功能,擴展UITableViewDelegate協議並實現相應方法就完美多了。
潑盆冷水吧,這些都是不可能的。
不可能任務:
為Objective-C協議提供默認實現。
UIKit仍然使用Objective-C編譯,而Objective-C中並沒有協議擴展的概念。在實際使用中,這意味著即使我們可以聲明UIKit協議的擴展,對於UIKit對象來說,擴展協議中的方法仍然是不可見的。
例如:如果我們擴展UICollectionViewDelegate 並實現collectionView:didSelectItemAtIndexPath:方法。在我們點擊cell的時候,這個方法並不會被調用。因為UICollectionView在Objective-C上下文中查找不到這個擴展方法。如果我們把如collectionView:cellForItemAtIndexPath:此類必要(required)方法放在協議擴展中,編譯器還是會提示使用該協議的類沒有遵循UICollectionViewDelegate協議。
Xcode嘗試通過添加@objc標簽來解決這個問題,但是這是徒勞的,會有一個新的錯誤:"協議擴展中的方法不能用Objective-C實現"。這是個隱藏錯誤:協議擴展只能在Swift 2以上代碼中使用。
我們能做的:
為現有的Objective-C協議添加新的方法
我們可以通過Swift直接調用UIKit協議的擴展方法,即使對於UIKit來說它們是不可見的。這意味著我們不能覆蓋已有的協議方法,但是可以為協議添加新的方法。
這並沒有什麼驚喜之處,因為Objective-C代碼依然不能訪問這些方法。但還是帶來了一些機會。以下是一些組合使用協議擴展和現有UIKit協議的可能方式。
UIKit協議擴展示例:
擴展UICoordinateSpace
你以前一定嘗試過UIKit和Core Graphics坐標之間的相互轉換(左上坐標系->左上坐標系)。我們可以為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 } }
擴展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中所有條目的數量。如果有個label顯示條目數量,而我們的數據都分散在各個section中的時候,這個方法格外有用。比如在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寫的文章 Custom Navigation Transitions & More,並使用其中的方法自定義navigation的過渡。以下就有一組我使用過的方法,通過擴展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 } }
我們可以在傳遞到animation coordinator的transitionContext對象調用這些方法
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調用。
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)))); } }
另外,如果我們在使用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程序內部通常包含3個重要部分。通常被描述為MVC(Model-View-Controller)模式。在App中使用這種模式來計算數據並展示出來。
下面的三個例子中,我將展示一些有協議擴展特色的面向協議設計模式,依次用到Model->Controller->View組件。
Model管理中的協議(M)
假設我們有一個音樂類應用,叫Pear Music,裡面用到的model對象有Artists,Albums, Songs 和Playlists。我們需要通過某種標識,從網絡端加載這些model對象。
設計協議時,最好從頂端的抽象開始。基本思路是:有一個遠程資源,可以通過一個API來創建。我們這樣來定義協議:
// Any entity which represents data which can be loaded from a remote source. protocol 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需要調用,一個從"api.pearmusic.com"返回JSON類型數據; 另外一個從"media.pearmusic.com"返回media數據.要處理這些,我們為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) } } } }
我們提供了默認的host名稱、創建完整URL的方法,還有加載資源的方法。接下來需要協議的使用者提供正確的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有image數據的訪問權限,因此應用MediaResource協議的對象可以提供imageValue,而不管它本身是什麼類型的,也不需要考慮上下文環境。
之前提到我們可以通過已知的標識符加載model對象。因此我們創建一個描述唯一標識的協議:
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 } }
這段代碼中,我們還是需要依賴於協議采用者提供“id”屬性,因為在協議擴展中我們不能存儲屬性。另外需要注意的一點是:這裡用where Self:NSObject語句限定只有在類型為NSObject時才可使用該擴展。不這樣做的話,就沒辦法調用self.init()方法,因為根本沒有它的聲明。一個替代方案是在該協議中自己聲明init()方法,但是這樣做的話,協議的采用者就必須顯式的實現它。因為所有的model對象都是NSObject的子類,因此這並不是問題。
OK,現在我們有了一個獲取網絡資源的基本方案。下來我們來創建遵循這些協議的model類型。首先是Song model類:
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文件:
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協議擴展,我們加載Song對象的代碼會像這樣:
let s = Song(id: "abcd12345") let artistLabel = UILabel() s.loadJSON { (success) -> () in artistLabel.text = s.artist }
Duang!我們的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協議之前,先稍稍退一步。我們意識到media API只需要identifier,而不需要考慮協議應用者的類型。這意味著,只要知道了identifier,就可以創建出mediaPath。用where語句可使MediaResource更智能的處理Unique協議。
extension MediaResource where Self: Unique { var mediaPath: String { return String(format: "/images/%@", self.id) } }
因為我們的Playlist類已經遵循了Unique協議,因此不需要顯式的處理,它就可以和MediaResource搭配使用。對於所有MediaResource的使用者來說(它們也必然適配於Unique協議)也是一樣的:只要對象的identifier對應media 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 }
現在,我們已經有了一種定義遠程資源的通用方式,對於程序中任何實體都使用,而不局限於這些model對象。我們可以通過簡單的方式擴展RemoteResource,使其支持各種REST操作,另外,也可以針對其他數據類型創建子協議。
處理數據格式化的協議(C)
上文中我們創建了一種加載model對象的方法,繼續下一步:我們需要格式化對象中的元數據,並協調的顯示出來。
Peer Music是一個大應用,其中有許多不同類型的model。每個model都可能在不同的地方顯示。例如:作為view controller的title時,我們可能只顯示“name”。而如果有更多顯示空間的話,如UITableViewCell中,則顯示為“name instrument”。空間再多點的話,還可以顯示為“name instrument bio”。
當然,在controllers中,cell中,或者label中實現這些格式化方法沒有問題。但是如果能夠提取出這部分代碼邏輯,給整個app使用,會大大減少維護成本。
我們也可以將字符串格式化的代碼放到model對象中,但這樣在顯示字符串的時候,就必須確定model的類型。
也可以在基類中實現某些便利方法,由各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 } }
簡單吧。以下是model對象使用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) }
建議只將方法聲明放置到協議中,協議的應用者(adopter)會實現這些方法。而對協議擴展來說,我們會添加真正的實現代碼。displayStringValue: 方法會決定使用哪個字符串,它會用assignString:將該字符串傳遞出去,而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 } }
現在我們的model對象已經遵循了StringRepresentable協議,另外,我們還有了可以自動選擇字符串的協議。下面看看如何在UIKit中使用。
從最簡單的UILabel開始吧。傳統做法是:繼承UILabel類,應用協議,然後在需要使用StringRepresentable來顯示的時候調用這個自定義的UILabel。而更好的方案(假定我們不需要繼承),就是使用指定類型的擴展(當然這裡指定的是UILabel類),讓所有的UILabel類自動適應StringDisplay協議。
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協議,我們可以直接調用UIButton對象的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)'
現在button會根據frame的大小自動選擇title來顯示。
若我們點擊一個Album,進入AlbumDetailsViewController的頁面,協議可以幫助我們找到一個合適的字符串作為navigation的標題。有了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) } }
現在我們可以相信,格式化model的工作可以由協議擴展單獨完成,並且能夠根據不同的UI元素靈活顯示。這種模式可以在以後的model對象上重復使用,適應於不同的UI元素。因為協議的這種可擴展性,它甚至可以用在許多非UI環境中。
樣式中使用協議(V)
我們已經了解了如何在model類和格式化字符串中使用協議擴展,現在,讓我們看看單純的前段實例,看一下協議擴展是如何使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 } }
這樣,協議使用者就不需要進行顯式調用。
接著,我們定義各種特定樣式,在協議擴展的實現中返回需要的值。
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方法。這裡你可能有點疑問,實際上,重寫的awakeFromNib方法被插入到了繼承鏈中,就好像是繼承自UITableViewCell類本身。這樣,在UITableViewCell子類中調用super,就會直接調用到這個方法。
public override func awakeFromNib() { super.awakeFromNib() self.updateStyles() } }
現在,我們創建子類,然後通過應用協議來加載需要的樣式:
class PurpleHeaderCell : UITableViewCell, BackgroundColor_Purple, FontWeight_H1 {}
我們已經為UIKit的元素創建了類似css的樣式聲明。使用協議擴展,甚至可以為UIKit添加如Bootstrap的功能。這種方案在不同的方面都可以有所作為,特別是當程序中的樣式動態成都高、顯示元素較多時,更能發揮價值。
假定我們程序中有20+的view controller,每個都使用了2-3中顯示樣式。之前的我們只能被迫創建基類或者寫一堆用來定義樣式的全局函數;現在只需要實現並使用樣式協議就可以了。
我們得到了什麼?
到此為止我們已經嘗試了不少東西,它們都很有趣。但是思考一下:我們到底能從協議和協議擴展中獲得什麼?有人會認為根本沒有必要創建協議。
面向協議編程並不能完美適配於所有UI場景。
通常,當添加共享代碼或通用方法時,協議和協議擴展好處頗多。而且,代碼的組織性和函數相比更好。
數據類型越多,協議越能發揮用武之地。在UI需要顯示多種信息格式時,使用協議會得心應手。但這並不意味著,我們要添加6種協議和一打協議擴展來創建一個顯示artist名稱的紫色背景cell。
讓我們來補充Pear Music軟件的使用場景,來看看面向協議編程是否真的物有所值。
添加復雜度
假定我們已經維護了Pear Music一段時間,這個軟件可以顯示albums、artists和songs,有著友好的界面。我們又有巧妙的協議和擴展來維持MVC的結構。現在Pear的CEO要求我們創建Pear Music的2.0版本。我們需要和一個叫Apple Music的軟件進行競爭。
我們需要一項酷炫的新功能來證明自己,經過研究,決定添加“長按預覽”功能。這項功能創意新穎、獨到。公司裡長的像Jony Ive的哥們已經坐在鏡頭前侃侃而談。讓我們趕緊開始干活,用面向協議編程的方法來搞定它。
創建Modal Page
流程如下:用戶長按artist,album,song或者playlist,這時一個模態窗口(modal view)在屏幕上顯示出來,從網絡上加載條目的圖片,並顯示其描述,就像Facebook的分享按鈕做的那樣。
我們先來創建一個UIViewController,它將用來做模態顯示。從一開始,我們就考慮讓初始化方式更加通用,只需要一些遵循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! init(previewObject: protocol) { self.modelObject = previewObject super.init(nibName: "PreviewController", bundle: NSBundle.mainBundle()) } }
接下來我們使用內置的協議擴展方法來給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 } }
最後,通過同樣的方法獲取metadata,就像我們在Facebook例子中做的那樣。
// 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的初始化方法。通過基於協議的方式,既可以保證view controller的簡潔性,又可以保證其擴展性。
按照這種方式,PreviewController不用分別處理Artist,Album,Song,Playlist等不同的數據類型,變得更加簡潔和輕量級。它甚至不用些一行數據類型相關的代碼。
集成第三方代碼
以下是本教程中最後一個酷炫的示例。同樣,用PreviewController展示。這裡我們需要集成一個新的框架,來展示Twitter上音樂家的信息。在主頁面上顯示推文列表,有一下的model類可以使用:
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上顯示推文。這裡只需要通過應用現有協議來擴展它,就這麼簡單。
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了。對於PreviewController來說,它甚至不需要知道現在正在和一個外部框架打交道。
let tweet = TweetObject() let vc = PreviewController(previewObject: tweet)
課程總結
在WWDC2015上Apple建議創建協議,而不是類。但是我對這個觀點持懷疑態度,因為它忽略了在使用UIKit這個已類為重的框架時,協議擴展微妙的限制。只有當協議擴展被廣泛應用,而且不需要考慮舊代碼的時候,才能發揮它的威力。雖然在一開始我提到的例子看起來都很瑣碎,這種通用的設計在程序擴展、復雜度不斷提升時,還是非常有效。
在代碼解釋性和成本之間,需要綜合考慮。協議和擴展在大多數基於UI的程序中並不怎麼實用。如果你的app只有一個單view,顯示一種類型的數據,而且永遠不改變,就不用過分考慮實用協議。但是如果你的app要讓核心數據在不同的顯示狀態下切換,顯示樣式和展現方式多種多樣。這時,協議和協議擴展將成為數據和顯示層的橋梁,你會在後期使用中受益匪淺。
最後,我不想把協議看做是萬用靈藥,而是將其當做在某種開發場景中,一種創造性的工具。當然,我認為開發者嘗試一下面向協議技術是很有好處的,按照協議的方式,重新審視自己的代碼,你會發現很多不一樣的東西。聰明的使用它們。
如有問題,或需要更詳細的討論,請給我發郵件,或在Twitter上聯系我。