RxSwift 實戰操做【註冊登陸】

前言

看了前面的文章,相信不少同窗還不知道RxSwift該怎麼使用,這篇文件將帶領你們一塊兒寫一個 註冊登陸(ps:本例子採用MVVM)的例子進行實戰。本篇文章是基於RxSwift3.0寫的,採用的是Carthage第三方管理工具導入的RxSwift3.0,關於Carthage的安裝和使用,請參考Carthage的安裝和使用html

最終效果

效果圖

下載Demo點我git

前提準備

首先請你們新建一個swift工程,而後把RxSwift引入到項目中,而後可以編譯成功就行。github

而後咱們來分析下各個界面的需求:數據庫

註冊界面需求:

  • 輸入用戶名必須大於等於6個字符,否則密碼不能輸入;
  • 密碼必須大於等於6個字符,否則重複密碼不能輸入;
  • 重複密碼和密碼必須同樣, 不能註冊按鈕不能點擊;
  • 點擊註冊按鈕,提示註冊成功或者註冊失敗;
  • 註冊成功會寫進本地的plist文件,而後輸入用戶名會檢測該用戶名是否已註冊

登陸界面需求:

  • 點擊輸入用戶名,檢測是否已存在,若是存在,戶名可用,不然提示用戶名不存在;
  • 輸入密碼,點擊登陸,若是密碼錯則提示密碼錯誤,不然進入列表界面,提示登陸成功。

列表界面需求:

  • 輸入聯繫人的首字母進行篩選

好了,分析完上面的需求以後,是時候展現真正的技術了,let's go。swift

註冊界面

你們如今storyboard中創建出下面這個樣子的界面(ps:添加約束不在本篇範圍內):
圖1api

建立對應的文件

而後創建一個對應的控制器RegisterViewController類,另外建立一個RegisterViewModel.swift,將RegisterViewControllerstoryboard中的控制器關聯,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類,最好不要繼承NSObjectSwift中推薦儘可能使用原生類。咱們考慮到當文本框內容變化的時候,咱們須要把文本框的內容當作參數傳遞進來進行處理,判斷是否符合咱們的要求,而後返回處理結果,也就是狀態。基於此,咱們建立一個Protocol.swift文件,建立一個enum用於表示咱們處理結果,因此,咱們在Protocol.swift文件中添加以下代碼:閉包

enum Result {
    case ok(message:String)
    case empty
    case failed(message:String)
}

username處理

先寫出總結:其實就是兩個流的傳遞過程。
UI操做 -> ViewModel -> 改變數據
數據改變 -> ViewModel -> UI刷新app

回到咱們ServiceValidationService類中,寫一個檢測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.orEmptyRxCocoa庫中的東西,它把TextFiledtext變成了一個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)
  • ViewModelusername處理結果usernameUseable綁定到nameLabel顯示文案上,根據不一樣的結果顯示不一樣的文案;
  • ViewModelusername處理結果usernameUseable綁定到pwdTextField,根據不一樣的結果判斷是否能夠輸入。

關於上面的validationResultinputEnabled是須要咱們本身去定製的,這就用到了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結果,進行它的texttextColor顯示
  • 咱們對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()中初始化passwordUseablerePasswordUseable

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就是註冊按鈕是否可用的輸出,這個其實關係到usernamepassword
  • registerResult就只最後註冊結果了.

咱們在init()函數中初始化registerButtonEnabledregisterResult,在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的處理,把usernamepasswordrePassword的處理結果綁定到一塊兒,返回一個總的結果流,這是個Bool值的流。
  • 咱們先將usernamepassword組合,獲得一個元素是它倆組合的元祖的流。
  • 而後對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中添加登陸界面,以下,當點擊登陸的時候,就跳轉到登陸界面。

圖2

建立一個LoginViewController.swiftLoginViewModel.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元組,包括usernameDriver序列,passwordDriver序列,還有登陸按鈕點擊的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中添加一個控制器,佈局以下圖:
圖3

而後創建對應的ListViewController.swiftListViewModel.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,將數據綁定到tableViewitems元素,這是RxCocoatableView的一個擴展方法。咱們能夠點進去看看,一共有三個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的時候使用這個,它有兩個參數,一個是循環利用的cellidentifier,一個cell的類型。後面會返回的是一個閉包,在閉包裏對cell進行設置。方法用起來比較簡單,就是有點難理解。

ok,到此爲止,此次實戰也算結束了。運行你的項目看看吧。

致謝

若是發現文章有錯誤的地方,歡迎指出,謝謝!!

相關文章
相關標籤/搜索