RxSwift MVVM實操-從一個註冊demo說起
背景
在學習了RxSwift官方的demo以及各種操作符後,對RxSwift會有一個大致的瞭解,但在實際開發過程中並不是有很多機會去使用,主要是因為使用生疏的開發技能會帶來開發時間上與產品質量上的風險,為了避免”不熟悉->不敢用->用的少->不熟悉->不敢用->用的少…”的惡性迴圈,個人覺得一種比較好的方法是在業餘時間選擇一些常見的功能使用RxSwift實現一遍,一方面加深對RxSwift的理解,另一方面,在實際專案中遇到類似的業務場景時,如果打算使用RxSwift的話則不再會心中沒底。
本文選擇了一個常見的註冊功能作為例子,採用RxSwift+MVVM的形式去實現。文中會詳細說明從需求分析到程式碼實現過程中的每個步驟。
Ok, Let’s go.
需求說明
提供一個註冊頁面,頁面中有三個輸入框,分別用於輸入使用者名稱
,密碼
,確認密碼
。使用者在相應的輸入框內輸入時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(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和響應式程式設計又是什麼關係?
吃飯與筷子的關係吧,你可以不用筷子吃飯,但是用筷子會更方便。