本文参考Raywenderlich的ReactiveCocoa Tutorial – The Definitive Introduction的教学模式来教大家。
Raywenderlich的示例代码使用OC写的,本文使用swift重构Raywenderlich的工程,带你走进ReactiveSwift(1.0.0-alpha.3)和ReactiveCocoa(5.0.0-alpha.3)的世界,教学之前先介绍下什么是ReactiveSwift和ReactiveCocoa。
ReactiveSwift
官方原文:
ReactiveSwift offers composable, declarative and flexible primitives that are built around the grand concept of streams of values over time. These primitives can be used to uniformly represent common Cocoa and generic programming patterns that are fundamentally an act of observation.Because all of these different mechanisms can be represented in the same way, it’s easy to declaratively chain and combine them together, with less spaghetti code and state to bridge the gap.
总的来说就是ReactiveSwift提供了可组合的、声明式的和灵活的基本类型。归结为以下6大类:
- 委托方法(Delegate methods)
- 回调函数块(Callback blocks)
- 通知(Notifications)
- 控制动作和响应者链事件(Control actions and responder chain events)
- 将来和承诺(Futures and promises)
- 键值观察(Key-value observing (KVO)
因为这些所有不同的机制都可以用同一种方式来表达,使用基本类型更容易地进行链式编程,通过把它们结合在一起,减少了套管程序(spaghetti code)。
ReactiveCocoa
官方原文:
ReactiveCocoa wraps various aspects of Cocoa frameworks with the declarative ReactiveSwift primitives.
ReactiveCocoa使用ReactiveSwift的基本类型来封装Cocoa框架的方方面面。
介绍完了ReactiveSwift和ReactiveCocoa,下面我们就开始学习吧。
The Reactive Playground
在学习ReactiveCocoa的过程中,先下载一个初始工程Reactive Playground,点击Reactive Playground可跳转到我的github,git clone到你的本地吧。
ReactivePlayground是一个非常简单的app,它的首页是一个登录的页面,输入用户名user和password密码就可以登录啦,登录成功之后是一只可爱的猫咪。
添加ReactiveCocoa框架
1、打开你的终端,cd到Reactive Playground,接着输入以下两条命令:
touch Podfile
open -e Podfile
在Podfile中输入:
platform :ios, '10.0'
use_frameworks!
target 'ReactivePlayground' do
pod 'ReactiveCocoa', '5.0.0-alpha.3'
end
保存并退出Podfile,继续在终端输入:
pod install
如果出现下图的文字,说明安装成功:
Show Time
如之前所提到的,ReactiveCocoa提供了标准的接口来处理一系列不同的事件,用ReactiveCocoa的术语来说这些接口就是信号(Signal)。
打开SignInViewController.swift,在顶部导入ReactiveCocoa框架:
import ReactiveCocoa
在viewDidLoad方法的底部添加:
usernameTextField.reactive.continuousTextValues.observeValues {
text in
print(text ?? "")
}
运行你的APP,在username text field输入,你在Xcode控制台会看到以下相似的输出:
i
is
is t
is th
is thi
is this
is this
is this m
is this ma
is this mai
is this maig
is this maigc
is this magic?
每次你在username text field输入,控制台都会输出,你没有设置target-action,也没有设置委托(delegate),在ReactiveCocoa的世界里只有信号和closure,兴奋吧!
ReactiveCocoa信号会发送一系列的事件给订阅者(观察者)。ReactiveCocoa有以下事件:
- Value事件:Value事件提供了新值
- Failed事件:Failed事件表明在信号完成之前发生了错误
- Completed事件:Completed事件信号完成了,之后不会再有新的值发送
- Interrupted事件:Interrupted事件表明由于被取消,信号被终止了
usernameTextField.reactive就是把usernameTextField变成可响应的,而continuousTextValues就是text值的信号。通过observeValues,我们可以观察到continuousTextValues这个信号传来的Value事件,每次在usernameTextField输入的时候,我们就会接收到text的值。
现在把上面的代码换成以下:
usernameTextField.reactive.continuousTextValues.filter({
text in
return text!.characters.count > 3
}).observeValues {
text in
print(text ?? "")
}
现在的输出为:
is t
is th
is thi
is this
is this
is this m
is this ma
is this mai
is this maig
is this maigc
is this magic?
filter这个函数只允许当它的返回值为true的事件发生,在这里是输入的字符数大于3个,也就是说字符数小于等于3的事件会被过滤掉。
什么是事件
上面我们提到了不同类型的事件,但是我们没有说明事件的细节,有兴趣的是事件里面可以包含任何事物!
作为示例,把上面的代码改成以下:
usernameTextField.reactive.continuousTextValues.map({
text in
return text!.characters.count
}).filter({
characterCount in
return characterCount > 3
}).observeValues {
characterCount in
print(characterCount ?? "")
}
运行你的APP,你会看到类似以下输出:
4
5
6
注意到我们新添加的map函数,给map函数提供一个closure,它就能够转换事件的数据。对于每一次map接收到的Value事件,它就会运行closure,以closure的返回值作为Value事件发送出去。上面的代码中,我们的text的值映射成text的字符数。
创建合法状态的信号
第一件你需要做的事是创建两个表明username text field和password text field合法的信号。在SignInViewController.swift的viewDidLoad方法的底部添加:
usernameTextField.reactive.continuousTextValues.map {
text in
return self.isValidUsername
}
passwordTextField.reactive.continuousTextValues.map {
text in
return self.isValidPassword
}
你可以看到,我们使用了map函数将text的值映射成Bool,这应该很浅显易懂。
下面继续,把上面的代码替换成:
let validUsernameSignal = usernameTextField.reactive.continuousTextValues.map({
text in
return self.isValidUsername
})
validUsernameSignal.map({
isValidUsername in
return isValidUsername ? UIColor.clear : UIColor.yellow
}).observeValues {
backgroundColor in
self.usernameTextField.backgroundColor = backgroundColor
}
let validPasswordSignal = passwordTextField.reactive.continuousTextValues.map({
text in
return self.isValidPassword
})
validPasswordSignal.map({
isValidPassword in
return isValidPassword ? UIColor.clear : UIColor.yellow
}).observeValues {
backgroundColor in
self.passwordTextField.backgroundColor = backgroundColor
}
同样的,我们使用map函数把Bool映射成UIColor,然后观察Value的值,根据Value事件传来的颜色来改变username text field和password text field的背景颜色。这样,当这些text field输入的字符小于3个,背景颜色就会高亮成黄色,表示不合法,当输入字符大于3个就会变成白色的,表示合法。
最后一步,找到updateUIState方法,删掉下面两行代码:
usernameTextField.backgroundColor = isValidUsername ? UIColor.clear : UIColor.yellow
passwordTextField.backgroundColor = isValidPassword ? UIColor.clear : UIColor.yellow
现在运行你的APP,你应该可以看到如上述所说的效果。
将多个信号结合在一起
我们希望的是登录按钮只有在username text field和password text field合法的时候才能被按下去。
在viewDidLoad方法的底部添加:
let signUpActiveSignal = Signal.combineLatest(validUsernameSignal, validPasswordSignal)
signUpActiveSignal.map({
(isValidUsername, isValidPassword) in
return isValidUsername && isValidPassword
}).observeValues {
signupActive in
self.signInButton.isEnabled = signupActive
}
上面的代码中我们用Signal(Signal是ReactiveSwift的基本类型,所以我们要import ReactiveSwift)的Signal.combineLatest方法将validUsernameSignal和validPasswordSignal两个信号结合在一起,再将它们映射成一个Bool信号来表明username text field和password text field是否同时合法。
之后,通过观察Value事件,我们将信号传过来的值赋值给signInButton。这样,signInButton的可用性就可以通过信号来控制了。
在运行之前,在viewDidLoad里面,删掉以下代码:
updateUIState()
// Handle text changes for both text fields.
usernameTextField.addTarget(self, action: #selector(SignInViewController.usernameTextFieldChanged), for: .editingChanged)
passwordTextField.addTarget(self, action: #selector(SignInViewController.passwordTextFieldChanged), for: .editingChanged)
还有SignInViewController.swift最下面的usernameTextFieldChanged、passwordTextFieldChanged和updateUIState三个方法。
现在运行你的APP,你讲看到当username text field和password text field是同时合法的,signInButton才可用。
在ReactiveSwift中,你可以做到更酷,把上面代码替换成:
signInButton.reactive.isEnabled <~ Signal.combineLatest(validUsernameSignal, validPasswordSignal).map { $0 && $1 }
一行搞定signInButton的可用性。顺便一提,<~操作符的左边应为遵循了BindingTarget的协议的类型,而右边是信号(Signal)的类型。
上面的代码展示了ReactiveCocoa的强大之处:
- 可分开的(Splitting):信号可用拥有多个订阅者(观察者),来作为后续步骤的信号源。注意到validUsernameSignal和validPasswordSignal是两个用来验证username text field和password text field分开的合法的信号,这两个信号有着不同的目的。
- 可结合的(Combining):多个信号可以结合在一起来创建一个新的信号。更值得兴奋的是,你可以结合任意类型的信号来创建新的信号。
可响应的登录
我们的APP使用了ReactiveCocoa来管理text fields和button,但是,从我们的代码可以看到我们在Storyboard使用了IBAction来处理Button的点击事件,所以下一步我们要做的是,改变剩下的代码,使它们全部变成可响应的。
把Storyboard的SignInButton的touchUpInside事件移除掉。返回到SignInViewController.swift,在viewDidLoad的底部添加:
let signInSignal = signInButton.reactive.trigger(for: .touchUpInside)
signInSignal.observeValues {
print("button clicked")
}
上述代码跟之前遇到的差不多,trigger会根据你想要的触发事件来创建信号,我们选择的是touchUpInside事件,所以当按钮被按下时,button clicked会被打印。
创建自定义的信号
首先在SignInViewController.swift添加:
private func createSignInSignal() -> Signal<Bool, NoError> {
let (signInSignal, observer) = Signal<Bool, NoError>.pipe()
self.signInService.signIn(withUsername: self.usernameTextField.text!, andPassword: self.passwordTextField.text!) {
success in
observer.send(value: success)
observer.sendCompleted()
}
return signInSignal
}
上面的代码使用Signal的pipe方法来创建信号,该方法返回一个(Signal<Value, Error: Swift.Error>)的元组。
我们可以通过向observer发送事件来控制pipe方法返回的信号,需要注意一点的是,信号会一直保持有效直到observer发送完成(completed)事件。
接着在viewDidLoad的底部添加:
signInButton.reactive.trigger(for: .touchUpInside).map({
self.createSignInSignal()
}).observeValues {
print("Sign in result: \($0)")
}
从上面的代码可以看出,当observer发送一个Value事件,我们通过观察信号来看到它的值。
运行你的APP,看看控制台的输出吧~
Sign in result: ReactiveSwift.Signal<Swift.Bool, Result.NoError>
信号中的信号
细心的读者会发现,我们的observer明明发送的是登录是否成功的Bool类型,为什么控制台的输出是信号的描述呢?
上面的问题可以描述为信号中的信号,也就是说一个外部的信号包含了内部的信号。把上面的代码替换成:
signInButton.reactive.trigger(for: .touchUpInside).flatMap(.latest) {
self.createSignInSignal()
}.observeValues {
success in
print("Sign in result: \(success)")
}
通过使用flatMap函数,结合flatten策略(.latest),我们保证了我们观察的值是内部的信号的值(也就是最新的值)。
运行你的APP,你会得到以下类似的输出:
Sign in result: 0
Sign in result: 1
棒极了!
现在我们来完善登录的逻辑,把代码替换成::
signInButton.reactive.trigger(for: .touchUpInside).flatMap(.latest) {
self.createSignInSignal()
}.observeValues {
success in
if success {
self.performSegue(withIdentifier: "signInSuccess", sender: self)
}
}
输入user和password登录,看到可爱的猫咪了吗!
在这里,你可以很快速的点击signInButton,但最终我们登录成功跳转到下一个页面只会发生一次,聪明的你应该想到了,对,我们使用了flatMap,并且flatten策略为.latest,保证我们接收到的信号是最新的。
但是我们目前的用户体验并不好,用户点击signInButton之后,signInService会验证用户的登录信息,在这段时间signInButton应该被禁用,防止用户重复登录(在实际项目中,点击signInButton会发起网络请求的),我们不想有一堆的登录网路请求发生。再者,当用户登录失败的时候,我们应该显示错误信息。
添加副作用(side-effects)
为了演示信号中的信号和如何处理信号中的信号,我们添加了一个名字为createSignInSignal的方法。对于添加副作用,我们引入一个新的类SignalProducer。把createSignInSignal方法替换为:
private func createSignInSignalProducer() -> SignalProducer<Bool, NoError> {
let (signInSignal, observer) = Signal<Bool, NoError>.pipe()
let signInSignalProducer = SignalProducer<Bool, NoError>(signal: signInSignal)
self.signInService.signIn(withUsername: self.usernameTextField.text!, andPassword: self.passwordTextField.text!) {
success in
observer.send(value: success)
observer.sendCompleted()
}
return signInSignalProducer
}
createSignInSignalProducer总体上与createSignInSignal相似,只是多了下面这一行
let signInSignalProducer = SignalProducer<Bool, NoError>(signal: signInSignal)
返回值为:
SignalProducer<Bool, NoError>
SignalProducer是用来创建信号还有执行副作用的,执行副作用这一点是信号做不到的,后面我们会看到SignalProducer是如何执行副作用的。
把下面代码:
signInButton.reactive.trigger(for: .touchUpInside).flatMap(.latest) {
self.createSignInSignal()
}.observeValues {
success in
if success {
self.performSegue(withIdentifier: "signInSuccess", sender: self)
}
}
替换为:
let signalProducer = SignalProducer<Void, NoError>(
signal: signInButton.reactive.trigger(for: .touchUpInside)
).on(
starting: nil, started: nil,
event: {
_ in
self.signInButton.isEnabled = false
self.signInFailureTextLabel.isHidden = true
},
value: nil, failed: nil, completed: nil, interrupted: nil, terminated: nil, disposed: nil
)
signalProducer.flatMap(.latest, transform: {
self.createSignInSignalProducer()
}).startWithValues {
success in
self.signInButton.isEnabled = true
self.signInFailureTextLabel.isHidden = success
if success {
self.performSegue(withIdentifier: "signInSuccess", sender: self)
}
}
signalProducer.start()
上面有几个我们不熟悉的方法,不要怕,我们一步一步来剖析。
之前我们说到SignalProducer的其中一个作用是用来执行副作用的,而它就是通过on方法来注入副作用的。
on方法里面的各个参数表示的是每个生产者事件(producer events),每个参数接受的类型都是closure,通过给它们提供closure,当接收到相应的事件,closure就会执行,on方法返回的是被注入了副作用的SignalProducer。
我们用signInButton.reactive.trigger(for: .touchUpInside)这个信号来初始化SignalProducer,并在event事件注入了副作用,所以当signInButton被按下时,signInButton.reactive.trigger(for: .touchUpInside)这个信号就会"告诉"signalProducer,之后signalProducer就会接收到各个事件(这些事件是有顺序的,想知道具体的顺序可以在各个事件print一下),当接收到event事件时,下面两行代码就会被执行:
self.signInButton.isEnabled = false
self.signInFailureTextLabel.isHidden = true
SignalProducer和Signal差不多,也有map、flatMap和filter等这些方法。
接下来我们flatMap中调用了createSignInSignalProducer方法,创建了一个SignalProducer<Bool, NoError>。随后我们调用了startWithValues,每当接收到Value事件,将会调用startWithValues的closure。
注意这里是SignalProducer中的SignalProducer,所以我们要用flatMap,不然startWithValues中的closure的参数将会变成SignalProducer<Bool, NoError>,也就是我们调用createSignInSignalProducer后返回的类型。
非常重要的一点是,当你注入了副作用后,一定要在on返回的SignalProducer调用start方法,这个方法告诉SignalProducer开始工作,否则不会执行任何副作用。
好了,运行你的APP,我们结合界面和代码一起分析一下。
在两个文本框随便输入大于3个字符,按下signInButton。此时,我们的signalProducer将会接收到Event事件,随后执行我们在on方法注入的副作用,禁用了signInButton还有隐藏了signInFailureTextLabel。
接着,在createSignInSignalProducer方法中执行了登录,短暂的延迟之后(模拟网络请求),会发出一个登录成功与否的信号,startWithValues就会收到Value事件,从而进行下一步的逻辑。
在这里,我们启用了signInButton并根据接收到的success来决定signInFailureTextLabel的隐藏与否,如果登录成功将会呈现一只可爱的猫咪哦。
觉得上面的代码很冗长且不易理解?自动手动启用/禁用signInButton很麻烦?没关系,ReactiveSwift提供了更优雅的写法。
在viewDidLoad方法删掉:
signInButton.reactive.isEnabled <~ Signal.combineLatest(
validUsernameSignal, validPasswordSignal
).map {
$0 && $1
}
接着在底部添加:
let signUpActiveSignal = Signal.combineLatest(validUsernameSignal, validPasswordSignal).map {
(isValidUsername, isValidPassword) in
return isValidUsername && isValidPassword
}
let signInButtonEnabledProperty = Property(initial: false, then: signUpActiveSignal)
let action = Action<(String, String), Bool, NoError>(enabledIf: signInButtonEnabledProperty) {
(username, password) in
return self.createSignInSignalProducer(withUsername: username, andPassword: password)
}
action.values.observeValues {
success in
self.signInFailureTextLabel.isHidden = success
if success {
self.performSegue(withIdentifier: "signInSuccess", sender: self)
}
}
signInButton.reactive.pressed = CocoaAction<UIButton>(action) {
_ in
(self.usernameTextField.text!, self.passwordTextField.text!)
}
上面代码新出现了几个ReactiveSwift的基本类型如:Property、Action。
Property(initial: false, then: signUpActiveSignal)
Property首先接收一个初始的值,我们设置成false,之后这个property会随着signUpActiveSignal里面的值变化而变化。
Action是一个泛型为Action<Input, Output, SwiftError>,Action就是动作的意思,比如当用户点击了signInButton后应该发生的动作。Action可以有输入和输出,也可以没有。
上述代码中,我们的输入来自username text field和password text field,输出为Bool(登录是否成功)。
enabledIf这个参数是一个信号,这个信号用来控制signInButton的启用/禁用。后面一个参数是一个closure,它的原型为(Input) -> SignalProducer<Output, Error>),这个closure的Input来自于我们的username text field和password text field的值。
我们还需要为它返回一个SignalProducer<Output, Error>以便于添加副作用,通过action.values.observeValues我们可以观察到Value事件,也就是我们登录成功与否的值。
最后,signInButton.reactive.pressed是一个CocoaAction,当用户点击了signInButton就会触发这个CocoaAction,CocoaAction同时会帮你控制signInButton的启用/禁用状态。
记住实例化CocoaAction的时候一定要用,下面的实例化方法:
CocoaAction<UIButton>(action: Action<Input, Output, Error>, inputTransform: (UIButton) -> Input)
这样,CocoaAction传递给Action的Input才是动态的,也就是username text field和password text field当前的值。
如果用下面的实例化方法:
CocoaAction<UIButton>(action: Action<Input, Output, Error>, input: (usernameTextField.text!, passwordTextField.text!))
这样的Input是常数,当APP启动后,username text field和password text field的值是空的,也就是说CocoaAction传递给Action的Input一直是空值。
现在运行你的APP,你可以看到ReactiveCocoa 帮我们自动地启用和禁用signInButton,上面代码没有一句signInButton.isEnabled = true/false的代码,怎么样,神奇吧!
总结
ReactiveSwift和ReactiveCocoa还有很多强大的地方,有兴趣的读者可以都官方查看文档,希望本次的教程能帮你学习基础的ReactiveSwift和ReactiveCocoa!
你可以在我的github下载添加了ReactiveCocoa的完整工程啦ReactivePlayground-Final!
记住进入工程pod install!