RxSwift_v1.0笔记——13 Intermediate RxCocoa
这章将学习一些高级的RxCocoa集成技巧,围绕原生的UIKit组件进行自定义封装
Note: 本章不讨论RxSwift构架,也不包括RxSwift/RxCocoa项目的最佳结构。这些讨论将放在23章“MVVM withRxSwift”
开始
在ApiController.swift中替换你的 API key。如果没有,可在 https://home.openweathermap.org/users/sign_up 这个网站申请key
private let apiKey = "[YOUR KEY]"
当搜索时显示activity
当用户点击搜索按钮时,应用没有反馈,这节将练习增加这个功能。
下图是这个功能的逻辑
为了完成这个任务,你需要拆分事件流,以便当用户按按键后,服务器数据到达时你会收到通知。
打开ViewController.swift,在 viewDidLoad()方法的style()函数下增加如下代码:
let searchInput =
searchCityName.rx.controlEvent(.editingDidEndOnExit).asObservable()
.map { self.searchCityName.text }
.filter { ($0 ?? "").characters.count > 0 }
当用户按下搜索键且输入的字符串不为空时, searchInput observable为搜索提供文本。
现在不用重头创建了,你可以使用 searchInput observable来修改 search observable。
let search = searchInput.flatMap { text in
return ApiController.shared.currentWeather(city: text ?? "Error")
.catchErrorJustReturn(ApiController.Weather.dummy)
}
.asDriver(onErrorJustReturn: ApiController.Weather.dummy)
现在,当应用调用API为忙时,你有两个observables 可以用来标示。你可以选择绑定两个observables,正确的映射到 UIActivityIndicatorView的 isAnimating属性,然后用 isHidden属性为所有的labels做同样的事。这看起来简单,但在Rx有更简洁的方法。
searchInput 和search能合并到一个observable,依据是否正在接受事件来决定是true还是false。
在刚增加的代码块下面增加:
let running = Observable.from([
searchInput.map { _ in true },
search.map { _ in false }.asObservable()
])
.merge()
.startWith(true)
.asDriver(onErrorJustReturn: false)
组合后的结果如下:
.asObservable()帮助类型转换
.startWith(true)避免了在应用启动时手动隐藏所有labels
现在,创建绑定将会非常简单。下面代码可以防止绑定到labels之前或之后,他们没有区别:
running
.skip(1)
.drive(activityIndicator.rx.isAnimating)
.addDisposableTo(bag)
第一个值是手动注入的,因此你必须略过,否则应用在打开时activity indicator将立即显示。
Then add the following to hide and show the labels accordingly to the status:
running
.drive(tempLabel.rx.isHidden)
.addDisposableTo(bag)
running
.drive(iconLabel.rx.isHidden)
.addDisposableTo(bag)
running
.drive(humidityLabel.rx.isHidden)
.addDisposableTo(bag)
running
.drive(cityNameLabel.rx.isHidden)
.addDisposableTo(bag)
the application now should look like the following when it’s making an API request:
All labels should be hidden, but the activity indicator should not display
扩展CCLocationManager用来获得当前的位置
A weather application that doesn’t know its current location is a bit odd, to say the least. You can fix this by using some of the components provided in RxCocoa.
创建扩展
第一步封装CoreLocation框架。打开 CLLocationManager+Rx.swift文件。
为保持风格一致所有的扩展加上了“.rx"命名空间。聪明的实现方法是使用RxSwift提供的Reactive代理
打开RxSwift库的 Reactive.swift文件,你会发现一个结构体Reactive<Base>、一个协议 ReactiveCompatible和一个扩展 ReactiveCompatible,它有用来创建命名空间rx的变量。
这个文件的最后一行是:
/// Extend NSObject with `rx` proxy.
extension NSObject: ReactiveCompatible { }
这显示了继承至 NSObject的类如何获得rx命名空间。你的任务是为 CLLocationManager创建专用的rx扩展,并且暴露给其他类使用。
导航到RxCocoa文件夹,你会发现一些Objective-C 文件 _RxDelegateProxy.h 、 _RxDelegateProxy.m也有 DelegateProxy.swift /和 DelegateProxyType.swift.这些文件包含了聪明的解决了桥接RxSwift与其他框架的实现,它使用代理(数据源)作为供应数据的主要资源。
DelegateProxy伪造了一个代理对象,它将代理获得的所有数据接收到专用的observables。
DelegateProxy和 在Reactive的正确使用的组合将使你的CLLocationManager扩展看起来就像所有其他RxCocoa扩展程序一样。
CLLocationManager需要一个delegate,,因此你需要创建一个必要的proxy,将所有来自必要的位置管理数据delegates到专用的observable中。映射是一个简单的一对一关系,因此单个协议函数将对应于返回给定数据的单个observable。
在 CLLocationManager+Rx.swift中增加以下代码:
class RxCLLocationManagerDelegateProxy: DelegateProxy,
CLLocationManagerDelegate, DelegateProxyType {
}
RxCLLocationManagerDelegateProxy将成为你的proxy,在一个observable创建并有一个订阅后立刻附加到 CLLocationManager实例。
这时(at this point),你需要为proxy delegate增加setter和getter。首先增加setter:
class func setCurrentDelegate(_ delegate: AnyObject?, toObject object:
AnyObject) {
let locationManager: CLLocationManager = object as! CLLocationManager
locationManager.delegate = delegate as? CLLocationManagerDelegate
}
然后是getter:
class func currentDelegateFor(_ object: AnyObject) -> AnyObject? {
let locationManager: CLLocationManager = object as! CLLocationManager
return locationManager.delegate
}
通过使用这两个函数,你能够获取并设置 delegate,这将是proxy用来推动来至 CLLocationManager实例的数据连接到observables。这就是如何扩展一个类来使用RxCocoa的delegate proxy模式。
现在使用你刚刚创建的proxy delegate创建observables来观察位置的改变,增加以下代码:
extension Reactive where Base: CLLocationManager {
var delegate: DelegateProxy {
return RxCLLocationManagerDelegateProxy.proxyForObject(base)
}
}
对于 CLLocationManager的一个实例,使用Reactive扩展将暴露该扩展中的rx命名空间中的方法。对于每一个 CLLocationManager实例,你现在有一个暴露的扩展rx可用。但是不幸的是,你没有真实的observables来获得真实的数据。
为了修复这个问题,在你刚刚创建的扩展中增加以下代码:
var didUpdateLocations: Observable<[CLLocation]> {
return
delegate.methodInvoked(#selector(CLLocationManagerDelegate
.locationManager(_:didUpdateLocations:)))
.map { parameters in
return parameters[1] as! [CLLocation]
}
}
用这个函数,delegate当做proxy来监听所有的 didUpdateLocations的调用,来获得数据并投递到一个 CLLocation数组中。 methodInvoked(_:)是在RxCocoa中的Objective-C代码的一部分,也是作为delegates的低等级的观察者
不管什么时候methodInvoked(_:)方法被调用,它都会返回一个observable发送next事件。这些事件中包含的元素是调用该方法的参数的数组。你用 parameters[1]访问这个数组,然后投递它到一个 CLLocation数组中。
现在你可以在你的应用中继承这个扩展了。
用按钮获得当前的位置
你已经创建了扩展,现在你能够使用在左下角的定位按钮:
切换到ViewController.swift,处理按钮逻辑前,需要做些准备工作。第一,导入 CoreLocation框架
import CoreLocation
下一步,增加定位管理到视图控制器:
let locationManager = CLLocationManager()
你需要确保应用有足够的权限访问用户的位置。从iOS8后,在应用获取位置数据前,操作系统必须获得用户许可。因此,首先你需要的是,当用户点击位置按钮时,请求许可然后更新数据。
在 viewDidLoad()中增加以下代码实现:
geoLocationButton.rx.tap
.subscribe(onNext: { _ in
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
})
.addDisposableTo(bag)
为了测试应用是否接收到了用户的位置,使用下面的临时片段测试:
locationManager.rx.didUpdateLocations
.subscribe(onNext: { locations in
print(locations)
})
.addDisposableTo(bag)
当你构建并允许程序后,你应该能够看到类似下图中控制台输出:
Note:当使用仿真器时,你可以伪造位置,通过Debug\Location,然后选择一个仿真的位置。
在 ApiController.swift里有一个专用的函数,它基于用户的经纬度检索来至服务器的数据。
func currentWeather(lat: Double, lon: Double) -> Observable<Weather>
在 viewDidLoad(),创建一个observable, 返回最新的有效位置:
let currentLocation = locationManager.rx.didUpdateLocations
.map { locations in
return locations[0]
}
.filter { location in
return location.horizontalAccuracy < kCLLocationAccuracyHundredMeters
}
didUpdateLocations发射了一个抓取位置的数组,但你只需要一个,这就是为什么你使用map获得第一个位置。然后您使用filter来防止使用完全不同的数据,并确保位置准确到一百米以内。
用当前的数据更新天气
你有一个observable用来返回用户的位置,并且有一个机制,基于经纬度来获得天气。一个自然的组合在RxSwift中应该是:
模拟observables的请求,用下面代码替换 已经存在的geoLocationButton.rx.tap:
let geoInput = geoLocationButton.rx.tap.asObservable()
.do(onNext: {
self.locationManager.requestWhenInUseAuthorization()
self.locationManager.startUpdatingLocation()
})
let geoLocation = geoInput.flatMap {
return currentLocation.take(1)
}
上面代码确保了位置管理器正在更新并提供关于当前位置的信息,并且仅转发单个值。这样可以防止应用程序每次从位置管理器更新新值。
现在创建一个新的observable检索天气数据
let geoSearch = geoLocation.flatMap { location in
return ApiController.shared.currentWeather(lat:
location.coordinate.latitude, lon: location.coordinate.longitude)
.catchErrorJustReturn(ApiController.Weather.dummy)
}
上面代码生成了一个天气类型的observable的geoSearch,这与使用城市名称作为输入的调用相同。两个observables返回同样的天气类型,执行同样的任务,这听起来像是代码需要重构!
是的,上面的代码与城市名作为输入的代码,能够用observable进行合并。这个新的特性给了你同样的结果而不必重构整个应用。
我们的目标是保持search作为Weather的Driver,并且作为当前应用状态的observable来运行。为了实现第一个目标,删除当前的search observable,并在你声明searchInput之后创建一个中间量:
let textSearch = searchInput.flatMap { text in
return ApiController.shared.currentWeather(city: text ?? "Error")
.catchErrorJustReturn(ApiController.Weather.dummy)
}
现在你能够用 geoSearch合并 textSearch,来创建一个新的搜索observable,在前面的块后面附加:
let search = Observable.from([geoSearch, textSearch])
.merge()
.asDriver(onErrorJustReturn: ApiController.Weather.dummy)
这将传递一个Weather对象到与源相关的UI,既可以是城市名也可以是用户的当前位置。最后一步是提供反馈并确保搜索时正确的显示activity indicator,在请求完成后隐藏它。
现在,跳转到定义 running observable.的位置,改变第一行代码以便 geoInput包含在源中,如下:
let running = Observable.from([
searchInput.map { _ in true },
geoInput.map { _ in true },
search.map { _ in false }.asObservable()
])
现在不管是用户搜索城市还是点击位置按钮,应用的行为将完全一致。
你使用合并操作增加了一个额外的源来扩展应用的功能,它转换你的扁平的,单一数据流转换为多源合一的数据流:
也有一些运行状态的改变:
你已经创建了一个相当高级的应用:你开始用一个单一的文本源,现在你有了两个与前一章代码逻辑相似的数据源。
怎样扩展UIKit view
现在是时候探索如何扩展UIKit组件去超越RxCocoa所提供的了。
应用现在显示了用户位置的天气,但是在滚动和导航的同时,您可以在地图上探索周围的天气。
这听起来像是你将创建新的reactive扩展,这次是MKMapView类。
使用MKMapView扩展UIKit views
开始扩展 MKMapView,你将开始用你扩展 CLLocationManager所使用的相同的样式:为 MKMapView base 类创建一个delegate proxy RxMKMapViewDelegateProxy 和 extend Reactive。
打开 MKMapView+Rx.swift,你可以在Extensions目录找到它,然后创建扩展的基础:
class RxMKMapViewDelegateProxy: DelegateProxy, MKMapViewDelegate,
DelegateProxyType {
}
extension Reactive where Base: MKMapView {
}
在RxMKMapViewDelegateProxy内部,创建delegate的setter和getter以使proxy到位:
class func currentDelegateFor(_ object: AnyObject) -> AnyObject? {
let mapView: MKMapView = (object as? MKMapView)!
return mapView.delegate
}
class func setCurrentDelegate(_ delegate: AnyObject?, toObject object:
AnyObject) {
let mapView: MKMapView = (object as? MKMapView)!
mapView.delegate = delegate as? MKMapViewDelegate
}
下一步,通过增加以下的Reactive扩展来创建proxy:
public var delegate: DelegateProxy {
return RxMKMapViewDelegateProxy.proxyForObject(base)
}
你已经创建了proxy。现在你能够扩展 MKMapView到代理委派的方法到observables。
在扩展 MKMapView之前,需要确保当前项目能够正确的显示map视图。
在视图控制器右下角已经有了这个按钮:
现在增加代码到 viewDidLoad(),以便在按钮按下时用来显示或隐藏地图视图:
mapButton.rx.tap
.subscribe(onNext: {
self.mapView.isHidden = !self.mapView.isHidden
})
.addDisposableTo(bag)
构建并运行项目,然后重复点击map按钮来查看地图的显示和隐藏:
在地图上显示叠加层 260
现在地图已经准备接收和显示数据,但是首先你需要增加天气叠加层。你需要执行以下delegate方法:
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) ->
MKOverlayRenderer
在Rx上封装一个有返回类型的delegate是非常困难的,有以下两个原因:
- 具有返回类型的Delegate方法不适用于观察,而是用于定制行为。
- 定义自动的默认值可以工作在任何情况下是一个不平常的taskIdentifier。
你能够使用Subject观察这个值,但是这样的话它将提供非常小的值
考虑所有这些情况,最后的解决方案是将此调用转发(forward)给delegate的classic实现
基本上你获得了最好的两个世界:您希望使用返回值符合代理的方法,就像使用普通UIKit开发一样实用,但是你也希望能够使用来至代理函数的observables。这次,只有一次,你能同事拥有他们
MKMapViewDelegate不是唯一的,有代理函数协议需要一个返回类型的协议,有一个现成的方法帮助你解决:
public static func installForwardDelegate(_ forwardDelegate: AnyObject,
retainDelegate: Bool, onProxyForObject object: AnyObject) -> Disposable
如果你想查看函数的实现,在RxCocoa中查找 DelegateProxyType.swift。
你希望转发在Rx proxy中没有封装的代理方法。为MKMapView增加Reactive扩展:
public func setDelegate(_ delegate: MKMapViewDelegate) -> Disposable {
return RxMKMapViewDelegateProxy.installForwardDelegate(
delegate,
retainDelegate: false,
onProxyForObject: self.base
)
}
用这个函数,你现在能够安装一个转发代理,它将转发调用,如果需要,它也提供返回值。
增加下面代码到 viewDidLoad()的结尾,设置视图控制器作为delegate, 来接收来至你RxProxy的所有未处理的调用。
mapView.rx.setDelegate(self)
.addDisposableTo(bag)
编译器会报错(协议没有实现)。在文件末尾增加下面代码:
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) ->
MKOverlayRenderer {
if let overlay = overlay as? ApiController.Weather.Overlay {
let overlayView = ApiController.Weather.OverlayView(overlay:
overlay, overlayIcon: overlay.icon)
return overlayView
}
return MKOverlayRenderer()
}
}
OverlayView是需要通过MKMapView实例来渲染覆盖在地图上的信息的类型。这里的目标是简单的在地图上显示天气图标——不需要提供任何额外的信息。稍后,将详细介绍 OverlayView。
到这你几乎完成了:你解决了delegate返回类型的问题,创建了转发proxy,设置了覆盖显示。现在是时候用RxSwift处理这些overlays。
导航到MKMapView+Rx.swift,增加下面绑定观察者到Reactive扩展,这将抓取 MKOverlay的所有实例并把它们注入到当前的地图中:
var overlays: UIBindingObserver<Base, [MKOverlay]> {
return UIBindingObserver(UIElement: self.base) { mapView, overlays in
mapView.removeOverlays(mapView.overlays)
mapView.addOverlays(overlays)
}
}
使用 UIBindingObserver让你可以使用 bindTo或drive函数——非常方便!
overlays内部绑定了observable,先前的overlays将被移除并重新创建
考虑应用的范围,这儿没有任何优化的必要。同一时刻不可能超过10个overlays,所以删除所有内容并增加新内容是一个公平妥协。如果不需要处理更多,你能够使用diff algorithm来改进性能并减少开销。
使用已创建的绑定 262
打开 ApiController.swift并检查 Weather结构体。这里有两个嵌套的类:Overlay和OverlayView。
Overlay是NSObject的子类并实现了 MKOverlay协议。这是你将传递到 OverlayView的,渲染实际数据并覆盖在地图上的数据的信息对象。你仅仅需要知道, Overlay只保持了在地图上显示图标的必要信息:坐标,显示数据的矩形和当前使用的图标。
OverlayView的责任是渲染overlay。为了避免导入图片, imageFromText将把文本转换为图片,因此图标作为overlay能够容易的显示在地图上。 OverlayView只需要原始的overlay实例和图标字符串来创建一个新的实例。
在Wearther 结构体中,你将看到一个便利的函数,它转换了结构体到一个有效的Overlay:
func overlay() -> Overlay { ... }
切换到ViewController.swift并增加以下代码到 viewDidLoad():
search.map { [$0.overlay()] }
.drive(mapView.rx.overlays)
.addDisposableTo(bag)
这绑定了最新送达的数据到你前面创建的overlays目标,并映射Weather到正确的overlay。
构建并运行程序,搜索一个城市,然后打卡地图,滚动到那个城市,你应该看到像下图所示:
观察地图滚动事件
用绑定属性扩展 MKMapView后,是时候看看怎样为delegates实现更多便利的通知机制。与你定义的 CLLocationManager没有什么是不同的,你可以用同样的样式。
现在的目标是监听来之地图视图的用户拖动事件和其他导航事件。一旦用户停止浏览(navigate around),你将为地图的中心位置更新天气状况并显示它。
MKMapViewDelegate提供了以下方法来观察这个变化:
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated:
Bool)
在你实现这个代理方法时,每次用户拖动地图到一个新的区域都会被调用。这是一个创建reactive扩展的好机会。在MKMapView+Rx.swift的扩展内增加:
public var regionDidChangeAnimated: ControlEvent<Bool> {
let source = delegate
.methodInvoked(#selector(MKMapViewDelegate
.mapView(_:regionDidChangeAnimated:)))
.map { parameters in
return (parameters[1] as? Bool) ?? false
}
return ControlEvent(events: source)
}
为了安全,如果投递失败,该方法范围false。
响应regionDidChangeAnimated事件
剩下的部分是使用先前创建的 ControlEvent
切换到 ViewController.swift,你需要做以下改变:
-
创建mapInput,它将使用先前创建的observable。
-
创建mapSearch,它将为位置触发搜索。
-
更新search的observable来处理mapSearch的结果。
-
更新running的observable来正确的处理地图事件和天气结果。
第一个改变是相当的简单,并且必须在 let textSearch = …
之后完成
let mapInput = mapView.rx.regionDidChangeAnimated
.skip(1)
.map { _ in self.mapView.centerCoordinate }
skip(1)可以防止应用程序在mapView初始化之后立即触发搜索。
下一步使用 mapInput创建 mapSearch observable,来抓取地图的天气数据:
let mapSearch = mapInput.flatMap { coordinate in
return ApiController.shared.currentWeather(lat: coordinate.latitude,
lon: coordinate.longitude)
.catchErrorJustReturn(ApiController.Weather.dummy)
}
接下来需要更新搜索结果和运行状态的observable
let search = Observable.from([geoSearch, textSearch, mapSearch])
你仅仅添加mapSearch在数组末端。最后要做的以下列方式来修改observable的调用运行:
let running = Observable.from([searchInput.map { _ in true },
geoInput.map { _ in true },
mapInput.map { _ in true},
search.map { _ in false }.asObservable()])
.merge()
.startWith(true)
.asDriver(onErrorJustReturn: false)
像以前一样,简单的增加mapInput.map { _ in true}
到数组而不需要改变链式代码。
构建并运行你的应用,浏览(navigate around)地图,查看每个滚动后显示当地天气状况的天气图标!
RxCocoa总结
在这两个章节,你在RxSwift上浏览了惊人的扩展的大部分有趣的部分。RxCocoa不是强制性的,你可以完全不使用它来写你的应用,但是猜想你已经知道了它对你应用是有用的。
下面列出了RxCocoa的优点:
- 它已经为大部分常用的组件集成了许多扩展
- 它超越(goes beyond)了基本的UI组件
- 它很容易与bindTo或drive一起使用
- 它提供了所有用来创建你自定义扩展的机制
在开始下章前,浏览下RxCocoa来增加使用更多通用扩展的信心,下章将使用非常广泛。
网友评论