零基础上手ReactiveSwift & ReactiveCocoa教程

本文参考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的代码,怎么样,神奇吧!

总结

ReactiveSwiftReactiveCocoa还有很多强大的地方,有兴趣的读者可以都官方查看文档,希望本次的教程能帮你学习基础的ReactiveSwift和ReactiveCocoa!

你可以在我的github下载添加了ReactiveCocoa的完整工程啦ReactivePlayground-Final

记住进入工程pod install!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容