背景
在学习了RxSwift官方的demo以及各种操作符后,对RxSwift会有一个大致的了解,但在实际开发过程中并不是有很多机会去使用,主要是因为使用生疏的开发技能会带来开发时间上与产品质量上的风险,为了避免”不熟悉->不敢用->用的少->不熟悉->不敢用->用的少…”的恶性循环,个人觉得一种比较好的方法是在业余时间选择一些常见的功能使用RxSwift实现一遍,一方面加深对RxSwift的理解,另一方面,在实际项目中遇到类似的业务场景时,如果打算使用RxSwift的话则不再会心中没底。
本文选择了一个常见的注册功能作为例子,采用RxSwift+MVVM的形式去实现。文中会详细说明从需求分析到代码实现过程中的每个步骤。
Ok, Let’s go.
需求说明
提供一个注册页面,页面中有三个输入框,分别用于输入用户名
,密码
,确认密码
。用户在相应的输入框内输入时App需要对输入的值进行校验,校验失败时,需要在相应的输入框下方提示失败原因。
页面中还有一个注册按钮
,当用户名
,密码
,确认密码
校验全部通过后,注册按钮
启用,用户点击注册按钮
,
App执行注册操作,注册成功后,则自动进行登录操作,登录成功后退出注册页面,如果注册或登录操作有任何一个失败,则给出提示。
用户名校验逻辑
- 长度不能小于6个字符
- 不能包含!@#$%^&*()这些特殊字符
- 在用户输入后停顿时长达到0.5s时才进行校验
- 在校验中时,需要在输入框下方展示 ‘校验中…’,当校验失败时,需要在输入框下方展示失败原因
密码校验逻辑
- 长度不能小于6个字符
- 不能包含!@#$%^&*()这些特殊字符
- 如果
确认密码
输入框有值,则需要与确认密码
保持一致 - 在用户输入后停顿时长达到0.5s时才进行校验
- 在校验中时,需要在输入框下方展示 ‘校验中…’,当校验失败时,需要在输入框下方展示失败原因
确认密码校验逻辑
- 必须与
密码
输入框的值相同,如果密码
输入框没有内容则不进行此项校验 - 在用户输入后停顿时长达到0.5s时才进行校验
- 在校验中时,需要在输入框下方展示 ‘校验中…’,当校验失败时,需要在输入框下方展示失败原因
界面UI
MVVM 各层功能划分
View
- 提供注册界面UI,包含3个输入框以及
注册
按钮。 - 接受用户输入和按钮点击事件并将其传递给
ViewModel
。 - 处理
ViewModel
反馈的信息。
ViewModel
接收并处理View
中用户的输入以及按钮点击事件,并将结果反馈给 View
层
Model
业务服务层,提供用户名校验、密码校验、注册、登录服务。
数据流分析
在MVVM中,ViewModel扮演着一个非常重要的角色,其作为View与Model之间的纽带,接收View传递过来的事件,然后调用Model中的服务进行处理,最后将处理结果反馈至View,View再根据反馈信息进行处理。简单点说,ViewModel实现了View与Model之间的双向绑定。在实际开发过程中,最重要的就是要分析出View中有哪些事件流需要处理,ViewModel中又会有哪些输出流需要View处理,不管是view中产生的事件还是ViewModel产生的处理结果,最终都是抽象成数据流, 下面是针对当前demo的一个数据流的分析:
sequenceDiagram
View->>ViewModel: '用户名' 用户输入数据流
View->>ViewModel: '密码' 用户输入数据流
View->>ViewModel: '确认密码' 用户输入数据流
View->>ViewModel: '注册按钮' 用户点击事件流
ViewModel->>View: '用户名'验证结果输出流
ViewModel->>View: '密码'验证结果输出流
ViewModel->>View: '确认密码'验证结果输出流
ViewModel->>View: '登录'结果输出流
ViewModel->>View: '注册按钮'可用性输出流
==注:上图中的’输入’与’输出’是相对于ViewModel而言的==
开始编码
首先,根据MVVM 各层功能划分,定义下面几个Class
:
- SignUpVC 注册页面(View)
- SignUpVM 注册页面的 ViewModel
- SignUpService 注册相关服务(Model)
SignUpService
: 负责提供业务服务接口,当前提供的接口如下:
class SignUpService{
//校验'用户名'
static func validateUsername(username: String) -> Observable<ValidateResult>
//校验'密码'
static func validatePsd(username: String) -> Observable<ValidateResult>
//执行注册操作
static func signUp(username: String, psd: String) -> Observable<Bool>
//执行登录操作
static func signIn(username: String, psd: String) -> Observable<Bool>
}
==注:异步操作的结果在Rx中都是可以用数据流表示的,因为异步操作的结果就和数据流中的数据一样,是不定时的产生的。只是有的异步操作只有会产生一个结果就结束,比如网络请求,而有的异步操作则会持续不断输出结果,比入网络状态监听。==
其中,ValidateResult
定义如下:
enum ValidateFailReason{
case emptyInput
case other(String)
}
enum ValidateResult {
case validating
case ok
case failed(ValidateFailReason)
var isOk: Bool {
if case ValidateResult.ok = self {
return true
}else{
return false
}
}
}
**接着根据数据流向分析,我们
在SignUpVM
中定义输入数据流与输出数据流:**
class SignUpVM {
struct Input {
//'用户名'输入流
let username: Observable<String>
//'密码'输入流
let psd: Observable<String>
//'确认密码'输入流
let confirmPsd: Observable<String>
//'注册按钮点击事件'输入流
let signUpBtnTaps: Observable<Void>
}
struct Output {
//'用户名验证结果'输出流
var usernameValidateResult: Observable<ValidateResult>!
//'密码验证结果'输出流
var psdValidateResult: Observable<ValidateResult>!
//'确认密码验证结果'输出流
var confirmPsdValidateResult: Observable<ValidateResult>!
//'注册按钮enable设置'输出流
var signUpEnable: Observable<Bool>!
//'登录结果'输出流
var signInResult: Observable<Bool>!
}
}
==注:下面代码中的output
和input
分别表示struct Output
和struct Input
的实例==
这里说明一下每个输入数据流中的数据是什么:
- input.username
‘用户名’输入流: 用户在’用户名’输入框输入的字符串
- input.psd
‘密码’输入流: 用户在’密码’输入框输入的字符串
- input.confirmPsd
‘确认密码’输入流: 用户在’确认密码’输入框输入的字符串
- input.signUpBtnTaps
‘注册按钮点击事件’输入流: ‘注册按钮’点击事件
然后我们看一下在SignUpVC中每个输入流是如何产生的,下面列出了SignUpVC中SignUpVM.Input初始化代码片段:
==注:SignUpVC是MVVM中的View层,负责将View中产生的事件传递给相应的ViewModel。==
SignUpVM.Input(
username: _usernameTf.rx.value
.orEmpty
.asObservable()
.distinctUntilChanged()
.debounce(0.5, scheduler: MainScheduler.instance),
psd: _psdTf.rx.value
.orEmpty
.asObservable()
.distinctUntilChanged()
.debounce(0.5, scheduler: MainScheduler.instance),
confirmPsd: _confirmPsdTf.rx.value
.orEmpty
.asObservable()
.distinctUntilChanged()
.debounce(0.5, scheduler: MainScheduler.instance),
signUpBtnTaps: _signUpBtn.rx.tap.asObservable()
))
代码有点长,但是很简单,我们一个个看,首先看username
参数的值, 它的构成有点长,我们一段段分析:_usernameTf.rx.value
表示’用户名输入框’的值所组成的数据流,当输入框内容变化、失去焦点、初始化时其值会被数据流发射。orEmpty
是为了将_usernameTf.rx.value
中的nil
转换空字符串,distinctUntilChanged
则是为了过滤掉与上一次相同的值。debounce
作用是当用户在输入框内输入字符后0.5内未再有输入,则此时输入框内的值会被数据流中发射。简单点说其作用就是为了在用户输入时降低实时校验的频率。
psd
、 confirmPsd
与username
类似,此处就不多说了。
signUpBtnTaps
参数的值很简单:_signUpBtn.rx.tap.asObservable()
表示点击事件数据流。
接着我们分析一下SignUpVM
中每个输出流是如何定义的:
==注:ViewModel在接收到View的数据流后,会去执行一些业务逻辑,
产生的结果则会做为输出流再传递给View,用一个函数表达式表示就是:
outputStream = f (inputStream)==
output.usernameValidateResult
: ‘用户名验证结果’输出流。
当SignUpVM
接收到 input.username(‘用户名’输入数据流)的数据时,SignUpVM
会调用SignUpService
提供的接口进行验证,验证结果作为output.usernameValidateResult
输出数据流的数据,代码如下:
output.usernameValidateResult = input.username
.flatMapLatest { (username) -> Observable<ValidateResult> in
return SignUpService.validateUsername(username: username)
}
.share(replay: 1)
flatMapLatest
可以简单的理解其作用为: 用’链式调用’的形式串联异步操作。这样就无需通过回调嵌套的形式处理异步操作的串联。
图解:
首先看下如果用 flatmap 是什么结果:
下面则是 flatmapLatest的:
由上图可见,flatMap不管校验结果什么时候返回,都会被output.usernameValidateResult
数据流发射,若是flatMapLatest,output.usernameValidateResult
只会将最近一次的校验结果发射。’最近一次’是指触发校验行为时,如果先前的校验行为还未产生结果,那么先前的校验行为的结果将会被丢弃,只有此次触发的校验行为的结果才有机会被output.usernameValidateResult
输出(当然,如果下次校验行为触发时,此次校验行为还未完成,那么此次校验行为的结果则同样会被丢弃,以此往复)。
output.psdValidateResult
与output.usernameValidateResult
类似,这里就直接列出代码:
output.psdValidateResult = input.psd
.flatMapLatest { (psd) -> Observable<ValidateResult> in
return SignUpService.validatePsd(psd: psd)
}
.share(replay: 1)
confirmPsdValidateResult
: ‘确认密码验证结果’输出流。
SignUpVM
会在用户输入密码
或确认密码
时对两个密码进行比对,一致则通过校验,不一致则校验失败,代码如下:
output.confirmPsdValidateResult = Observable<ValidateResult>
.combineLatest(input.psd,
input.confirmPsd,
resultSelector: { (psd: String, confirmPsd: String) -> ValidateResult in
if(psd.isEmpty || confirmPsd.isEmpty){
return .failed(.emptyInput)
}else if(psd != confirmPsd){
return .failed(.other("两次密码不一致"))
}else{
return .ok
}
})
.share(replay: 1)
因为要进行比对,所以无论此时用户是在输入密码
或确认密码
,SignUpVM
都需要能够从相应的输入流(input.psd
和input.confirmPsd
)中获取这个两个字段当前最新的值,
combineLatest
操作符则是实现该目的的关键,该操作符会从指定的多个数据流中获取最近一次发射的数据,将这些数据传递给一个回调函数,该函数进行处理并返回一个值,这个值会被combineLatest
所生成的那个数据流发射。
combineLatest图解
- output.signUpEnable
:’注册按钮enable设置’输出流。
当用户名
,密码
,确认密码
都通过验证后,该数据流发射’启用’信号,反之则发射’禁用’信号,代码如下:
output.signUpEnable = Observable<Bool>
.combineLatest(output.usernameValidateResult,
output.psdValidateResult,
output.confirmPsdValidateResult,
resultSelector: { (
usernameValidateResult: ValidateResult,
psdValidateResult: ValidateResult,
confirmPsdValidateResult: ValidateResult) -> Bool in
return usernameValidateResult.isOk
&& psdValidateResult.isOk
&& confirmPsdValidateResult.isOk
})
一眼看上去有点复杂,我们仔细分析一下:启用/禁用’注册’按钮是取决于用户名
,密码
,确认密码
的验证结果的,这三个字段验的证结果数据流已经定义过了,所以此处只需要用combineLatest
将三者组合,当任何一个字段有验证结果产生时,则会进行一次判断以决定启用或禁用’注册’按钮。
signInResult
:’登录结果’输出流。
struct UsernameAndPsd{
let username: String
let psd: String
}
let usernameAndPsdSeq: Observable<UsernameAndPsd> = Observable.combineLatest(input.username, input.psd) { (username, psd) -> UsernameAndPsd in
return UsernameAndPsd(username: username, psd: psd)
}
output.signInResult = input.signUpBtnTaps
.withLatestFrom(usernameAndPsdSeq)
.flatMapLatest {(unamePsd: UsernameAndPsd) -> Observable<(Bool,UsernameAndPsd)> in
return SignUpService.signUp(username: unamePsd.username,
psd: unamePsd.psd)
.map{ (isSignSuccess) -> (Bool,UsernameAndPsd) in
return (isSignSuccess, UsernameAndPsd(username: unamePsd.username,psd: unamePsd.psd))
}
}.flatMapLatest{ (e: (isSignUpSuccess: Bool,unameAndPsd: UsernameAndPsd )) -> Observable<Bool> in
if e.isSignUpSuccess{
return SignUpService.signIn(username: e.unameAndPsd.username, psd: e.unameAndPsd.psd)
}else{
return Observable<Bool>.of(false)
}
}
恩,又是一大串,但实际上逻辑是比较清晰的,分析前先说一下UsernameAndPsd
,很简单,它就是为了方便同时传递username 和 psd而做的一个封装。
下面分析逻辑:’注册’按钮被点击后,SignUpVM
通过input.signUpBtnTaps
拿到点击事件,之后则是要进行’注册’操作,我们看到,’注册’的接口是需要用户名
和密码
的,而input.signUpBtnTaps
传递点击的事件并不带有任何上下文信息,所以通过input.signUpBtnTaps
是无法拿到当前界面上用户输入的用户名
和密码
,但是SignUpVM
中是拥有用户名
和密码
的输入流的,所以还是那个套路,使用combineLatest
将用户名
和密码
输入流组合一下,然后每次当用户名
和密码
的输入流产生数据时都会被发送一份到那个组合数据流中,这样,’注册’按钮点击事件发生时,我们就可以去那个组合数据流中拿用户名
和密码
,而'拿'
这个操作则是由withLatestFrom
操作符实现。
目前为止,我们完成了’注册’按钮点击事件处理以及获取用户名
和密码
这两个步骤,接下来则是使用获取到的用户名
和密码
进行注册,注册接口是个异步操作,所以使用flatMapLatest
进行串联。接着进行’登录’操作,注意,在登录操作前对’注册’操作是否成功进行了判断,成功才会继续执行’登录‘,失败则直接抛出错误信号。
至此,整个注册功能的核心已经完成,需要注意的是,SignUpVM
初始化时的这一系列输入流到输出流的转换,并没有产生任何side effect,我们只是定义了该如何转换,换句话说,我们只是定义了 outputStream = **f** (inputStream)
, 而 f
则是需要等到inputStream有数据产生时才会执行。
总结
在用响应式的思维实现业务时需要关注三个点:
- 要处理哪些事件(比如:网络状态变更、页面滚动、页面关闭、动画执行完毕、接口请求出错等等)
- 怎么处理
- 处理结果怎么返回
以上三点分别抽象为:输入流(要处理的事件),变换函数(怎么处理),输出流(处理结果)。
所以归根结底,响应式编程就是面向数据流的编程。
难点在于,在开始编码前就需要能够根据业务需求精确的分析出各种数据流,对于不熟悉的业务场景,确实难以下手。
再谈RxSwift,其实RxSwift本质上就一个功能—-回调,但是通过采用’订阅数据流’的形式,能够将回调行为衍生出的各种复杂问题以一种可视化的符合人类思维逻辑的形式进行解决。
RxSwift和响应式编程又是什么关系?
吃饭与筷子的关系吧,你可以不用筷子吃饭,但是用筷子会更方便。