本章將向你介紹另一個框架,它是原生RxSwift庫的一部分:RxCocoa。
RxCocoa全平臺通用。每個平臺有一套自定義的封裝,它提供了一套內建的擴展,給許多UI控件和其他SDK類。在本章中,您將在iPhone和iPad上使用為iOS提供的功能。
Note: 當前,RxCocoa幾乎完成了對iOS的支持,接下來是Apple Watch和macOS。macOS仍然缺少一些高級的封裝實現,但它包括創建跨平臺解決方案的所有基礎來共享邏輯。后續章節你將看到它如何使用。
開始 229
這個項目叫Wunderast,一個天氣app,由OpenWeatherMap http://openweathermap.org 提供數據。項目使用CocoaPods集成了RxSwift,RxCocoa和SwiftyJSON(處理OpenWeatherMap API返回的JSON數據)框架。
RxCocoa與RxSwift一起被釋放。兩個框架共享相同的釋放進度,通常最新RxSwift包含同樣版本的RxCocoa。
在這個項目中,你將使用為 UITextField和 UILabel的Rx封裝,建議先瀏覽這兩個文件以便理解他們是如何工作的。
打開 UITextField+Rx.swift(RxCocoa中),這個文件很短——小于50行代碼,唯一的屬性是 ControlProperty<String?>類型的text。
ControlProperty<String?>是Subject專用的類型,它能被訂閱也能注入新的值。text屬性直接關聯到 UITextField的text屬性。
打開UILabel+Rx.swift,里面有兩個屬性: text 和 attributedText。它們同樣關聯到原始的 UILabel的屬性。它們都使用了 一個新的類型UIBindingObserver。
這個observer與ControlProperty相似,它專用與同UI一起工作。 UIBindingObserver用下面的邏輯來綁定UI,它不能綁定錯誤。如果有錯誤發送給了 UIBindingObserver,在Debug模式它將調用 fatalError(),在release模式將被增加到錯誤日志中。
配置API key 230
打開 https://home.openweathermap.org/users/sign_up ,注冊并在 https://home.openweathermap.org/api_keys 頁面生成一個新的key。
復制API key 粘貼到ApiController.swift文件的下面位置:
private let apiKey = "[YOUR KEY]"
使用RxCocoa與基本的UIKit控件 230
你現在準備輸入一些數據并調用API來返回給定城市的天氣,包括溫度,濕度和城市名。
使用RxCocoa顯示數據 230
如果你已經運行了這個項目,您可能會問為什么應用程序在從API中實際檢索任何內容之前顯示數據。這是為了讓你能夠確認手動注入的數據是正確的,如果有錯誤你就知道它是在API的處理代碼中,而不會在你的Rx邏輯和UI相關代碼中。
在ApiController.swift中,你將看到一個結構體,它使用Swift更易于設計,作為一個適當映射到JSON的數據結構被使用。
struct Weather {
let cityName: String
let temperature: Int
let humidity: Int
let icon: String
...
}
在ApiController.swift中查看一下函數:
func currentWeather(city: String) -> Observable<Weather> {
// Placeholder call
return Observable.just(
Weather(
cityName: city,
temperature: 20,
humidity: 90,
icon: iconNameToChar(icon: "01d"))
)
}
這個函數返回了一個偽造的城市名RxCity并顯示了一些虛擬的數據,你使用它替代真實的數據,直到你檢索了來之服務器的天氣信息。
虛擬的數據可以幫助簡化開發過程并給你機會用一個實際的數據結構來工作,甚至不需要網絡聯接。
打開 ViewController.swift,它是這個項目中唯一的視圖控制器。這個項目的主要目標是連接這個唯一的視圖控制器到 提供數據的ApiController。
結果是單向數據流:
如前幾章所述,RxSwift(更準確地說,observables)能夠接收數據并讓所有訂閱者知道數據已經到達,并推送要處理的值。因此,在視圖控制器工作時,訂閱observable的正確位置是在 viewDidLoad內。這是因為你需要盡可能早的訂閱,但僅僅在加載視圖后。訂閱晚了可能導致丟失事件,或是部分UI在你綁定數據前消失了。
要檢索數據,請在 viewDidLoad:末尾增加下面代碼
ApiController.shared.currentWeather(city: "RxSwift")
.observeOn(MainScheduler.instance)
.subscribe(onNext: { data in
self.tempLabel.text = "\(data.temperature)° C"
self.iconLabel.text = data.icon
self.humidityLabel.text = "\(data.humidity)%"
self.cityNameLabel.text = data.cityName
})
構建并運行APP,將看到如下:
現在還有兩個問題:
- 有一個編譯器警告
- 你沒有使用文本框輸入
第一個問題顯示如下:
當視圖控制器消失時訂閱必須被取消。增加下面屬性到視圖控制器類:
let bag = DisposeBag()
增加 .addDisposableTo(bag)
ApiController.shared.currentWeather(city: "RxSwift")
.observeOn(MainScheduler.instance)
.subscribe(onNext: { data in
self.tempLabel.text = "\(data.temperature)° C"
self.iconLabel.text = data.icon
self.humidityLabel.text = "\(data.humidity)%"
self.cityNameLabel.text = data.cityName
})
.addDisposableTo(bag)
無論何時,視圖控制器被施放,它將取消并銷毀訂閱
現在需要來處理文本框。RxCocoa在Cocoa之上增加了許多,因此你能夠開始使用這個功能完成你的宏偉目標。這個框架使用了強大的協議擴展(protocol extensions)并給許多UIKit組件增加了rx命名空間。也就是說你能夠輸入 searchCityName.rx.查看到可用的屬性和方法:
有一個你之前已經探究過的:text。這個函數返回一個observable,它是一個 ControlProperty<String?>類型,它遵循了 ObservableType 和ObserverType,因此你能夠訂閱它,也能夠發射新的值。
了解了 ControlProperty的基本背景知識后,你能夠改進代碼,利用文本框的優勢在虛擬數據中來顯示城市名。增加到 viewDidLoad():
searchCityName.rx.text
.filter { ($0 ?? "").characters.count > 0 }
.flatMap { text in
return ApiController.shared.currentWeather(city: text ?? "Error")
.catchErrorJustReturn(ApiController.Weather.empty)
}
上面的代碼將返回一個新的observable與要顯示的數據。 currentWeather不接收nil或者empty值,所以你需要將他們濾掉。然后你使用 ApiController類來抓取天氣數據。在先前的章節你已經完成了相似的涉及網絡的任務,因此你不需要在意這些細節。
繼續先前的代碼塊,切換到正確的線程并顯示數據:
.observeOn(MainScheduler.instance)
.subscribe(onNext: { data in
self.tempLabel.text = "\(data.temperature)° C"
self.iconLabel.text = data.icon
self.humidityLabel.text = "\(data.humidity)%"
self.cityNameLabel.text = data.cityName
})
.addDisposableTo(bag)
你切換到主線程用當前的天氣數據來更新UI。下圖可視化了這個流程:
無論你什么時候改變輸入,label將更新城市名——但是現在它將一直返回你虛擬的數據。應用顯示虛擬數據是正確的,是時候獲得來至API的真實數據了。
Note:catchErrorJustReturn會再以后解釋。 當你接收了一個來至API的錯誤時,需要防止observable被銷毀。例如,無效的城市名稱返回404作為NSURLSession的錯誤。在這種情況下,您需要返回一個空值,以免應用程序遇到錯誤時停止工作。
檢索來至OpenWeather API的數據 234
API返回結構化的JSON響應,以下是有效的位:
{
"weather": [
{
"id": 741,
"main": "Fog",
"description": "fog",
"icon": "50d"
}
],
}
上面的數據被關聯到當前的天氣;圖標元素為當前的條件顯示正確的圖標。下面這段分配了溫度和濕度的數據。
"main": {
"temp": 271.55,
"pressure": 1043,
"humidity": 96,
"temp_min": 268.15,
"temp_max": 273.15
}
}
上面的溫度單位為Kelvin
在ApiController.swift,有一個叫 iconNameToChar的函數,輸入字符串(更準確說是來至JSON的圖標數據)并返回另一個字符串,它使用UTF-8編碼,在你的應用中形象化的呈現天氣圖標。還有一個方便的函數 buildRequest用來創建網絡請求。它使用RxCocoa封裝讓 NSURLSession執行網絡請求。這個函數有以下任務:
- 獲得基本的URL并附加組件來正確的構造GET(或POST)請求
- 使用你的API key
- 給 application/json設置請求類型
- 請求度量單位(在這里是degrees Kelvin)
- 返回的數據映射到JSON對象
最后一行return語句如下:
//[...]
return session.rx.data(request: request).map { JSON(data: $0) }
它圍繞 NSURLSession,使用了RxCocoa的rx擴展的data函數。返回 Observable<Data>。這個數據作為map函數的輸入被使用,它用來轉換原始數據到JSON類型的SwiftyJSON數據結構。
下圖可以幫助你更好的理解 ApiController內部的原理:
從虛擬數據切換到實際數據請求很簡單。你需要用一個真實的網絡請求數據替換 Observable.just([…])的調用。OpenWeatherMap的API文檔 http://openweathermap.org/current 解釋了如何通過api.openweathermap.org/data/2.5/weather?q={city name} 來獲得給定城市當前的天氣。
在ApiController.swift,用下列實現替換虛擬的 currentWeather(city:):
func currentWeather(city: String) -> Observable<Weather> {
return buildRequest(pathComponent: "weather", params: [("q", city)])
.map { json in
return Weather(
cityName: json["name"].string ?? "Unknown",
temperature: json["main"]["temp"].int ?? -1000,
humidity: json["main"]["humidity"].int ?? 0,
icon: iconNameToChar(icon: json["weather"][0]["icon"].string ??
"e")
)
}
}
這個請求返回一個JSON對象,它能夠同一些回調值轉換到你期望的Weather的數據結構,然后給你的用戶界面。
構建并運行,輸入London,你將看到下面結果:
你的應用現在可以顯示來自服務器檢索的數據了。你已經使用了一些RxCocoa特性,下節你將使用更多RxCocoa的高級特性。
Note: 如果你想了解更多(going the extra mile),移除flatmap內部的catchErrorJustReturn。一旦你收到404錯誤因為一個無效的城市名,(你將在log中看到),這個應用將停止工作因為你的observable由于錯誤被銷毀了。
綁定observables 237
綁定稍微有些爭議——例如,蘋果絕不會在iOS上釋放他們的Cocoa Bindings系統(即使它很長一段時間已經是macOS的重要部分)。mac綁定非常高級并且在macOS SDK中與蘋果提供的專用類有些許結合。
RxCocoa中提供了一些簡單的解決方案,它只依賴框架中包含的幾種類型。既然你已經感覺RxSwift編碼很舒適,所以你會非常快速的看出綁定。
在RxCocoa,綁定是單向的數據流。本書中不會覆蓋到雙向綁定。
什么是綁定observables 237
容易理解綁定的方式是想想兩個實體之間的連接關系
- 生產者,生產值
- 接受者,處理來自生產者的值
接受者不能夠返回值。這是RxSwift綁定的基本規則。
Note:如果你想試驗雙向綁定(例如在數據模型屬性和文本框之間),你應該使用四個實體模組化:兩個生產者和兩個接受者。你可以想象這會是相當復雜的。
綁定的函數叫 bindTo(_:)。要綁定observable到另一個實體,接收者必須遵循 ObserverType。前面章節已經解釋過這個實體:它是一個能夠處理值的Subject,也能手動寫。Subject同Cocoa的重要特性一起工作是極其重要的,考慮到框架的組件,例如UILabel,UITextField和UIImageView,它們有可變的數據,能夠被設置或檢索。
bindTo(:)也能用作其他目的——不僅僅綁定用戶界面到源(underlaying)數據。例如,你應該使用 bindTo(:)創建依賴進程,以便一個確定的observable將觸發一個對象去執行一些后臺任務,而在前臺不用顯示任何東西。
總得來說, bindTo(:)是一個特殊的經過裁剪的 subscribe(:)的版本,當調用bindTo(_:)時沒有副作用或其他情況。
使用綁定observables顯示數據 238
現在你可以集成綁定到你的應用。這將讓整個代碼更加簡潔并且轉換搜索結果到可重用的數據源。
第一步重構很長的observable,用 subscribe(onNext:)分配數據到當前的標簽。打開ViewController.swift,在viewDidLoad()中,用以下代碼替換全部的 searchCityName訂閱代碼:
let search = searchCityName.rx.text
.filter { ($0 ?? "").characters.count > 0 }
.flatMapLatest { text in
return ApiController.shared.currentWeather(city: text ?? "Error")
.catchErrorJustReturn(ApiController.Weather.empty)
}
.observeOn(MainScheduler.instance)
這個改變,尤其是 flatMapLatest,使得搜索結果可重用,并將一次性的數據源轉換到多重使用的observable。這一變化的能力將在專門針對MVVM的章節中介紹,現在只用簡單知道,在Rx中observable是能夠被大量地(heavily)重用的實體,正確的建模可以使一個長期,難以閱讀的一次性觀察者變成一個多用途和易于理解的觀察者。
通過這個小小的改變,它可以處理來自不同訂閱的每個參數,映射到值來請求顯示。例如,這里是如何將溫度作為字符串從共享數據源中獲取的observable:
search.map { "\($0.temperature)° C" }
這將創建一個observable,它返回需要顯示溫度的字符串。試著創建你的第一個綁定,使用bindTo 來連接原始數據源到溫度標簽。在 viewDidLoad()中增加:
search.map { "\($0.temperature)° C" }
.bindTo(tempLabel.rx.text)
.addDisposableTo(bag)
構建并運行,使用新的RxCocoa綁定能力來顯示溫度
現在應用只顯示溫度,但你可以簡單的在剩余的labels上應用同樣的樣式來實現前面的功能:
search.map { $0.icon }
.bindTo(iconLabel.rx.text)
.addDisposableTo(bag)
search.map { "\($0.humidity)%" }
.bindTo(humidityLabel.rx.text)
.addDisposableTo(bag)
search.map { $0.cityName }
.bindTo(cityNameLabel.rx.text)
.addDisposableTo(bag)
現在應用程序使用單一的一個叫做search的源observable來顯示你請求的來自服務器的數據,并綁定數據塊到屏幕上的每個標簽:
另一個不錯的清晰的作用是,由編譯器檢查來確保正確類型的使用。基本上不可能由于類型不同問題導致app崩潰。
Note:當綁定到UI組件時,RxCocoa將檢查觀察是否在主線程。如果不上,它將調用fatalError(),應用將崩潰輸出下面的信息:fatal error: Element can be bound to user interface only on MainThread.
使用Units來改善代碼 240
RxCocoa提供更高級的功能,使Cocoa和UIKit的工作變得輕而易舉。除了bindTo,它還提供了observable的特殊實現,它們專門用于與UI配合使用:Units。Units是一組類,專用于observable,當與UI一起工作時,它讓寫代碼變得更容易和簡單。讓我們看看吧!
什么是ControlProperty和Driver? 241
Units的官方文檔是這樣描述的:
Units有助于通訊并保證observable序列屬性與交互界面綁定。
沒有上下文這聽起來相對抽象,當給用戶界面控件綁定observables時,讓我們考慮一些通用的概念。觀察需要一直訂閱在主線程確保能夠更新UI,你常常需要分享訂閱來綁定多個UI組件,并且你不想有錯誤中斷UI。
經過以上思考,下面是Units的實際特性列表:
- Units 不能輸出錯誤
- Units在主調度表上被觀察
- Units在主調度表上被訂閱
- Units共享副作用
這些特性的存在確保了用戶界面一直顯示一些東西,且顯示的數據一直被正確的方式加工過,這樣UI就能處理它。Units框架的兩個主要組成部分如下:
- ControlProperty 和 ControlEvent
- Driver
ControlProperty不是新知識;你在不久前(a little while ago)剛剛使用過它,使用專用的rx擴展綁定數據到正確的用戶界面組件。
ControlEvent被用來監聽UI控件的某些事件,像是在編輯文本框時,在鍵盤上按“返回”按鈕。如果該組件使用UIControlEvents來跟蹤(keep track of)其當前狀態,控制事件就是有效的。
Driver是一個特殊的observable,具有與前面相同的約束,它不能輸出錯誤。所有處理必須取保在主線程執行,避免在后臺線程改變UI。
Units通常是框架的可選部分,你不需要一定使用它。毫無忌諱的連接observables和subjects來確保正在正確的調度表中做正確的任務——但是如果你想要更好的編譯檢查和明確的UI約束,Units是強大和節省時間的組件。不使用Untis,就容易忘記調用 .observeOn(MainScheduler.instance),最后(end up)會嘗試在后臺進程更新你的UI。
Driver 和 ControlProperty現在看起來難以理解,不用擔心。像許多Rx一樣,一旦你深入代碼,就會更有感覺。
使用Driver and ControlProperty改善項目 241
原理講完了,是時候應用這些好的概念到你的應用。
第一步,轉換天氣數據observable到driver。 在viewDidLoad()中找到你定義search 常量的位置,用下面代碼替代它:
let search = searchCityName.rx.text
.filter { ($0 ?? "").characters.count > 0 }
.flatMapLatest { text in
return ApiController.shared.currentWeather(city: text ?? "Error")
.catchErrorJustReturn(ApiController.Weather.empty)
}
.asDriver(onErrorJustReturn: ApiController.Weather.empty)
關鍵代碼時底部的: .asDriver(…)。它把observable轉換為了Driver。 onErrorJustReturn在observable出錯是返回默認值——這為driver自己消除了發射錯誤的可能。
你可能也注意到自動完成提供了asDriver(onErrorJustReturn:)的另一個變體:
- asDriver(onErrorDriveWith:)它可以手動處理錯誤,返回為此目的生成的新序列。
- asDriver(onErrorRecover:)這一個與另一個現有的驅動程序一起使用。這將會恢復僅僅出現錯誤的當前驅動程序。
現在用drive替換所有的4個訂閱的bindTo。
search.map { "\($0.temperature)° C" }
.drive(tempLabel.rx.text)
.addDisposableTo(bag)
search.map { $0.icon }
.drive(iconLabel.rx.text)
.addDisposableTo(bag)
search.map { "\($0.humidity)%" }
.drive(humidityLabel.rx.text)
.addDisposableTo(bag)
search.map { $0.cityName }
.drive(cityNameLabel.rx.text)
.addDisposableTo(bag)
drive的工作與bindTo十分相似;名稱的差異更好地表達使用Units的意圖。
你任然有一些地方需要改進。應用使用了太多的資源并產生了太多API請求——因為它在每次輸入字符時觸發(fire )一個請求。 有點過分,你不覺得嗎?
節流( throttle)將是一個好的選擇,但它任然會導致一些非必要的請求。另一個好的選擇應該是使用文本框的 ControlProperty并且僅僅當用戶點擊鍵盤上的搜索按鈕是才出發請求。
下面這行:
let search = searchCityName.rx.text
用下面代碼替換
let search =
searchCityName.rx.controlEvent(.editingDidEndOnExit).asObservable()
.map { self.searchCityName.text }
為了保證輸入有效,最好略過空字符串并過濾搜索observable。然后繼續鏈式代碼:
.flatMap { text in
return ApiController.shared.currentWeather(city: text ?? "Error")
}
.asDriver(onErrorJustReturn: ApiController.Weather.empty)
現在應用僅僅當用戶點擊搜索按鈕才檢索天氣。沒有網絡請求被浪費,并且代碼由Units在編譯時控制。你也移除了 catchErrorJustReturn(_:)
原模式使用單observable更新UI;通過多個塊的分解,您已從訂閱切換到bindTo,并在視圖控制器中重復使用相同的可觀察值。這種方式讓代碼易于使用和復用。
例如,如果你想增加當前的大氣壓顯示在用戶界面,你所要所的是增加屬性到結構體,映射JSON值,然后增加另一個UILabel,并映射那個屬性到新標簽,簡單!
銷毀RxCocoa 244
本章的最后的主題是純理論的,超越了這個項目。正如本章開始所說的,在視圖控制器有一個bag,當視圖控制器施放時,它負責銷毀所有的訂閱。但是在這個例子中,為什么在閉包中沒有使用weak或者unowned?
答案很簡單:此應用是單視圖控制器,且當app運行時一直在屏幕上,因此不需要用guard來防止循環引用或浪費內存。
unowned vs weak with RxCocoa 244
當用Cocoa處理RxCocoa或RxSwift時,很難判斷什么時候用weak或unowned。當閉包能夠在將來的某個時間內調用,當前self對象已經施放時,你使用weak。為此,self變為可選值。unowned避免了可選的self。但是代碼必須確保在閉包獲得調用前,對象沒有施放——否則app將崩潰。
以下是一些使用weak,unowned或nothing的一些建議:
- nothing:在單例或一個視圖控制器中絕不會施放
- unowned:閉包執行之后才施放的所有視圖控制器內
- weak:其他情況
這些規則防止經典的EXC_BAD_ACCESS錯誤。如果你一直遵守這些規則,你將不會遇到內存管理方面的問題。如果你想確保安全,raywenderlich.com Swift Guidelines https://github.com/raywenderlich/swift-style-guide#extending-object-lifetime 不推薦使用unowned。
何去何從? 245
RxCocoa是一個很大的框架。現在你僅僅使用了很小的一部分。
在接下來的章節,你將會看到,如何通過增加專用的函數來擴展RxCocoa來改善這個應用,如何使用RxSwift和RxCocoa來增加更多高級特性。
在開始之前,讓我們花些時間來學習下RxCocoa和.rx擴展。我們來看一組例子:
UIActivityIndicatorView
UIActivityIndicatorView是一個常用的UIKit組件。這個擴展包含了如下屬性:
public var isAnimating: UIBindingObserver<Base, Bool>
它的名稱已經說明了它是關聯到原始的isAnimation屬性。正如你所看到的,類似與UILabel,這個屬性是UIBindingObserver類型,并且結果是它能夠綁定到一個observable來通知后臺指示器。正如你在第10張的挑戰中所使用的。
UIProgressView
UIProgressView不是一個常用的組件,但是它也在RxCocoa中,且有下列屬性:
public var progress: UIBindingObserver<Base, Float>
像所有其他類似組件一樣, UIProgressBar能夠綁定到一個observable。例如,假設有一個 uploadFile(),它正在處理一個上傳文件到服務器的任務的observable,提供了發送字節的中間事件和總字節數。這個代碼應該應該看起來像這樣:
let progressBar = UIProgressBar()
let uploadFileObs = uploadFile(data: fileData)
uploadFileObs.map { sent, totalToSend in
return sent / totalToSend
}
.bindTo(progressBar.rx.progress)
.addDisposableTo(bag)
結果是:在每一個中間值被提供的時間點更新progress bar,并且用戶有任務進度的虛擬指示。
現在改輪到你了。你在擴展中畫的時間越多,你將會在后續章節和將來的應用中更安逸的使用它們。
Note: RxCocoa是一個持續改進的框架。如果你認為缺少任何控件或擴展,你可以創建它們并提交一個pull請求到官方的倉庫。社區歡迎和鼓勵你的貢獻。