美文网首页RxSwift
RxSwift_v1.0笔记——13 Intermediate

RxSwift_v1.0笔记——13 Intermediate

作者: 大灰很 | 来源:发表于2017-07-16 19:16 被阅读146次

    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来增加使用更多通用扩展的信心,下章将使用非常广泛。

    挑战

    挑战1:添加绑定属性以将地图聚焦在给定点上

    挑战2:使用MKMapView来浏览位置并显示附近的天气条件

    相关文章

      网友评论

      本文标题:RxSwift_v1.0笔记——13 Intermediate

      本文链接:https://www.haomeiwen.com/subject/szhxkxtx.html