RxSwift 實戰操作【註冊登錄】
前言
看了前面的文章,相信很多同學還不知道RxSwift
該怎麽使用,這篇文件將帶領大家一起寫一個 註冊登錄(ps:本例子采用MVVM
)的例子進行實戰。本篇文章是基於RxSwift3.0
寫的,采用的是Carthage
第三方管理工具導入的RxSwift3.0
,關於Carthage
的安裝和使用,請參考Carthage的安裝和使用。
最終效果
下載Demo點我
前提準備
首先請大家新建一個swift
工程,然後把RxSwift
引入到項目中,然後能夠編譯成功就行。
然後我們來分析下各個界面的需求:
註冊界面需求:
- 輸入用戶名必須大於等於6個字符,不然密碼不能輸入;
- 密碼必須大於等於6個字符,不然重復密碼不能輸入;
- 重復密碼和密碼必須一樣, 不能註冊按鈕不能點擊;
- 點擊註冊按鈕,提示註冊成功或者註冊失敗;
- 註冊成功會寫進本地的plist文件,然後輸入用戶名會檢測該用戶名是否已註冊
登錄界面需求:
- 點擊輸入用戶名,檢測是否已存在,如果存在,戶名可用,否則提示用戶名不存在;
- 輸入密碼,點擊登錄,如果密碼錯則提示密碼錯誤,否則進入列表界面,提示登錄成功。
列表界面需求:
- 輸入聯系人的首字母進行篩選
好了,分析完上面的需求之後,是時候展示真正的技術了,let‘s go。
註冊界面
大家現在storyboard
中建立出下面這個樣子的界面(ps:添加約束不在本篇範圍內):
創建對應的文件
然後建立一個對應的控制器RegisterViewController
RegisterViewModel.swift
,將RegisterViewController
與storyboard
中的控制器關聯,RegisterViewController
看起來應該是這樣子的:
class RegisterViewController: UIViewController { @IBOutlet weak var userNameTextField: UITextField! @IBOutlet weak var nameLabel: UILabel! @IBOutlet weak var pwdTextField: UITextField! @IBOutlet weak var pwdLabel: UILabel! @IBOutlet weak var rePwdTextField: UITextField! @IBOutlet weak var rePwdLabel: UILabel! @IBOutlet weak var registButton: UIButton! @IBOutlet weak var loginButton: UIBarButtonItem! override func viewDidLoad() { super.viewDidLoad() } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } }
另外,我們創建一個Service.swift
文件。Service
文件主要負責一些網絡請求,和一些數據訪問的操作。然後供ViewModel
使用,由於本次實戰沒有使用到網絡,所以我們只是模擬從本地plist
文件中讀取用戶數據。
首先我們在Service
文件中創建一個ValidationService
類,最好不要繼承NSObject
,Swift
中推薦盡量使用原生類。我們考慮到當文本框內容變化的時候,我們需要把文本框的內容當做參數傳遞進來進行處理,判斷是否符合我們的要求,然後返回處理結果,也就是狀態。基於此,我們創建一個Protocol.swift
文件,創建一個enum
用於表示我們處理結果,所以,我們在Protocol.swift
文件中添加如下代碼:
enum Result {
case ok(message:String)
case empty
case failed(message:String)
}
username處理
先寫出總結:其實就是兩個流的傳遞過程。
UI操作 -> ViewModel -> 改變數據
數據改變 -> ViewModel -> UI刷新
回到我們Service
中ValidationService
類中,寫一個檢測username
的方法。它看起來應該是這個樣子的:
class ValidationService {
// 單例類
static let instance = ValidationService()
private init(){}
let minCharactersCount = 6
func validationUserName(_ name:String) -> Observable<Result> {
if name.characters.count == 0 { // 當字符串為空的時候,什麽也不做
return Observable.just(Result.empty)
}
if name.characters.count < minCharactersCount {
return Observable.just(Result.failed(message: "用戶名長度至少為6位"))
}
if checkHasUserName(name) {
return Observable.just(Result.failed(message: "用戶名已存在"))
}
return Observable.just(Result.ok(message: "用戶名可用"))
}
func checkHasUserName(_ userName:String) -> Bool {
let filePath = NSHomeDirectory() + "/Documents/users.plist"
guard let userDict = NSDictionary(contentsOfFile: filePath) else {
return false
}
let usernameArray = userDict.allKeys as NSArray
return usernameArray.contains(userName)
}
}
接下來該處理我們的RegisterViewModel
了,我們聲明一個username
,指定為Variable
類型,為什麽是一個Variable
類型?因為它既是一個Observer
,又是一個Observable
,所以我們聲明它是一個Variable
類型的對象。我們對username
處理應該會有一個結果,這個結果應該是由界面監聽來改變界面顯示,因此我們聲明一個usernameUseable
表示對username
處理的一個結果,因為它是一個Observable
,所以我們將它聲明為Observable
類型的對象,所以RegisterViewModel
看起來應該是這樣子的:
class RegisterViewModel {
let username = Variable<String>("")
let usernameUseable:Observable<Result>
init() {
}
}
然後我們再寫RegisterViewController
,它看起來應該是這樣子的:
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
let viewModel = RegisterViewModel()
userNameTextField.rx.text.orEmpty.bind(to: viewModel.username).disposed(by: disposeBag)
}
- 其中
userNameTextField.rx.text.orEmpty
是RxCocoa
庫中的東西,它把TextFiled
的text
變成了一個Observable
,後面的orEmpty
我們可以Command
點進去看下,它會把String?
過濾nil
幫我們變為String
類型。 bind(to:viewModel.username)
的意思是viewModel.username
作為一個observer
(觀察者)觀察userNameTextField
上的內容變化。- 因為我們有監聽,就要有監聽資源的回收,所以我們創建一個
disposeBag
來盛放我們這些監聽的資源。
現在,回到我們的RegisterViewModel
中,我們添加如下代碼:
init() {
let service = ValidationService.instance
usernameUseable = username.asObservable().flatMapLatest{ username in
return service.validationUserName(username).observeOn(MainScheduler.instance).catchErrorJustReturn(.failed(message: "userName檢測出錯")).shareReplay(1)
}
}
viewModel
中,我們把username
當做observable
(被觀察者),然後對裏面的元素進行處理之後發射對應的事件。
下面我們在RegisterViewController
中處理我們的username
請求結果。我們在ViewDidLoad
中添加下列代碼:
viewModel.usernameUseable.bind(to:
nameLabel.rx.validationResult).addDisposableTo(disposeBag)
viewModel.usernameUseable.bind(to:
pwdTextField.rx.inputEnabled).addDisposableTo(disposeBag)
- 將
ViewModel
中username
處理結果usernameUseable
綁定到nameLabel
顯示文案上,根據不同的結果顯示不同的文案; - 將
ViewModel
中username
處理結果usernameUseable
綁定到pwdTextField
,根據不同的結果判斷是否可以輸入。
關於上面的validationResult
和inputEnabled
是需要我們自己去定制的,這就用到了RxSwift 系列(九) -- 那些難以理解的概念文章中的UIBindingObserver
了。
所以,我們在Protocol.swift
文件中添加如下代碼:
extension Result {
var isValid:Bool {
switch self {
case .ok:
return true
default:
return false
}
}
}
extension Result {
var textColor:UIColor {
switch self {
case .ok:
return UIColor(red: 138.0 / 255.0, green: 221.0 / 255.0, blue: 109.0 / 255.0, alpha: 1.0)
case .empty:
return UIColor.black
case .failed:
return UIColor.red
}
}
}
extension Result {
var description: String {
switch self {
case let .ok(message):
return message
case .empty:
return ""
case let .failed(message):
return message
}
}
}
extension Reactive where Base: UILabel {
var validationResult: UIBindingObserver<Base, Result> {
return UIBindingObserver(UIElement: base) { label, result in
label.textColor = result.textColor
label.text = result.description
}
}
}
extension Reactive where Base: UITextField {
var inputEnabled: UIBindingObserver<Base, Result> {
return UIBindingObserver(UIElement: base) { textFiled, result in
textFiled.isEnabled = result.isValid
}
}
}
- 首先,我們對
Result
進行了擴展,添加了isValid
屬性,如果狀態是ok
,這個屬性就為true
,否則為false
- 然後對
Result
添加了一個textColor
屬性,如果狀態為ok
則為綠色,否則使用紅色 - 我們對
UILabel
進行了UIBingObserver
,根據result
結果,進行它的text
和textColor
顯示 - 我們對
UITextField
進行了UIBingObserver
,根據result
結果,對它的isEnabled
進行設置。
寫到這裏,我們暫停一下,運行一下項目看下程序的運行情況,試著去輸入username
嘗試一下效果,是不是很激動??
password處理
有了上面username
的理解,相信大家對password
也就熟門熟路了,因此有些細節就不做描述了。
我們現在對Service
中添加對password
的處理:
func validationPassword(_ password:String) -> Result {
if password.characters.count == 0 {
return Result.empty
}
if password.characters.count < minCharactersCount {
return .failed(message: "密碼長度至少為6位")
}
return .ok(message: "密碼可用")
}
func validationRePassword(_ password:String, _ rePassword: String) -> Result {
if rePassword.characters.count == 0 {
return .empty
}
if rePassword.characters.count < minCharactersCount {
return .failed(message: "密碼長度至少為6位")
}
if rePassword == password {
return .ok(message: "密碼可用")
}
return .failed(message: "兩次密碼不一樣")
}
validationPassword
處理我們輸入的密碼;validationRePassword
處理我們輸入的重復密碼;- 上面函數的返回值都是
Result
類型的值,因為我們外面不需要對這個過程進行監聽,所以不必返回一個新的序列。
在RegisterViewModel
中添加需要的observable
:
let password = Variable<String>("")
let rePassword = Variable<String>("")
let passwordUseable:Observable<Result>
let rePasswordUseable:Observable<Result>
然後在init()
中初始化passwordUseable
和rePasswordUseable
:
passwordUseable = password.asObservable().map { passWord in
return service.validationPassword(passWord)
}.shareReplay(1)
rePasswordUseable = Observable.combineLatest(password.asObservable(), rePassword.asObservable()) {
return service.validationRePassword($0, $1)
}.shareReplay(1)
回到RegisterViewController
中,添加對應的綁定:
pwdTextField.rx.text.orEmpty.bind(to: viewModel.password).disposed(by: disposeBag)
rePwdTextField.rx.text.orEmpty.bind(to: viewModel.rePassword).disposed(by: disposeBag)
viewModel.passwordUseable.bind(to: pwdLabel.rx.validationResult).addDisposableTo(disposeBag)
viewModel.passwordUseable.bind(to: rePwdTextField.rx.inputEnabled).addDisposableTo(disposeBag)
viewModel.rePasswordUseable.bind(to: rePwdLabel.rx.validationResult).addDisposableTo(disposeBag)
??,先放輕松一下,運行程序看看,輸入用戶名和密碼和重復密碼感受一下。
註冊按鈕處理
首先我們在Service
裏面添加一個註冊函數:
func register(_ username:String, password:String) -> Observable<Result> {
let userDict = [username: password]
if (userDict as NSDictionary).write(toFile: filePath, atomically: true) {
return Observable.just(Result.ok(message: "註冊成功"))
}else{
return Observable.just(Result.failed(message: "註冊失敗"))
}
}
我是直接把註冊信息寫入到本地的plist文件,寫入成功就返回ok,否則就是
failed。
回到RegisterViewModel
中添加如下代碼:
let registerTaps = PublishSubject<Void>()
let registerButtonEnabled:Observable<Bool>
let registerResult:Observable<Result>
registerTaps
我們使用了PublishSubject
,因為不需要有初始元素,其實前面的Variable
都可以換成PublishSubject
。大夥可以試試;registerButtonEnabled
就是註冊按鈕是否可用的輸出,這個其實關系到username
和password
;registerResult
就只最後註冊結果了.
我們在init()
函數中初始化registerButtonEnabled
和registerResult
,在init()
中添加如下代碼:
registerButtonEnabled = Observable.combineLatest(usernameUseable, passwordUseable, rePasswordUseable) { (username, password, repassword) in
return username.isValid && password.isValid && repassword.isValid
}.distinctUntilChanged().shareReplay(1)
let usernameAndPwd = Observable.combineLatest(username.asObservable(), password.asObservable()){
return ($0, $1)
}
registerResult = registerTaps.asObservable().withLatestFrom(usernameAndPwd).flatMapLatest { (username, password) in
return service.register(username, password: password).observeOn(MainScheduler.instance).catchErrorJustReturn(Result.failed(message: "註冊失敗"))
}.shareReplay(1)
registerButtonEnabled
的處理,把username
、password
和rePassword
的處理結果綁定到一起,返回一個總的結果流,這是個Bool
值的流。- 我們先將
username
和password
組合,得到一個元素是它倆組合的元祖的流。 - 然後對
registerTaps
事件進行監聽,我們拿到每一個元組進行註冊行為,涉及到耗時數據庫操作,我們需要對這個過程進行監聽,所以我們使用flatMap
函數,返回一個新的流。
回到RegisterViewController
中,添加按鈕的綁定:
registButton.rx.tap.bind(to: viewModel.registerTaps).disposed(by: disposeBag)
viewModel.registerButtonEnabled.subscribe(onNext: { [weak self](valid) in
self?.registButton.isEnabled = valid
self?.registButton.alpha = valid ? 1 : 0.5
}).disposed(by: disposeBag)
viewModel.registerResult.subscribe(onNext: { [weak self](result) in
switch result {
case let .ok(message):
self?.showAlert(message:message)
case .empty:
self?.showAlert(message:"")
case let .failed(message):
self?.showAlert(message:message)
}
}).disposed(by: disposeBag)
彈框方法
func showAlert(message:String) {
let action = UIAlertAction(title: "確定", style: .default) { [weak self](_) in
self?.userNameTextField.text = ""
self?.pwdTextField.text = ""
self?.rePwdTextField.text = ""
// 這個方法是基於點擊確定讓所有元素還原才抽出的,可不搭理。
self?.setupRx()
}
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alertController.addAction(action)
present(alertController, animated: true, completion: nil)
}
註意:上述setupRx()
是為了點擊確定之後,界面上所有的元素還原才抽出的,具體的可以查看demo
現在,運行項目,我們已經能夠正常的註冊賬號了。??
登錄界面
首先我們在storyboard
中添加登錄界面,如下,當點擊登錄的時候,就跳轉到登錄界面。
創建一個LoginViewController.swift
和LoginViewModel.swift
文件,有了上述註冊功能的講解,相信登錄功能也很容易了。
我們在Service.swift
中添加如下代碼:
func loginUserNameValid(_ userName:String) -> Observable<Result> {
if userName.characters.count == 0 {
return Observable.just(Result.empty)
}
if checkHasUserName(userName) {
return Observable.just(Result.ok(message: "用戶名可用"))
}
return Observable.just(Result.failed(message: "用戶名不存在"))
}
// 登錄
func login(_ username:String, password:String) -> Observable<Result> {
guard let userDict = NSDictionary(contentsOfFile: filePath),
let userPass = userDict.object(forKey: username)
else {
return Observable.just(Result.empty)
}
if (userPass as! String) == password {
return Observable.just(Result.ok(message: "登錄成功"))
}else{
return Observable.just(Result.failed(message: "密碼錯誤"))
}
}
- 判斷用戶名是否可用,如果本地plist文件中有這個用戶名,就表示可以使用這個用戶名登錄,用戶名可用;
- 登錄方法,如果用戶名和密碼都正確的話,就登錄成功,否則就密碼錯誤;
然後LoginViewModel.swift
,像這樣:
class LoginViewModel {
let usernameUseable:Driver<Result>
let loginButtonEnabled:Driver<Bool>
let loginResult:Driver<Result>
init(input:(username:Driver<String>, password:Driver<String>, loginTaps:Driver<Void>), service:ValidationService) {
usernameUseable = input.username.flatMapLatest { userName in
return service.loginUserNameValid(userName).asDriver(onErrorJustReturn: .failed(message: "連接server失敗"))
}
let usernameAndPass = Driver.combineLatest(input.username,input.password) {
return ($0, $1)
}
loginResult = input.loginTaps.withLatestFrom(usernameAndPass).flatMapLatest{ (username, password) in
service.login(username, password: password).asDriver(onErrorJustReturn: .failed(message: "連接server失敗"))
}
loginButtonEnabled = input.password.map {
$0.characters.count > 0
}.asDriver()
}
}
- 首先我們聲明的對象都是
Driver
類型的,第一個是username
處理結果流,第二個是登錄按鈕是否可用的流,第三個是登錄結果流; - 下面的
init
方法,看著和剛才的註冊界面不一樣。這種寫法我參考了官方文檔的寫法,讓大家知道有這種寫法。但是我並不推薦大家使用這種方式,因為如果Controller
中的元素很多的話,一個一個傳過來是很可怕的。 - 初始化方法傳入的是一個
input
元組,包括username
的Driver
序列,password
的Driver
序列,還有登錄按鈕點擊的Driver
序列,還有Service
對象,需要Controller
傳遞過來,其實Controller
不應該擁有Service
對象。 - 初始化方法中,我們對傳入的序列進行處理和轉換成相對應的序列。大家可以看到都使用了
Driver
,我們不再需要shareReplay(1)
。 - 明白了註冊界面的東西,想必這些東西也自然很簡單了。
接下來我們在LoginViewController.swift
中寫,它看來像這樣子的:
override func viewDidLoad() {
super.viewDidLoad()
title = "登錄"
let viewModel = LoginViewModel(input: (username: usernameTextField.rx.text.orEmpty.asDriver(),
password: passwordTextField.rx.text.orEmpty.asDriver(),
loginTaps:loginButton.rx.tap.asDriver()),
service: ValidationService.instance)
viewModel.usernameUseable.drive(nameLabel.rx.validationResult).disposed(by: disposeBag)
viewModel.loginButtonEnabled.drive(onNext: { [weak self] (valid) in
self?.loginButton.isEnabled = valid
self?.loginButton.alpha = valid ? 1.0 : 0.5
}).disposed(by: disposeBag)
viewModel.loginResult.drive(onNext: { [weak self](result) in
switch result {
case let .ok(message):
self?.performSegue(withIdentifier: "showListSegue", sender: nil)
self?.showAlert(message: message)
case .empty:
self?.showAlert(message: "")
case let .failed(message):
self?.showAlert(message: message)
}
}).disposed(by: disposeBag)
}
- 我們給
viewModel
傳入相應的Driver序列。 - 將
viewModel
中的對象進行相應的監聽,如果是Driver
序列,我們這裏不使用bingTo
,而是使用的Driver
,用法和bingTo
一模一樣。 Deriver
的監聽一定發生在主線程,所以很適合我們更新UI的操作。- 登錄成功會跳轉到我們的列表界面。
列表界面
由於篇幅原因,列表界面就不做很復雜了,簡單地弄了些假數據。既然做到這裏了,怎麽也得把它做完吧。
let‘s go,在storyboard
中添加一個控制器,布局如下圖:
然後建立對應的ListViewController.swift
、ListViewModel.swift
文件,因為需要model
類,所以創建了一個Contact.swift
類,然後添加了contact.plist
資源文件。
首先編寫我們的Contact.swift
類,它看來像這樣子:
class Contact:NSObject {
var name:String
var phone:String
init(name:String, phone:String) {
self.name = name
self.phone = phone
}
}
然後在Service.swift
文件中,添加一個SearchService
類,它看起來像這樣:
class SearchService {
static let instance = SearchService();
private init(){}
// 獲取聯系人
func getContacts() -> Observable<[Contact]> {
let contactPath = Bundle.main.path(forResource: "Contact", ofType: "plist")
let contactArr = NSArray(contentsOfFile: contactPath!) as! Array<[String:String]>
var contacts = [Contact]()
for contactDict in contactArr {
let contact = Contact(name:contactDict["name"]!, phone: contactDict["phone"]!)
contacts.append(contact)
}
return Observable.just(contacts).observeOn(MainScheduler.instance)
}
}
- 從本地獲取數據,然後轉換成
Contact
模型; - 我們返回的是一個元素是
Contact
數組的Observable
流。接下來更新UI的操作要在主線程中。
然後看看我們的ListViewModel.swift
,它看起來像這樣:
class ListViewModel {
var models:Driver<[Contact]>
init(with searchText:Observable<String>, service:SearchService){
models = searchText.debug()
.observeOn(ConcurrentDispatchQueueScheduler(qos: .background))
.flatMap { text in
return service.getContacts(withName: text)
}.asDriver(onErrorJustReturn:[])
}
}
- 我們的
models
是一個Driver
流,因為更新tableView
是UI操作; - 然後我們使用
service
去獲取數據的操作應該在後臺線程去運行,所以添加了observeOn
操作; flatMap
返回新的observable
流,轉換成models
對應的Driver
流。
註意:因為這裏是根據搜索框的內容去搜索數據,因此在SearchService
中需要添加一個函數,它看起來應該是這樣子的:
func getContacts(withName name: String) -> Observable<[Contact]> {
if name == "" {
return getContacts()
}
let contactPath = Bundle.main.path(forResource: "Contact", ofType: "plist")
let contactArr = NSArray(contentsOfFile: contactPath!) as! Array<[String:String]>
var contacts = [Contact]()
for contactDict in contactArr {
if contactDict["name"]!.contains(name) {
let contact = Contact(name:contactDict["name"]!, phone: contactDict["phone"]!)
contacts.append(contact)
}
}
return Observable.just(contacts).observeOn(MainScheduler.instance)
}
最後,我們的ListViewController
就簡單了:
var searchBarText:Observable<String> {
return searchBar.rx.text.orEmpty.throttle(0.3, scheduler: MainScheduler.instance)
.distinctUntilChanged()
}
override func viewDidLoad() {
super.viewDidLoad()
title = "聯系人"
let viewModel = ListViewModel(with: searchBarText, service: SearchService.instance)
viewModel.models.drive(tableView.rx.items(cellIdentifier: "cell", cellType: UITableViewCell.self)){(row, element, cell) in
cell.textLabel?.text = element.name
cell.detailTextLabel?.text = element.phone
}.disposed(by: disposeBag)
}
發現木有,這裏我們麽有使用到DataSource
,將數據綁定到tableView
的items
元素,這是RxCocoa
對tableView
的一個擴展方法。我們可以點進去看看,一共有三個items
方法,並且文檔都有舉例,我們使用的是
public func items<S : Sequence, Cell : UITableViewCell, O : ObservableType where O.E == S>(cellIdentifier: String, cellType: Cell.Type = default) -> (O) -> (@escaping (Int, S.Iterator.Element, Cell) -> Swift.Void) -> Disposable
這是一個柯裏化的方法,不帶section
的時候使用這個,它有兩個參數,一個是循環利用的cell
的identifier
,一個cell
的類型。後面會返回的是一個閉包,在閉包裏對cell
進行設置。方法用起來比較簡單,就是有點難理解。
ok,到此為止,這次實戰也算結束了。運行你的項目看看吧。
致謝
如果發現文章有錯誤的地方,歡迎指出,謝謝!!
RxSwift 實戰操作【註冊登錄】