RxSwift 实战操作【注册登录】
前言
看了前面的文章,相信很多同学还不知道RxSwift
该怎么使用,这篇文件将带领大家一起写一个 注册登录(ps:本例子采用MVVM
)的例子进行实战。本篇文章是基于RxSwift3.0
写的,采用的是Carthage
第三方管理工具导入的RxSwift3.0
,关于Carthage
的安装和使用,请参考Carthage的安装和使用。
最终效果
前提准备
首先请大家新建一个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,到此为止,这次实战也算结束了。运行你的项目看看吧。
致谢
如果发现文章有错误的地方,欢迎指出,谢谢!!