本文由Ellen Shapiro于16年发表于raywenderlich,原文可查看Getting Started With RxSwift and RxCocoa。
若能随心所欲的控制自己的代码达成预期的效果,想来一定是极好的。
面向对象编程中大部分程序都是命令式的:你的代码告诉你的程序去干什么同时有多种方式来监听变化-但是通常你又必须显示的告诉系统什么时候发生的变化。
这样其实也还好,不过如果能提前都设置好,在发生变化时代码能够自动更新会不会感觉更加棒棒的?这就是响应式编程的基本思想:你的应用会针对底层数据的改变而做出相应的响应,而不需要你去直接告诉它。这样使得可以全心关注于逻辑处理而不需要维护一堆状态。
在Objective-C或Swift中这其实都可以实现,主要可以通过系统的KVO机制,在Swift中还可以使用didSet方法。然而实际效果你懂的。为了避免这些问题,现在在Objective-C和Swift上都有了一些比较成熟的框架,来实现响应式编程。
注意:如果想了解更多,强烈推荐你看看这篇文章ReactiveCocoa vs RxSwift,并且正文下面的评论也值得一读。
今天我们将使用其中一个框架,RxSwift,还有它的小伙伴RxCocoa,通过实现一个购买巧克力的App,学习如何从令人恼火的命令式编程过渡到优雅的响应式编程。
RxSwift & RxCocoa是什么?
RxSwift和RxCocoa是ReactiveX(通常被简称为“Rx”)语言工具全套的一部分,这个套件跨越了多种编程语言和平台。Rx起源于.Net/C#生态体系,不过现在已经非常受Ruby、JS、尤其是Java和Android开发人员的欢迎了。
RxSwift是一个用于与Swift语言交互的框架,而RxCocoa是一个通过应用响应式技巧来使得Cocoa的API更容易使用的框架。
Rx框架提供了一套通用的词汇,以方便在不同的语言中描述相同的任务。理论上,这使得你可以更加专注于语言本身的语法,而不会把时间浪费在将一个普通任务从一种语言转换到另一种新语言上。
Observables and Observers
本篇教程中需要知道的两个基本概念:Observable(被观察者)和Observer(观察者)。
- Observable是发出变化通知的对象;
- Observer是订阅了一个Observable的对象,以便在Observable变化时接收通知
当然可以同时有多个Observer监听同一个Observable。这意味着当Observable变化时,会通知到所有相关的Observer。
The DisposeBag
RxSwift和RxCocoa还有一个额外的工具来帮助处理ARC和内存管理,即:DisposeBag类。这是Observer对象的一个虚拟“包”,当它们的父对象被释放时,这个虚拟包也会被销毁。
当带有一个DisposeBag属性的对象调用析构函数时,虚拟包将会被清空,与此同时每一个可销毁的Observer会自动取消订阅它所观察的对象。这允许ARC可以像通常一样回收内存。
如果没有DisposeBag,就会产生两种结果:或者Observer会产生一个retain cycle,被永久的绑定到被观察对象上;或者意外地被释放时,导致程序崩溃。
所以为了更好的配合ARC,记得设置Observable对象时,一定要将它们添加到DisposeBag中。这样它们才能很好地被清理掉。
正式开始
让我们去买巧克力吧!首先下载本教程的初始工程Chocotastic。解压后用Xcode打开Chocotastic.xcworkspace文件。
构建并运行程序。你会看到以下效果,列出了几种你可以从欧洲购买到的巧克力,以及各自的价格:
随便点击一行,对应的巧克力将会添加到你的购物车中:
点击右上角,会进入到购物车界面,此时可以结账或者清空购物车:
如果点击结账按钮,将显示一个信用卡信息输入页面:
在本教程的后面,你将使用纯响应式编程来解决这些问题。先点击Cart按钮返回购物车页,然后点击Reset按钮返回主界面,同时购物车已被清空。
起点:非响应式
现在你已经知道了这个程序是干什么的了,来看看它是怎么做的。打开ChocolatesOfTheWorldViewController.swift文件,在这里你可以看到一些标准的UITableViewDelegate和UITableViewDataSource的实现方法。
还有一个updateCartButton()方法,用当前购物车中的巧克力数量来更新购物车按钮。这个方法在两个地方被调用:
在viewWillAppear(:)和 tableView(_:didSelectRowAt:)中。
这些都是以命令式的方法来修改数量:你必须显示调用方法来更新计数。
此时,你必须跟踪你要改变值的位置,不过当你使用响应式的方式来重写这些代码之后,无论数量是在哪或是怎样改变了,购物车按钮都会自动更新。
RxSwift:让购物车数量成响应式
所有引用购物车中物品的方法都是使用的一个ShoppingCart.sharedCart单例。打开ShoppingCart.swift文件,你能看到单例实例上一个变量的标准设置方式:
var chocolates = [Chocolate]()
现在,chocolates数组内容变化时不会被观察到。你可以在它的定义中添加一个didSet闭包,但它只有在整个数组变化时才会被调用,而不是它的任意一个元素发生改变。
幸运的是,RxSwift有一个解决方案。使用下面这行代码来替代原chocolates变量的创建:
let chocolates: Variable<[Chocolate]> = Variable([])
注意:如上修改之后会出现很多编译错误,稍后就会修改。
这种语法确实有点难理解,所以下面我们来慢慢了解到底发生了什么。
与将chocolates设置为一个包含Chocolate对象的数组不同,将其定义为一个RxSwift的Variable类型的变量,其中泛型类型指定为包含Chocolate对象的数组。
Variable是一个类,所以它使用引用语义–也就是说chocolates引用了一个Variable的实例。
Variable有一个value属性。这是你的Chocolate对象数组的实际存储位置。
Variable的神奇之处来自于一个叫做asObservable()方法。与过去你需要时刻手动检查值不同的是,现在你可以添加一个Observer来观察这个值,当值发生变化时,Observer会通知你,以便你可以对任何变化做出响应。
这样设置的缺点在于当你需要访问或改变chocolates数组中的元素时,你必须通过value属性,而不能直接使用它;这也就是为什么编译器会提示一堆错误。开始修改。
在ShoppingCart.swift中,找到totalCost()方法中下面这行:
return chocolates.reduce(0) {
改成:
return chocolates.value.reduce(0) {
在itemCountString()方法中,修改这行:
guard chocolates.count > 0 else {
改成:
guard chocolates.value.count > 0 else {
然后,
let setOfChocolates = Set<Chocolate>(chocolates)
改成:
let setOfChocolates = Set<Chocolate>(chocolates.value)
最后,
let count: Int = chocolates.reduce(0) {
改成:
let count: Int = chocolates.value.reduce(0) {
在CartViewController.swift中,找到reset()方法中下面这行:
ShoppingCart.sharedCart.chocolates = []
改成:
ShoppingCart.sharedCart.chocolates.value = []、
回到ChocolatesOfTheWorldViewController.swift中,修改updateCartButton()方法为:
cartButton.title = "\(ShoppingCart.sharedCart.chocolates.value.count) \u{1f36b}"
在tableView(_:didSelectRowAt:)方法中,修改
ShoppingCart.sharedCart.chocolates.append(chocolate)
为
ShoppingCart.sharedCart.chocolates.value.append(chocolate)
此时错误就全部被修复了,并且chocolates也可以被监听了。
在ChocolatesOfTheWorldViewController.swift中,添加如下一个属性
let disposeBag = DisposeBag()
这样就创建了一个DisposeBag对象,用于确保设置的Observer在deinit()中会被清理掉。
在 //MARK: Rx Setup 注释下面添加代码:
//MARK: Rx Setup
private func setupCartObserver() {
//1
ShoppingCart.sharedCart.chocolates.asObservable()
.subscribe(onNext: { //2
chocolates in
self.cartButton.title = "\(chocolates.count) \u{1f36b}"
})
.addDisposableTo(disposeBag) //3
}
这样设置了一个响应式的Observer来自动更新购物车。如你所见,RxSwift使用链式函数,也就是说每一个函数都接受前一个函数的结果。
来解释一下上面的代码:
1.首先,把购物车的chocolates变量设置为一个Observable。
2.在这个Observable上调用subscribe(onNext:)方法,以找到Observable的值的变化。subscribe(onNext:)接受一个每当值改变时就会执行的闭包作为参数。闭包的传入参数是Observable的新值。你将会持续接收到变更通知,直到你取消订阅或者你的订阅被丢弃。从这个方法得到的是一个遵守了Disposable的Observer对象;
3.将上一步得到的Observer对象添加到disposeBag中以确保在订阅对象被释放时你的订阅被丢弃。
最后,删除命令式的updateCartButton()方法。当然,这会导致调用这个方法的地方出现错误。
要修复它们,整个删除viewWillAppear(:)方法,在tableView(_:didSelectRowAt:)方法中删除updateCartButton()的调用。
运行一下,你会看到:
但是注意右上角购物车的按钮只显示了’Item’。当你点击了列表时,什么也没有发生。这是为啥呢?
因为刚才创建的方法还没有被调用,在viewDidLoad()里面添加如下代码:
setupCartObserver()
再次运行
点击任意一个巧克力-你会发现购物车中商品的数量自动更新了!
成功,现在所有的巧克力又都可以被添加到购物车中了。
RxCocoa:让TableView成响应式
目前为止,通过使用RxSwift你已经让购物车变成响应式了,现在来学习使用RxCocoa让UITableView也变成响应式。
RxCocoa扩展了UI元素以支持响应式API。这让你可以很方便的使用UITableView,而不需要直接重写delegate或data source的方法。
为了演示,删除UITableViewDataSource和UITableViewDelegate两个拓展以及相关的方法。然后,在viewDidLoad()方法中删除对tableView.dataSource和tableView.delegate的设置。
运行程序,你会看到啥也没有了:
一点也不好玩。现在来把巧克力找回来吧!
首先,为了获得一个响应式的table view,你需要一些让table view响应的东西。还是在ChocolatesOfTheWorldViewController.swift文件中,更新europeanChocolates属性,让其作为一个Observable对象:
let europeanChocolates = Observable.just(Chocolate.ofEurope)
just(_:)方法不会对Observable对象的底层值做任何修改,但你仍然需要以Observable值的方式来访问它。
有时,调用just(_:)意味着可能是过度地使用响应式编程了 – 毕竟,如果一个值从不改变,又何必使用响应式技术来监测它的变化呢?在本例中,你将使用它来设置将要改变的单元格的响应,不过经常思考如何使用Rx总是件好事。举个🌰,虽然你有一个锤子,但并不意味着每一个问题都是一个钉子。
现在你已经让europeanChocolates成为了一个Observable,添加以下代码:
private func setupCellConfiguration() {
//1
europeanChocolates
.bindTo(tableView
.rx //2
.items(cellIdentifier: ChocolateCell.Identifier,
cellType: ChocolateCell.self)) { // 3
row, chocolate, cell in
cell.configureWithChocolate(chocolate: chocolate) //4
}
.addDisposableTo(disposeBag) //5
}
稍微解释一下:
1.调用bindTo(_:)将europeanChocolates可观察对象关联到table view每一行都会执行的代码上;
2.调用rx,你可以访问任何类的RxCocoa扩展 – 在这里就是一个UITableView;
3.调用Rx的items(cellIdentifier:cellType:)方法,传入cell identifier及要使用的cell类型。这让Rx框架可以调用dequeuing方法;
4.对于列表的每一项传入一个闭包。闭包的参数包括行信息、行对应的chocolate对象以及当前cell,再去配置cell就非常简单了;
5.获取到bindTo(_:)返回的Disposable,然后添加到disposeBag中。
可以发现,通常是需要tableView的三个data source方法才能实现的效果现在一个方法就都做好了。在viewDidLoad()方法中添加一行来调用此方法:
setupCellConfiguration()
运行一下,会发现巧克力又回来了
然而,当点击了一个巧克力时,他们并没有被添加到购物车中。为何之前的Rx方法不起作用了呢?
其实不是,而是因为删除了tableView(_:didSelectRowAt:)方法。
为了修复这个问题,需要使用RxCocoa提供的另一个UITableView的扩展方法:modelSelected(_:),它返回一个你可以用来观察模型对象何时被选中的Observable。
添加以下方法:
private func setupCellTapHandling() {
tableView
.rx
.modelSelected(Chocolate.self) //1
.subscribe(onNext: { //2
chocolate in
ShoppingCart.sharedCart.chocolates.value.append(chocolate) //3
if let selectedRowIndexPath = self.tableView.indexPathForSelectedRow {
self.tableView.deselectRow(at: selectedRowIndexPath, animated: true)
} //4
})
.addDisposableTo(disposeBag) //5
}
详细说明一下:
1.调用table view的响应式拓展方法modelSelected(_:),传入Chocolate模型类型供后面使用。这个方法依然返回一个Observable;
2.获取到Observable后,调用subscribe(onNext:)方法,传入一个在模型被选中时会被调用的尾随闭包;
3.在尾随闭包中,将选中的巧克力添加到购物车中;
4.还是在闭包中,确保当前被点击的单元格选中状态被取消;
5.subscribe(onNext:)方法返回一个Disposable,添加这个Disposable到disposeBag中。
最后,在viewDidLoad()中添加一行来调用这个方法:
setupCellTapHandling()
运行一下,你会看到熟悉的巧克力列表:
而且现在你又可以往购物车中添加巧克力了!
RxSwift and Direct Text Input
RxSwift另一个非常有用的特性是它能够获取并响应用户输入的文本。
为了感受一下响应式的处理文本输入,你将在信用卡输入页面中添加一些简单的验证和卡类型检测。
在非响应式编程中信用卡输入的处理是通过一些UITextFieldDelegate的方法来实现的,通常每个方法中都包含大量的if/else语句,用于判断当前是哪个text field正在被编辑,从而决定应该执行什么操作和逻辑。
响应式编程直接将处理逻辑连接到每个text field上,是不是很方便。
在BillingInfoViewController.swift类中,在顶部添加如下代码:
private let disposeBag = DisposeBag()
和前面一样,首先定义一个DisposeBag。
一个非常有用的事情是,基于当前已知的卡片类型,当用户输入卡号时展示一下此信用卡的类型。
为了实现这一目的,在 //MARK: - Rx Setup下面添加以下代码:
//MARK: - Rx Setup
private func setupCardImageDisplay() {
cardType
.asObservable()
.subscribe(onNext: {
cardType in
self.creditCardImageView.image = cardType.image
})
.addDisposableTo(disposeBag)
}
稍后,你将使用这个方法来根据卡类型的改变去更新卡图片。它为变量的值添加了一个Observer,并附加一个在值改变时会执行的闭包,同时确保Observer被添加到disposeBag中了。
现在到了最有趣的部分:文本改变处理。
由于用户可能会快速输入,因此你可能不希望每次用户按键都去验证。这样会导致昂贵的计算甚至是UI卡顿。
一种更好的方式是限制验证的幅度,即只有一定的时间间隔后再去验证输入的内容。这样,再快的打字速度也不会阻塞整个程序的运行。
Throttling是RxSwift的一个特性。因为在一些东西改变时,通常有大量的逻辑操作。而使用Throttling特性,能够减少这些逻辑操作发生的次数。
首先,在BillingInfoViewController.swift中的其它属性声明下面添加如下代码:
private let throttleInterval = 0.1
这里以秒为单位为节流(throttle)长度定义了一个常量。
然后添加如下方法:
private func setupTextChangeHandling() {
let creditCardValid = creditCardNumberTextField
.rx
.text //1
.throttle(throttleInterval, scheduler: MainScheduler.instance) //2
.map { self.validate(cardText: $0) } //3
creditCardValid
.subscribe(onNext: { self.creditCardNumberTextField.valid = $0 }) //4
.addDisposableTo(disposeBag) //5
}
注意:如果在设置creditCardValid时得到一个”Generic parameter R could not be inferred“的编译错误,改成这样就可以解决:let creditCardValid: Observable。理论上,编译器能推导出它的类型,但偶尔还是需要一点帮助。
来看看上面代码做了什么:
1.text是另外一个RxCocoa的扩展(在使用之前必须先调用rx),这一次供UITextField使用。它将text field的内容作为一个Observable值返回;
2.节流输入,使得你设置的验证方式只在设置的时间间隔才运行。scheduler参数是一个更高级的概念,简单地说就是它绑定能到一个线程。因为你希望所有操作都是在主线程上执行,所以使用MainScheduler;
3.将被节流的输入值应用validate(cardText:)来转换它,此方法已经由当前类提供了。如果输入的卡号有效,则观察到的布尔值的最终值为true;
4.接受所创建的Observable值并订阅它,根据传入的值来更新text field的验证;
5.将生成的Disposable添加到disposeBag中。
将以下代码添加到setupTextChangeHandling()方法下面,以创建有效日期和卡片安全代码(CVV)的Observable变量:
let expirationValid = expirationDateTextField
.rx
.text
.throttle(throttleInterval, scheduler: MainScheduler.instance)
.map { self.validate(expirationDateText: $0) }
expirationValid
.subscribe(onNext: { self.expirationDateTextField.valid = $0 })
.addDisposableTo(disposeBag)
let cvvValid = cvvTextField
.rx
.text
.map { self.validate(cvvText: $0) }
cvvValid
.subscribe(onNext: { self.cvvTextField.valid = $0 })
.addDisposableTo(disposeBag)
现在你已经为三个text field的有效性设置了Observable值,添加如下代码:
let everythingValid = Observable
.combineLatest(creditCardValid, expirationValid, cvvValid) {
$0 && $1 && $2 //All must be true
}
everythingValid
.bindTo(purchaseButton.rx.enabled)
.addDisposableTo(disposeBag)
这里使用了Observable的combineLatest(_:)方法使用前面创建的三个Observable生成第四个,即everythingValid,其值是否为true取决于前面三个输入值是否都有效。
然后将everythingValid绑定到UIButton的响应式扩展的enabled属性上,这样购买按钮的状态就由everythingValid的值来控制了。
如果所有的输入都有效,那么everythingValid的内部值就为true。否则的话,为false。通过这种方式,购买按钮只有在信用卡详细信息有效时才可点击。
设置好了,接下来可以在viewDidLoad()方法中去调用了:
setupCardImageDisplay()
setupTextChangeHandling()
跑一下,要进入信用卡输入页面,需要至少选择一个巧克力将其添加到购物车,然后点击购物车按钮进入购物车界面。只要购物车中至少有一个巧克力,checkout按钮就是可用的:
点击Checkout按钮,将进入信用卡输入页面:
在卡号的text field中输入4–你将看到右侧表示卡片类型的图片显示了Visa:
删除4,卡片类型的图片将恢复成未知状态。输入55,图片将变成MasterCard:
赞。这个应用涵盖了美国四种主要的信用卡类型(Visa, MasterCard, American Express, Discover)。如果你有其中一种卡,你可以输入卡号来看看图片是否正确以及卡号是否有效。
如果你没有任何美国信用卡,你可以使用Paypal提供的测试卡卡号,这些应该可以通过程序的所有本地验证,即使卡号实际上是不可用的。
一旦输入有效的信用卡卡号,同时有效期和cvv也是有效的,那么Buy Chocolate!按钮将可以被点击:
点击按钮看下效果:
恭喜你!感谢RxSwift和RxCocoa,你可以随心所欲的买巧克力了。
何去何从
最终的代码可以在这里找到。
如果你想挑战一下,可以尝试将程序中的其他几个类也改成响应式:
修改CartViewController类,使用响应式的table view(而不用label)来展示购物车的内容;
允许用户直接从购物车中添加或删除巧克力,并能自动更新价格。
现在你已经稍微感受了下Rx编程,很有意思有木有?!以下是一些资源,以帮助你继续学习:
最后,我们的Marin Todorov有一个不错的博客rx_marin,里面有他学习响应式编程的一些心得。