Combine 实现优雅的表单验证

2,167 阅读3分钟

Demo背景

表单验证也是一个频次很高的交互场景,一般套路是监测用户输入的邮箱格式是否正确,密码是否有值,如果邮箱格式正确并且有输入密码的情况下,登录按钮的状态才变成可交互状态,否则就一直是不可点击的状态。通常会用Notification监测文本框的输入,每次有值改变,就去调用相对应的验证方法,所有的方法都走一遍后输出一个按钮是否可点击的bool值,这么做可以,但总觉得不是很优雅,于是尝试用Combine来实现一版,具体的交互如下:

2023-06-02-171300_vsKN93Gw.gif

Combine

Combine是iOS13上出来的一个sdk,江湖上号称是响应式函数编程在iOS中的一种实现。有两个重要的角色:publisher和subscriber。publisher用来expose值,官方文档说是 for processing values over time。应该是说可以在整个时间维度上随时抛出有变化的值,是一个流(stream)的概念。subscriber被用来接收publisher抛出的值。

具体步骤

  1. UI层面简单用storyboard布局,email、password两个文本框,一个登录的button和一个默认隐藏用来显示登录状态的label
  2. 创建一个LoginViewModel,统一处理要监听的属性和Combine中一些api的调用以及一些逻辑代码(比如邮箱格式是否正确,登录按钮是否可用等)
  3. viewController中去observer,接收属性值的变化,实时update UI

LoginViewModel

import Foundation
import Combine

class LoginViewModel: ObservableObject{
    // sec 1
    enum ViewState {
        case loading
        case success
        case failed
        case none
    }
    // sec 2
    @Published var email = ""
    @Published var password = ""
    @Published var state: ViewState = .none
    
    // sec 3
    var isValidUsernamePublisher: AnyPublisher<Bool, Never>{
        $email.map{ $0.isValidEmail}.eraseToAnyPublisher()
    }
    
    var isValidPasswordPublisher: AnyPublisher<Bool, Never>{
        $password.map{!$0.isEmpty}.eraseToAnyPublisher()
    }
    // sec 4
    var isSubmitEnabled: AnyPublisher<Bool, Never>{
        Publishers.CombineLatest(isValidUsernamePublisher, isValidPasswordPublisher).map{ $0 && $1 }.eraseToAnyPublisher()
    }
    // sec 5
    func submitLogin(){
        state = .loading
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)){[weak self] in
            guard let self = self else {return}
            if self.isCorrectLogin(){
                self.state = .success
            }else{
                self.state = .failed
            }
        }
    }
    
    // sec 6
    func isCorrectLogin() -> Bool{
        return email == "pgyjyl@163.com" && password == "123456"
    }
}
// sec 7
extension String{
    var isValidEmail: Bool{
        return NSPredicate(
            format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
        ).evaluate(with: self)
    }
}
  • sec1 定义ViewState,区分view的不同状态
  • sec2 LoginViewModel先要继承ObservableObject,才能有能力抛出值的改变,要监听改变的属性需要用属性包装器 @Published 修饰。这些被Published的属性只要发生变动就能实时驱动view状态的改变,达到updateUI的效果。
  • sec3 isValidUsernamePublisher和isValidPasswordPublisher就是对具体业务逻辑的处理了,具体逻辑在map回调里。
    • eraseToAnyPublisher:map的block里处理完业务逻辑,最终应该返回一个bool变量出来,eraseToAnyPublisher方法会包装真实的返回类型为一个通用的AnyPublisher<Bool, Never>类型,更好的被所有observe实时监听
  • sec4 CombineLatest:Combine sdk的信合api了,最终按钮是否可用的输出口是调用CombineLatest方法,传入若干个publisher,根据入参的不同返回不同的tuple,demo中会返回一个(string,string)的tuple,然后用map方法,把tuple转换成一个bool的表示
  • sec5 登录按钮的点击回调,更新state状态
  • sec6 匹配到写死的email和password 才算登录成功
  • sec7 String的extension方法,正则验证一个字符串是否符合email的规则

ViewController的更新

class ViewController: UIViewController {
    var viewModel = LoginViewModel()
    var cancellables = Set<AnyCancellable>()
    
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var emailTextField: UITextField!
    @IBOutlet weak var submitButton: UIButton!
    @IBOutlet weak var errorLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupPublishers()
    }
    
    func setupPublishers(){
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: emailTextField)
            .map{($0.object as! UITextField).text ?? ""}
            .assign(to: \.email, on: viewModel)
            .store(in: &cancellables)
        
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: passwordTextField)
            .map{($0.object as! UITextField).text ?? ""}
            .assign(to: \.password, on: viewModel)
            .store(in: &cancellables)
        
        viewModel.isSubmitEnabled
            .assign(to: \.isEnabled, on: submitButton)
            .store(in: &cancellables)
        
        viewModel.$state.sink{[weak self] state in
            switch state{
            case .loading:
                self?.submitButton.isEnabled = false
                self?.submitButton.setTitle("Loading..", for: .normal)
                self?.hideError(true)
            case .success:
                self?.showResultScreen()
                self?.resetButton()
                self?.hideError(true)
            case .failed:
                self?.resetButton()
                self?.hideError(false)
            case .none:
                break
            }
        }.store(in: &cancellables)
    }

    
    @IBAction func onSubmit(_ sender: Any) {
        viewModel.submitLogin()
    }
    func resetButton(){
        submitButton.setTitle("Login", for: .normal)
        submitButton.isEnabled = true
    }
    
    func showResultScreen(){
        let resultVc = ResultViewController()
        navigationController?.pushViewController(resultVc, animated: true)
    }
    
    func hideError(_ isHidden: Bool){
        errorLabel.alpha = isHidden ? 0 : 1
    }

}
  • 首先声明一个LoginViewModel的属性 viewModel
  • AnyCancellable的set,让subscriber可以随时release一个publisher
  • setupPublishers:
    • 使用notification这个publisher监听email和password两个文本框的值改变,在assign中,文本框的值被更新到viewModel中要监听的属性上(@Published修饰的属性),并且observe被store在cancellables的set中,这是publisher的一个链式调用,本质上做了observe和publisher的绑定
  • isSubmitEnabled:将按钮是否可用的publisher assgin on到具体的button上
    • 其实内部是keypath assing subscription。是我们创建接收publisher并设置状态到具体的UIKit上的subscription的一个方法,返回值是An AnyCancellable instance
  • sink:viewModel.$state.sink 返回值也是AnyCancellable,主要用来输出值的一个subscription,根据不同的输出值的状态更新view

引用