Swift - RxSwift的使用详解70(RxFeedback架构2:一个用户注册样例)
这个用户注册样例我之前也做过,当时用的是 MVVM 架构(文章1、文章2)。下面我使用 RxFeedback 来对其进行重构。
1,效果图
(1)默认“注册”按钮不可用,只有用户名、密码、再次输入密码三者都符合如下条件时才可用:
- 输入用户名时会同步检查该用户名是否符合条件(只能为数字或字母),以及是否已存在(通过网络请求),并在输入框下方显示验证结果。
- 输入密码时会检查密码是否符合条件(最少要 5 位),并在输入框下方显示验证结果。
- 再次输入密码时会检查两个密码是否一致,并在输入框下方显示验证结果。
(2)当所有输入都符合条件时,点击“注册”按钮发起请求,并将结果弹出显示。同时在注册过程中按钮左侧会显示一个菊花状的网络请求指示器。
2,页面设计
(1)首先我们在 storyboard 中添加 3 个输入框、3 个文本标签,它们分别用于输入用户名、密码、确认密码,以及对应的验证结果显示。
(2)接着在界面最下方添加一个按钮用于注册。
(3)接着在注册按钮的左侧放置一个 Activity Indicator View,同时设置当其动画停止时自动隐藏。
(4)最后将这个 8 个 UI 控件与代码做 @IBOutlet 关联。
3,网络请求服务
我们首先将需要调用的网络请求:验证用户名是否存在,用户注册封装起来(GitHubNetworkService.swift),方便后面使用。(这里代码与之前那篇文章一样,没有变化)
import Foundation import RxSwift //GitHub网络请求服务 class GitHubNetworkService { //验证用户是否存在 func usernameAvailable(_ username: String) -> Observable<Bool> { //通过检查这个用户的GitHub主页是否存在来判断用户是否存在 let url = URL(string: "https://github.com/\(username.URLEscaped)")! let request = URLRequest(url: url) return URLSession.shared.rx.response(request: request) .map { pair in //如果不存在该用户主页,则说明这个用户名可用 return pair.response.statusCode == 404 } .catchErrorJustReturn(false) } //注册用户 func signup(_ username: String, password: String) -> Observable<Bool> { //这里我们没有真正去发起请求,而是模拟这个操作(平均每3次有1次失败) let signupResult = arc4random() % 3 == 0 ? false : true return Observable.just(signupResult) .delay(1.5, scheduler: MainScheduler.instance) //结果延迟1.5秒返回 } } //扩展String extension String { //字符串的url地址转义 var URLEscaped: String { return self.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "" } }
4,用户注册验证服务
(1)首先定义一个用于表示验证结果和信息的枚举(ValidationResult),后面我们会将它作为验证结果绑定到界面上。(这里代码与之前那篇文章一样,没有变化)
(2)接着将用户名、密码等各种需要用到的验证封装起来(GitHubSignupService.swift),方便后面使用。(返回的就是上面定义的 ValidationResult,这里代码与之前那篇文章一样,没有变化)
import UIKit //验证结果和信息的枚举 enum ValidationResult { case validating //正在验证中s case empty //输入为空 case ok(message: String) //验证通过 case failed(message: String) //验证失败 } //扩展ValidationResult,对应不同的验证结果返回验证是成功还是失败 extension ValidationResult { var isValid: Bool { switch self { case .ok: return true default: return false } } } //扩展ValidationResult,对应不同的验证结果返回不同的文字描述 extension ValidationResult: CustomStringConvertible { var description: String { switch self { case .validating: return "正在验证..." case .empty: return "" case let .ok(message): return message case let .failed(message): return message } } } //扩展ValidationResult,对应不同的验证结果返回不同的文字颜色 extension ValidationResult { var textColor: UIColor { switch self { case .validating: return UIColor.gray case .empty: return UIColor.black case .ok: return UIColor(red: 0/255, green: 130/255, blue: 0/255, alpha: 1) case .failed: return UIColor.red } } }
(2)接着将用户名、密码等各种需要用到的验证封装起来(GitHubSignupService.swift),方便后面使用。(返回的就是上面定义的 ValidationResult,这里代码与之前那篇文章一样,没有变化)
import UIKit import RxSwift //用户注册服务 class GitHubSignupService { //密码最少位数 let minPasswordCount = 5 //网络请求服务 lazy var networkService = { return GitHubNetworkService() }() //验证用户名 func validateUsername(_ username: String) -> Observable<ValidationResult> { //判断用户名是否为空 if username.isEmpty { return .just(.empty) } //判断用户名是否只有数字和字母 if username.rangeOfCharacter(from: CharacterSet.alphanumerics.inverted) != nil { return .just(.failed(message: "用户名只能包含数字和字母")) } //发起网络请求检查用户名是否已存在 return networkService .usernameAvailable(username) .map { available in //根据查询情况返回不同的验证结果 if available { return .ok(message: "用户名可用") } else { return .failed(message: "用户名已存在") } } .startWith(.validating) //在发起网络请求前,先返回一个“正在检查”的验证结果 } //验证密码 func validatePassword(_ password: String) -> ValidationResult { let numberOfCharacters = password.count //判断密码是否为空 if numberOfCharacters == 0 { return .empty } //判断密码位数 if numberOfCharacters < minPasswordCount { return .failed(message: "密码至少需要 \(minPasswordCount) 个字符") } //返回验证成功的结果 return .ok(message: "密码有效") } //验证二次输入的密码 func validateRepeatedPassword(_ password: String, repeatedPassword: String) -> ValidationResult { //判断密码是否为空 if repeatedPassword.count == 0 { return .empty } //判断两次输入的密码是否一致 if repeatedPassword == password { return .ok(message: "密码有效") } else { return .failed(message: "两次输入的密码不一致") } } }
5,对 UILabel 进行扩展(BindingExtensions.swift)
为了让 ValidationResult 能绑定到 label 上,我们要对 UILabel 进行扩展(这里代码与之前那篇文章一样,还是没有变化)import UIKit import RxSwift import RxCocoa //扩展UILabel extension Reactive where Base: UILabel { //让验证结果(ValidationResult类型)可以绑定到label上 var validationResult: Binder<ValidationResult> { return Binder(base) { label, result in label.textColor = result.textColor label.text = result.description } } }
6,主视图控制器(ViewController)
下面是本文的重点了。与 MVVM 架构不同的是,我们不再需要定义 ViewModel,而是通过状态、事件、反馈循环来实现整个页面的业务逻辑分离。import UIKit import RxSwift import RxCocoa import RxFeedback class ViewController: UIViewController { //用户名输入框、以及验证结果显示标签 @IBOutlet weak var usernameOutlet: UITextField! @IBOutlet weak var usernameValidationOutlet: UILabel! //密码输入框、以及验证结果显示标签 @IBOutlet weak var passwordOutlet: UITextField! @IBOutlet weak var passwordValidationOutlet: UILabel! //重复密码输入框、以及验证结果显示标签 @IBOutlet weak var repeatedPasswordOutlet: UITextField! @IBOutlet weak var repeatedPasswordValidationOutlet: UILabel! //注册按钮 @IBOutlet weak var signupOutlet: UIButton! //注册时的活动指示器 @IBOutlet weak var signInActivityIndicator: UIActivityIndicatorView! let disposeBag = DisposeBag() //状态 struct State { var username: String? //用户名 var password: String? //密码 var repeatedPassword: String? //再出输入密码 var usernameValidationResult: ValidationResult //用户名验证结果 var passwordValidationResult: ValidationResult //密码验证结果 var repeatedPasswordValidationResult: ValidationResult //重复密码验证结果 var startSignup: Bool //开始注册 var signupResult: Bool? //注册结果 //用户注册信息(只有开始注册状态下才有数据返回) var signupData: (username: String, password: String)? { return startSignup ? (username ?? "", password ?? "") : nil } //返回初始化状态 static var empty: State { return State(username: nil, password: nil, repeatedPassword: nil, usernameValidationResult: ValidationResult.empty, passwordValidationResult: ValidationResult.empty, repeatedPasswordValidationResult: ValidationResult.empty, startSignup: false, signupResult: nil) } } //事件 enum Event { case usernameChanged(String) //用户名输入 case passwordChanged(String) //密码输入 case repeatedPasswordChanged(String) //重复密码输入 case usernameValidated(ValidationResult) //用户名验证结束 case signup //用户注册 case signupResponse(Bool) //注册响应 } override func viewDidLoad() { super.viewDidLoad() //GitHub网络请求服务 let networkService = GitHubNetworkService() //用户注册服务 let signupService = GitHubSignupService() Driver.system( //初始状态 initialState: State.empty, //各个事件对状态的改变 reduce: { (state, event) -> State in switch event { case .usernameChanged(let value): var result = state result.username = value result.signupResult = nil //防止弹出框重复弹出 return result case .passwordChanged(let value): var result = state result.password = value //验证密码 result.passwordValidationResult = signupService.validatePassword(result.password ?? "") //验证密码重复输入 if result.repeatedPassword != nil { result.repeatedPasswordValidationResult = signupService.validateRepeatedPassword( result.password ?? "", repeatedPassword: result.repeatedPassword ?? "" ) } result.signupResult = nil return result case .repeatedPasswordChanged(let value): var result = state result.repeatedPassword = value //验证密码重复输入 result.repeatedPasswordValidationResult = signupService.validateRepeatedPassword( result.password ?? "", repeatedPassword: result.repeatedPassword ?? "" ) result.signupResult = nil return result case .usernameValidated(let value): var result = state result.usernameValidationResult = value result.signupResult = nil return result case .signup: var result = state result.startSignup = true result.signupResult = nil return result case .signupResponse(let value): var result = state result.startSignup = false result.signupResult = value return result } }, feedback: //UI反馈 bind(self) { me, state in //状态输出到页面控件上 let subscriptions = [ //用户名验证结果绑定 state.map{ $0.usernameValidationResult } .drive(me.usernameValidationOutlet.rx.validationResult), //密码验证结果绑定 state.map{ $0.passwordValidationResult } .drive(me.passwordValidationOutlet.rx.validationResult), //重复密码验证结果绑定 state.map{ $0.repeatedPasswordValidationResult } .drive(me.repeatedPasswordValidationOutlet.rx.validationResult), //注册按钮是否可用 state.map{ $0.usernameValidationResult.isValid && $0.passwordValidationResult.isValid && $0.repeatedPasswordValidationResult.isValid} .drive(onNext: { valid in me.signupOutlet.isEnabled = valid me.signupOutlet.alpha = valid ? 1.0 : 0.3 }), //活动指示器绑定 state.map{ $0.startSignup } .drive(me.signInActivityIndicator.rx.isAnimating), //注册结果显示 state.map{ $0.signupResult } .filter{ $0 != nil } .drive(onNext: { result in me.showMessage("注册" + (result! ? "成功" : "失败") + "!") }) ] //将 UI 事件变成Event输入到反馈循环里面去 let events = [ //用户名输入 me.usernameOutlet.rx.text.orEmpty.changed .asSignal().map(Event.usernameChanged), //密码输入 me.passwordOutlet.rx.text.orEmpty.changed .asSignal().map(Event.passwordChanged), //重复密码输入 me.repeatedPasswordOutlet.rx.text.orEmpty.changed .asSignal().map(Event.repeatedPasswordChanged), //注册按钮点击 me.signupOutlet.rx.tap .asSignal().map{ _ in Event.signup }, ] return Bindings(subscriptions: subscriptions, events: events) }, //非UI的自动反馈(用户名验证) react(query: { $0.username }, effects: { username in return signupService.validateUsername(username) .asSignal(onErrorRecover: { _ in .empty() }) .map(Event.usernameValidated) }), //非UI的自动反馈(用户注册) react(query: { $0.signupData }, effects: { (username, password) in return networkService.signup(username, password: password) .asSignal(onErrorRecover: { _ in .empty() }) .map(Event.signupResponse) }) ) .drive() .disposed(by: disposeBag) } //详细提示框 func showMessage(_ message: String) { let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) let okAction = UIAlertAction(title: "确定", style: .cancel, handler: nil) alertController.addAction(okAction) self.present(alertController, animated: true, completion: nil) } }
附:代码的拆分优化
上面样例中我们把 RxFeedBack 架构用到的 state、event、feedback 等等都定义在 ViewController 中,这样功能一旦多些,ViewController 里的代码就会变得十分冗长,难以阅读。下面我对代码进行改成,拆分成各个独立的文件。
1,GitHubSignup+State.swift
将 State、Event 以及相关的 reduce 都放在这里面。
import Foundation //状态 struct GitHubSignupState { var username: String? //用户名 var password: String? //密码 var repeatedPassword: String? //再出输入密码 var usernameValidationResult: ValidationResult //用户名验证结果 var passwordValidationResult: ValidationResult //密码验证结果 var repeatedPasswordValidationResult: ValidationResult //重复密码验证结果 var startSignup: Bool //开始注册 var signupResult: Bool? //注册结果 //用户注册信息(只有开始注册状态下才有数据返回) var signupData: (username: String, password: String)? { return startSignup ? (username ?? "", password ?? "") : nil } } //事件 enum GitHubSignupEvent { case usernameChanged(String) //用户名输入 case passwordChanged(String) //密码输入 case repeatedPasswordChanged(String) //重复密码输入 case usernameValidated(ValidationResult) //用户名验证结束 case signup //用户注册 case signupResponse(Bool) //注册响应 } extension GitHubSignupState { //返回初始化状态 static var empty: GitHubSignupState { return GitHubSignupState(username: nil, password: nil, repeatedPassword: nil, usernameValidationResult: ValidationResult.empty, passwordValidationResult: ValidationResult.empty, repeatedPasswordValidationResult: ValidationResult.empty, startSignup: false, signupResult: nil) } static func reduce(state: GitHubSignupState, event: GitHubSignupEvent , signupService: GitHubSignupService) -> GitHubSignupState { switch event { case .usernameChanged(let value): var result = state result.username = value result.signupResult = nil //防止弹出框重复弹出 return result case .passwordChanged(let value): var result = state result.password = value //验证密码 result.passwordValidationResult = signupService.validatePassword(result.password ?? "") //验证密码重复输入 if result.repeatedPassword != nil { result.repeatedPasswordValidationResult = signupService.validateRepeatedPassword( result.password ?? "", repeatedPassword: result.repeatedPassword ?? "" ) } result.signupResult = nil return result case .repeatedPasswordChanged(let value): var result = state result.repeatedPassword = value //验证密码重复输入 result.repeatedPasswordValidationResult = signupService.validateRepeatedPassword( result.password ?? "", repeatedPassword: result.repeatedPassword ?? "" ) result.signupResult = nil return result case .usernameValidated(let value): var result = state result.usernameValidationResult = value result.signupResult = nil return result case .signup: var result = state result.startSignup = true result.signupResult = nil return result case .signupResponse(let value): var result = state result.startSignup = false result.signupResult = value return result } } }
2,GitHubSignup+Feedback.swift
这里面放置与 UI 无关的反馈。
import Foundation import RxSwift import RxCocoa import RxFeedback struct GitHubSignupFeedback { //验证用户名 static func validateUsername(signupService: GitHubSignupService) -> (Driver<GitHubSignupState>) -> Signal<GitHubSignupEvent> { let query:(GitHubSignupState) -> String? = { $0.username } let effects:(String) -> Signal<GitHubSignupEvent> = { return signupService.validateUsername($0) .asSignal(onErrorRecover: { _ in .empty() }) .map(GitHubSignupEvent.usernameValidated) } return react(query: query, effects: effects) } //用户注册 static func signup(networkService: GitHubNetworkService) -> (Driver<GitHubSignupState>) -> Signal<GitHubSignupEvent> { let query:(GitHubSignupState) -> (String, String)? = { $0.signupData } let effects:(String, String) -> Signal<GitHubSignupEvent> = { return networkService.signup($0, password: $1) .asSignal(onErrorRecover: { _ in .empty() }) .map(GitHubSignupEvent.signupResponse) } return react(query: query, effects: effects) } }
3,ViewController.swift
可以发现代码经过前面的提取之后,主视图控制器的代码变得简洁许多。
import UIKit import RxSwift import RxCocoa import RxFeedback class ViewController: UIViewController { //用户名输入框、以及验证结果显示标签 @IBOutlet weak var usernameOutlet: UITextField! @IBOutlet weak var usernameValidationOutlet: UILabel! //密码输入框、以及验证结果显示标签 @IBOutlet weak var passwordOutlet: UITextField! @IBOutlet weak var passwordValidationOutlet: UILabel! //重复密码输入框、以及验证结果显示标签 @IBOutlet weak var repeatedPasswordOutlet: UITextField! @IBOutlet weak var repeatedPasswordValidationOutlet: UILabel! //注册按钮 @IBOutlet weak var signupOutlet: UIButton! //注册时的活动指示器 @IBOutlet weak var signInActivityIndicator: UIActivityIndicatorView! let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() //GitHub网络请求服务 let networkService = GitHubNetworkService() //用户注册服务 let signupService = GitHubSignupService() //UI绑定 let bindUI: (Driver<GitHubSignupState>) -> Signal<GitHubSignupEvent> = bind(self) { me, state in //状态输出到页面控件上 let subscriptions = [ //用户名验证结果绑定 state.map{ $0.usernameValidationResult } .drive(me.usernameValidationOutlet.rx.validationResult), //密码验证结果绑定 state.map{ $0.passwordValidationResult } .drive(me.passwordValidationOutlet.rx.validationResult), //重复密码验证结果绑定 state.map{ $0.repeatedPasswordValidationResult } .drive(me.repeatedPasswordValidationOutlet.rx.validationResult), //注册按钮是否可用 state.map{ $0.usernameValidationResult.isValid && $0.passwordValidationResult.isValid && $0.repeatedPasswordValidationResult.isValid} .drive(onNext: { valid in me.signupOutlet.isEnabled = valid me.signupOutlet.alpha = valid ? 1.0 : 0.3 }), //活动指示器绑定 state.map{ $0.startSignup } .drive(me.signInActivityIndicator.rx.isAnimating), //注册结果显示 state.map{ $0.signupResult } .filter{ $0 != nil } .drive(onNext: { result in me.showMessage("注册" + (result! ? "成功" : "失败") + "!") }) ] //将 UI 事件变成Event输入到反馈循环里面去 let events = [ //用户名输入 me.usernameOutlet.rx.text.orEmpty.changed .asSignal().map(GitHubSignupEvent.usernameChanged), //密码输入 me.passwordOutlet.rx.text.orEmpty.changed .asSignal().map(GitHubSignupEvent.passwordChanged), //重复密码输入 me.repeatedPasswordOutlet.rx.text.orEmpty.changed .asSignal().map(GitHubSignupEvent.repeatedPasswordChanged), //注册按钮点击 me.signupOutlet.rx.tap .asSignal().map{ _ in GitHubSignupEvent.signup }, ] return Bindings(subscriptions: subscriptions, events: events) } Driver.system( //初始状态 initialState: GitHubSignupState.empty, //各个事件对状态的改变 reduce: { GitHubSignupState.reduce(state: $0, event: $1, signupService: signupService) }, feedback: //UI反馈 bindUI, //非UI的自动反馈(用户名验证) GitHubSignupFeedback.validateUsername(signupService: signupService), //非UI的自动反馈(用户注册) GitHubSignupFeedback.signup(networkService: networkService) ) .drive() .disposed(by: disposeBag) } //详细提示框 func showMessage(_ message: String) { let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) let okAction = UIAlertAction(title: "确定", style: .cancel, handler: nil) alertController.addAction(okAction) self.present(alertController, animated: true, completion: nil) } }