以技术之名周报06#| ReactiveCocoa入门篇| 2020-05-10
Part-01 背景
作为一个iOS开发者,写的每一行代码都是对事件的反馈,像Button点击、网络请求、属性改变(KVO)、用户位置改变等。但是这些事件的处理采用的是Actions、delegate、KVO、回调等不同的方式。ReactiveCocoa针对不同的事件定义了的标准接口,这样不同的事件可以更容易链式调用、过滤、组合。
ReactiveCocoa组合了一对编程范式:函数式编程:使用更高级别的函数,该函数用其他函数作为他的参数。响应式编程:一种面向数据流和变化传播的声明式编程范式。所以,ReactiveCocoa也被称之为函数响应式编程框架。
ReactiveCocoa教程马上开始,接下来的教程侧重点在实用价值,所以给出的是实操而不是长篇大论。
Part-02 实践
ReactivePlayground
在接下来的整个ReactiveCocoa教程中,我们将通过ReactivePlayground这个应用来逐步引入响应式编程。下载ReactivePlayground工程,确保可以正常的build和run.
ReactivePlaygroun是一款非常简单的应用,主要就是给用户展示了一个登录的页面。输入用户的账号和密码,验证通过之后,进入到一个有一只可爱猫咪的页面。
打开工程,找到 RWViewController.m文件,你花费多久的时间可以找到Sign In Button变成enabing状态所需要的条件?展示/隐藏 signInFaulure Label的规则是什么? 也许,你花费两三分钟的时间就可以回答这些问题,但是当你面对更加复杂的项目的时候,你分析同样的问题可能就会花费相当长的时间。
这就是ReactiveCocoa的优势所在,ReactiveCocoa能够让应用程序的基本意图变得更加清晰。那让我们开始我们的工作吧!!!
添加ReactiveCocoa框架
最方便的方式就是通过CocoaPods.的方式导入框架。如果在此之前你没有使用过CocoaPods.,可以按照本网站的CocoaPods 入门教程进行操作,或者至少通过该教程的初始步骤进行操作,以便安装必备的组件。
如果因为某些原因你不想引入CocoaPods,你仍旧可以通过其他的方式使用ReactiveCocoa。可以参考Github上面的引入ReactiveCocoa文档,一步步操作。
打开终端,进入到下载工程的根目录,执行下面的指令创建Podfile文件;
1 | touch Podfile |
用文本编辑器打开Podfile文件,复制下面的代码到里面
1 | platform :ios, '7.0' |
这两句代码的意思是设置iOS平台且最小支持的版本是7.0、添加ReactiveCocoa框架作为依赖。保存文件,继续在终端执行下面的命令:
1 | pod install |
你会看到终端打印出类似下面的内容:
1 | Analyzing dependencies |
这意味着ReactiveCocoa framework已经被下载下来了,CocoaPods创建一个Xcode workspace文件将应用和framework整合起来了
开始实施
正如上面介绍中提到的,ReacticeCocoa为应用程序中发生的事件流提供了一个标准的接口。这种接口在ReactiveCocoa中称为信号(Signal),通过RACSignal
类来表示。
打开 RWViewController.m
文件,引入ReactiveCocoa头文件
1 | #import <ReactiveCocoa/ReactiveCocoa.h> |
暂时先不用替换任何现有代码,只需要做一些操作即可。添加下面的代码到viewDidload
方法:
1 | [self.usernameTextField.rac_textSignal subscribeNext:^(id x){ |
运行应用程序,在username textField中输入文字,查看控制台输出的内容:
1 | 2013-12-24 14:48:50.359 RWReactivePlayground[9193:a0b] i |
可以看到,每当改变textfield内容的时候,block中的代码都会执行。这里既没有target-action、也没有代理方法,仅仅用到了signals和block,令人激动万分!
ReactiveCocoa的 信号
(通过RACSignal表示)发送事件流给它的订阅者。主要是有三种类型的事件:next
、error
、completed
. 信号会因为error
或者complete
结束,但在结束之前可以发送任意数量的next
事件。
RACSignal
有多种方法用于订阅不同的事件类型,每种方法都一个或者多个block,事件发生的时候可以用来执行你想要的逻辑。比如:subscribeNext:
方法就提供了这样一个block,每当next
事件发生的时候,就会执行该block;
ReactiveCocoa框架通过类别
给标准的UIKit控件添加信号,因此你可以订阅这些控件的事件。这就是你可以在textfield上使用rac_textSignal
属性的原因。
ReactiveCocoa有大量的可以用来操纵事件流的操作符
。比如:你只对长度超过三个字符的用户名感兴趣,那么就可以使用 filter
操作符。将viewDidload中添加的代码更新为下面的代码:
1 | [[self.usernameTextField.rac_textSignal |
运行程序,然后在文本框继续输入字符,你会发现控制台在textfield的内容长度超过3的时候才打印:
1 | 2013-12-26 08:17:51.335 RWReactivePlayground[9654:a0b] is t |
其实,在这里你创建了一个非常简单的管道。这就是响应式编程的本质,通过数据流的方式来表达应用程序的功能。下面的图片看起来更为直观:
上图可以看到,rac_textSignal
是事件的初始来源,数据流通过一个filter
过滤,该filter
仅允许字符长度大于等于3的事件通过。该管道的最后一步是subscribeNext:
,在这一步可以通过block打印事件的值。
这里需要注意的一点是,filter
的返回值也是RACSignal
,可以通过下面的方式揭示管道的执行过程:
1 | RACSignal *usernameSourceSignal = |
因为在RACSignal
执行的每一种操作符返回还是RACSignal
,因此也被称为fluentinterface。该功能可以使你构造管道,而无需用局部变量引用每个步骤。
> Note: ReacticeCocoa使用了大量的blocks。如果你之前没有接触过block,你应该先看看Apple的Blocks Programming Topics,如果你像我一样对block很熟悉,但是对语法有点疑惑的话,可以访问http://fuckingblocksyntax.com/来巩固下你的知识。
####隐式转换
将之前的拆分的代码恢复成流式语法:
1 | [[self.usernameTextField.rac_textSignal |
上面指定位置代码的隐式转换不够优雅,因为传递给该block的值始终是NSString类型,所以可以直接更改参数类型本身,更新代码如下:
1 | [[self.usernameTextField.rac_textSignal |
运行代码,会发现和之前的效果一致。
什么是 【事件 】?
到目前为止,本教程已经描述了不同的事件类型,但是没有详细介绍这些事件的结构。有趣的是,事件可以包含任何东西!
为了证明这一点,向该管道添加另一种操作符,更新代码如下:
1 | [[[self.usernameTextField.rac_textSignal |
运行代码,发现控制台将会打印text field的内容长度而不是内容本身:
1 | 2013-12-26 12:06:54.566 RWReactivePlayground[10079:a0b] 4 |
新增的map
操作通过提供的block来转换事件数据流。对于接收到的每一个next
事件,它都会执行该block, 然后发出返回值,该返回值仍旧是一个next
事件。上面的代码中, map
操作符拿到NSString
类型的值,获得其长度,并将其转换成NSNumber
类型返回。
有关此功能的图形描述,可以看下面的图片:
如你所见,map
操作符之后的所有的步骤收到的都是NSNumber
实例。你可以使用map
操作符将收到数据转换成任意类型的对象。
Note: 上面代码中的text.length 返回的是NSInteger类型,它是一种基本类型。为了将其作为事件的内容来使用,必须将其进行包装。Objectice-C提供了一种简洁的字面量语法来进行此此操作@(texr.length)
现在应该使用所学的概念来更新ReactivePlayground的代码。
创建 Valid State Signals
首先要做的就是,创建一对信号来指示username
和password
是否是有效的。在RWViewController.m的viewDidload方法中添加如下代码:
1 | RACSignal *validUsernameSignal = |
如你所见,上面的代码将map
操作符应用到每一个textfield的rac_textSignal
,输出由Bool值封装的NSNumber对象。
接下来继续转换这些信号,以便它们能够为textfield提供合适的背景色。你通过订阅这些信号,拿到相应的值就可以更新textfield的背景色,一个可行的选择如下:
1 | [[validPasswordSignal |
先不要添加上面的代码,因为我们还有更加优雅的方式。
我们的目的是将信号的输出值赋值给textfield的background属性,但是上面的代码并不具备很好的表达性,赋值语句太过于靠后了。
幸运的是ReactiveCocoa有一个宏定义,允许你更加优雅的来表达这个功能。将下面的代码直接添加到viewDidload中两个signals的下方。
1 | RAC(self.passwordTextField, backgroundColor) = |
RAC
宏定义允许你将信号的输出赋值给对象的属性,它需要两个参数,第一个是包含要设置属性的对象。第二个是参数是属性名称。每次信号发出下一个事件时,传递的值都会赋值给给定的属性。
是不是非常优雅的解决方案?
最后一件要做的事情是移除洗面的代码
1 | self.usernameTextField.backgroundColor = self.usernameIsValid ? [UIColor clearColor] : [UIColor yellowColor]; |
通过下面的图像,可视化当前的逻辑。可以看到,这里有两个简单的管道,它们获取文本信号,然后通过map
将它们映射为有指示有效性的Bool值,然后再映射为UIColor,将UIcolor绑定到textfield 的backgroundcolor上。
你是否好奇,为什么创建了两个分离的信号validPasswordSignal
和validUsernameSignal
,而不是为每个textfield创建一个单一的流畅管道。保持耐心,这种疯狂背后的方法很快将变得清晰!!!
Combining signals (组合信号)
当前情况下, Sigin InButton只有在username和password输入框都有效的情况下才能点击,是时候通过响应式的方式来做这件事情了。
当前的代码已经有能发出boolean类型值的信号,来显示username和password是否是有效的:validUsernameSignal
和validPasswordSignal
。你要做的就是将这两种信号组合起来,以确定何时是该Button处于enable状态。
在viewDidload方法中添加下面的代码:
1 | RACSignal *signUpActiveSignal = [RACSignal combineLatest:@[validUsernameSignal, validPasswordSignal] |
上面的代码通过combineLatest:reduce:
方法组合validUsernameSignal
和validPasswordSignal
提交的最新值成为一个新的信号。每当两个信号中的任意一个提交了新值reduce
block都会执行,其返回值将成为组合信号
的下一个值。
RACSignal组合方法可以组合任意数量的信号,reduce block中的参数对应着每个
源信号
的值。ReacticeCocoa有一个小的实用类RACBlockTrampoline
,该类用来处理reduce block中的可变参数列表。
现在你就有了一个合适的信号,继续添加下面的代码到 viewDidload
方法的最后:
1 | [signUpActiveSignal subscribeNext:^(NSNumber *signupActive) { |
在运行程序之前,移除下面的属性和代码
1 | @property (nonatomic) BOOL passwordIsValid; |
同时也移除updateUIState
、usernameTextFieldChange
和passwordTextfieldChange
方法。最后确保移除viewDidload中对updateUIState
的调用。
运行程序,测试Sign In button,当username和password都是有效的时候,Sign In
Button应该也是enabled的。这个逻辑如下图:
上图揭示了一对非常重要的概念,这对概念可以让你用ReactiveCocoa执行非常强大的任务
- 拆分 - 信号可以有多个订阅者,并充当多个后续管道步骤的源。上图中,指示username和password有效性的boolean信号被拆分,并用于不同的目的。
- 组合 - 可以组合多个信号创建新的信号,上图只是组合的boolean信号,实际上你可以组合任意类型的信号。
这些更改的结果是不再需要指示两个textfield是否有效的私有属性,这是使用响应式的主要特征之一 : 不再需要实例变量来追踪瞬时状态。
Reactice Sign-In (响应式登录)
目前为止,只有管理textfield和button的状态使用到了响应式编程的方式,但是按钮的点击事件处理仍旧在使用action的方式,接下来要做的就是用响应式的方式来做替代action的方式。
Sign In
Button的点击事件通过storyboard action
的方式写在了RWViewController.m
的signInButtonTouched
中。我们要做的就是取代这种方式,所以第一步就是做的就是断开和storyboard action的关联。
打开Main.storyboard
,找到Sign In
Button,点击crtl
键打开outlet/action
连接,点击X移除连接,下图显示在哪里可以找到删除按钮:
你已经知道ReactiveCocoa 框架如何给UIKit标准控件添加属性和方法。在此之前,你是用的是rac_textSignal
,它在text改变的时候提交事件。为了处理事件,你需要另一种方法: rac_signalForControEvents
.
回到RWViewController.m
,在viewDidload方法最后添加如下代码:
1 | [[self.signInButton |
上面的代码从Button的UIControlEventTouchUpInside
事件创建了一个的信号,并添加订阅,以便每次该事件发生的时候都会打印日志。
运行程序,当username和password有效的时候,点击该button, 查看控制台输出日志:
1 | 2013-12-28 08:05:10.816 RWReactivePlayground[18203:a0b] button clicked |
现在按钮有了点击事件的信号,下一步是将此与登录过程本身关联起来。这带来一些问题,但是没关系,你并不介意这些问题,对吗? 打开RWDummySigInService.h
:
1 | typedef void (^RWSignInResponse)(BOOL); |
该接口将username
、password
和completeBlock
作为参数,当登录成功或者失败的时候执行completeBlock
。你可以直接在button的subscribeNext:
的block中调用该接口,为什么你能这么做呢?因为这种异步的、基于事件的行为,对ReactiveCocoa来说就是家常便饭。
Note: 为了简单期间,本教程使用的简单的虚拟服务,这样就不依赖任何外部的API。但是现在有一个非常现实的问题,怎么使用未用信号表示的API。
Creating Signals (创建信号)
幸运的是,将现有的异步API调整为信号相当容易。首先移除signInButtonTouched:
方法,该方法将被其他逻辑所取代。
在RWViewController.m
中添加下面的方法:
1 | -(RACSignal *)signInSignal { |
上面的方法创建了使用当前的username和password登录的信号,现在将其进行拆解:
上面的代码使用RACSignal
中的createSignal:
方法创建信号,该方法的参数是一个Block,用来描述该信号,且该Block只有一个参数。当信号有订阅者的时候,该Block中的代码将会执行。
传递给该Block的是一个subscriber
实例,该实例遵守RACSubscriber
协议,该协议中有你用来发出事件的方法,你可以发送任意个数的next
事件,这些事件会因为error
或者complete
事件结束。本教程中,将会发送一个next
事件来显示是否登录成功或者失败,然后发送 comlete
事件结束。
这个Block的返回值是一个 RACDisposable
对象,它允许你执行取消或者取消订阅的时候可能需要的任何清理工作。此信号因为没有任何清理需要,所以直接返回nil
;
正如你所看到的,在信号中包装异步的API非常的简单。
现在我们来利用这个信号,将下面的代码添加到viewDidload的最后
1 | [[[self.signInButton |
上面的代码通过map
操作符将登录信号转换为登录信号,订阅者只打印结果。
直接运行该代码,然后点击Sign In
Button,查看XCode的控制台打印,将会看到下面的结果,该结果也许和你想象的不太一样:
1 | 2014-01-08 21:00:25.919 RWReactivePlayground[33818:a0b] Sign in result: |
subscribeNext:
block传递的是整个信号,而不是登录信号的结果。
来看下这张图:
当你点击button的时候,rac_signallForControlEvents
发出一个next
事件,然后map
将创建并返回登录信号,这意味着下面的管道步骤接收的是RACSignal。这就是你在subscribeNext:
中观察到的内容。
上面中情况称为信号中的信号,换言之是包含内部信号的外部信号。如果你想的话,你可以在外部信号的 subscribeNext:
中订阅内部信号。但是这样做会导致嵌套混乱!幸运的是,这是一个常见的问题,ReactiveCocoa已经针对这种情况做好了准备;
信号中的信号
此问题的解决步骤非常的简单,只需将map
函数改成flattenMap
:
1 | [[[self.signInButton |
这段代码同样是将按钮的点击信号
映射为登录信号
,但是flattens
可以将事件从内部信号发送到外部信号。
运行代码,看一下Xcode的控制台:
1 | 2013-12-28 18:20:08.156 RWReactivePlayground[22993:a0b] Sign in result: 0 |
现在这个管道正在做的就是你想要的了,最后一步就是在subscribeNext
中添加相关的逻辑,以在登录成功之后执行所需的导航。
1 | [[[self.signInButton |
运行程序:
你是否注意到当前的应用程序存在一个小的用户体验的问题? 当登录服务验证提供的凭据时,应禁用登录按钮。这样可以防止用户重复相同的登录。此外,如果出现登录尝试失败,当用户再次尝试登录时,错误消息应隐藏。
但是,如何将此逻辑添加到当前管道?更改按钮的enable
状态不是transformation
、filter
或到目前为止遇到的任何其他概念。其实,它被称为副作用
或管道中next事件发生时执行的逻辑
,它实际上不会更改事件本身。
Adding side-effects(添加副作用)
用下面的代码取代当前的代码:
1 | [[[[self.signInButton |
你可以看到上面如何添加 doNext:
,在按钮触摸事件创建后立即向管道添加该步骤。请注意, doNext:
块没有返回值,因为它是副作用,它使事件本身保持不变。
上面的 doNext:
块将按钮的属性设置为 NO,并隐藏失败文本。当订阅Next:
块的时候重新启用按钮,并根据登录结果显示或隐藏失败文本。
运行应用程序以确认登录按钮按预期启用和禁用
现在,你的工作就完成了 – 应用程序现在完全是Reactive状态。
Note: 当异步事件正在进行的时候禁用button是一个常见的问题,如果ReacticeCocoa中遍布这种处理就会显得非常混乱。
RACCommand
封装了这个概念,并具有enabled
信号,允许您将Button的enable
属性连接到信号, 可能你需要尝试该类。
####结论
希望本教程已经给你一个良好的基础,这将有助于你在自己的应用程序中使用ReactiveCocoa框架。熟悉这些概念可能需要多加练习,但与任何语言或程序一样,一旦你找到它的窍门,它真的很简单。ReactiveCocoa的核心是信号,它们只不过是事件流。还有什么比这更简单的呢?
对于ReactiveCocoa,我发现的有趣的事情之一是有许多方法可以解决同样的问题。你可以通过这个应用程序练习,通过调整信号和管道,以更改它们拆分和组合的方式。
ReactiveCocoa的主要目标是使代码更简洁、更易于理解。就个人而言,我发现,如果应用程序的逻辑表示为清晰的管道,使用流畅的语法,则更容易理解应用程序的作用。
在本教程系列的第二部分中,你将了解更高级的主题,如错误处理以及如何管理在不同线程上执行的代码。
Part-03 原文
ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2