背景
在學習了RxSwift官方的demo以及各種操作符後,對RxSwift會有一個大緻的了解,但在實際開發過程中并不是有很多機會去使用,主要是因為使用生疏的開發技能會帶來開發時間上與産品品質上的風險,為了避免”不熟悉->不敢用->用的少->不熟悉->不敢用->用的少…”的惡性循環,個人覺得一種比較好的方法是在業餘時間選擇一些常見的功能使用RxSwift實作一遍,一方面加深對RxSwift的了解,另一方面,在實際項目中遇到類似的業務場景時,如果打算使用RxSwift的話則不再會心中沒底。
本文選擇了一個常見的注冊功能作為例子,采用RxSwift+MVVM的形式去實作。文中會詳細說明從需求分析到代碼實作過程中的每個步驟。
Ok, Let’s go.
Demo 項目位址
需求說明
提供一個注冊頁面,頁面中有三個輸入框,分别用于輸入
使用者名
,
密碼
,
确認密碼
。使用者在相應的輸入框内輸入時App需要對輸入的值進行校驗,校驗失敗時,需要在相應的輸入框下方提示失敗原因。
頁面中還有一個
注冊按鈕
,當
使用者名
,
密碼
,
确認密碼
校驗全部通過後,
注冊按鈕
啟用,使用者點選
注冊按鈕
,
App執行注冊操作,注冊成功後,則自動進行登入操作,登入成功後退出注冊頁面,如果注冊或登入操作有任何一個失敗,則給出提示。
使用者名校驗邏輯
- 長度不能小于6個字元
- 不能包含[email protected]#$%^&*()這些特殊字元
- 在使用者輸入後停頓時長達到0.5s時才進行校驗
- 在校驗中時,需要在輸入框下方展示 ‘校驗中…’,當校驗失敗時,需要在輸入框下方展示失敗原因
密碼校驗邏輯
- 長度不能小于6個字元
- 不能包含[email protected]#$%^&*()這些特殊字元
- 如果
輸入框有值,則需要與确認密碼
保持一緻确認密碼
- 在使用者輸入後停頓時長達到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(, scheduler: MainScheduler.instance),
psd: _psdTf.rx.value
.orEmpty
.asObservable()
.distinctUntilChanged()
.debounce(, scheduler: MainScheduler.instance),
confirmPsd: _confirmPsdTf.rx.value
.orEmpty
.asObservable()
.distinctUntilChanged()
.debounce(, 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: )
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: )
-
: ‘确認密碼驗證結果’輸出流。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: )
因為要進行比對,是以無論此時使用者是在輸入
密碼
或
确認密碼
,
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和響應式程式設計又是什麼關系?
吃飯與筷子的關系吧,你可以不用筷子吃飯,但是用筷子會更友善。