我认为我们每个人都面临着验证应用程序中数据的任务。 例如,注册用户时,您需要确保电子邮件具有正确的格式,并且密码符合安全性要求,等等。 您可以举很多例子,但这全都归结为一项任务-在提交表单之前进行数据验证。
在进行下一个项目时,我考虑过创建一个通用解决方案,而不是为每个屏幕分别编写带有表单的验证方法。 Swift 5.1引入了@propertyWrapper
批注,我认为使用如下语法会很方便:
@Validated([validator1, validator2, ...]) var email: String? = nil let errors = $email.errors
验证者
第一步是确定验证器的外观和工作方式。 作为项目的一部分,我需要检查数据的有效性并显示适当的错误消息。 无需进行更复杂的错误处理,因此验证器的实现如下:
struct ValidationError: LocalizedError { var message: String public var errorDescription: String? { message } } protocol Validator { associatedtype ValueType var errorMessage: String { get } func isValid(value: ValueType?) -> Bool } extension Validator { func validate(value: ValueType?) throws { if !isValid(value: value) { throw ValidationError(message: errorMessage) } } }
这里的一切都很简单。 验证器包含一条错误消息,如果验证失败,该消息将引发ValidationError。 由于所有验证器都返回相同类型的错误,但带有不同的消息,因此简化了错误处理。 一个示例是验证器的代码,该代码根据正则表达式检查字符串:
struct RegexValidator: Validator { public var errorMessage: String private var regex: String public init(regex: String, errorMessage: String) { self.regex = regex self.errorMessage = errorMessage } public func isValid(value: String?) -> Bool { guard let v = value else { return false } let predicate = NSPredicate(format: "SELF MATCHES %@", regex) return predicate.evaluate(with: v) } }
此实现包含一个众所周知的问题。 由于Validator
协议包含一个associatedtype
,因此我们无法创建type变量
var validators:[Validator]
为了解决这个问题,我们使用一种标准方法,即创建AnyValidator结构。
private class ValidatorBox<T>: Validator { var errorMessage: String { fatalError() } func isValid(value: T?) -> Bool { fatalError() } } private class ValidatorBoxHelper<T, V:Validator>: ValidatorBox<T> where V.ValueType == T { private let validator: V init(validator: V) { self.validator = validator } override var errorMessage: String { validator.errorMessage } override func isValid(value: T?) -> Bool { validator.isValid(value: value) } } struct AnyValidator<T>: Validator { private let validator: ValidatorBox<T> public init<V: Validator>(validator: V) where V.ValueType == T { self.validator = ValidatorBoxHelper(validator: validator) } public var errorMessage: String { validator.errorMessage } public func isValid(value: T?) -> Bool { validator.isValid(value: value) } }
我认为没有什么可评论的。 这是解决上述问题的标准方法。 添加Validator协议的扩展名也很有用,允许您创建AnyValidator对象。
extension Validator { var validator: AnyValidator<ValueType> { AnyValidator(validator: self) } }
物业包装
验证器经过筛选后,您可以直接转到@Validated
包装器的实现。
@propertyWrapper class Validated<Value> { private var validators: [AnyValidator<Value>] var wrappedValue: Value? init(wrappedValue value: Value?, _ validators: [AnyValidator<Value>]) { wrappedValue = value self.validators = validators } var projectedValue: Validated<Value> { self } public var errors: [ValidationError] { var errors: [ValidationError] = [] validators.forEach { do { try $0.validate(value: wrappedValue) } catch { errors.append(error as! ValidationError) } } return errors } }
本文的目的不是分析propertyWrapper
包装器如何工作以及它们使用什么语法。 如果您还不能满足他们的要求,建议您阅读我的另一篇文章, 如何为Swift属性使用包装器 (英语)。
此实现使我们可以声明需要验证的属性,如下所示:
@Validated([ NotEmptyValidator(errorMessage: "Email can't be empty").validator, RegexValidator(regex:"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64} ", errorMessage:"Email has wrong format").validator ]) var email: String? = nil
并在任何给定时间获取一系列验证错误,如下所示
let errors = $email.errors

验证程序的某些组合(例如,电子邮件验证)可能会出现在应用程序的多个屏幕上。 为了避免复制代码,在这种情况下,可以创建一个继承自Validated
的单独包装器。
@propertyWrapper final class Email: Validated<String> { override var wrappedValue: String? { get { super.wrappedValue } set { super.wrappedValue = newValue } } override var projectedValue: Validated<String> { super.projectedValue } init(wrappedValue value: String?) { let notEmptyValidator = NotEmptyValidator(errorMessage: "Email can't be empty") let regexValidator = RegexValidator(regex:"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64} ", errorMessage:"Email has wrong format").validator super.init(wrappedValue: value, [notEmptyValidator, regexValidator]) } } @Email var email: String? = nil
不幸的是,目前, @propertyWrapper
wrappedValue
批注需要覆盖wrappedValue
和projectedValue
,否则我们将收到编译错误。 它看起来像一个实现错误,因此在以后的Swift版本中,此问题可能会得到解决。
添加反应式
与iOS13一起,出现了本机的Combine响应式编程框架。 我认为使用以下语法可能会很有用:
let cancellable = $email .publisher .map { $0.map { $0.localizedDescription }.joined(separator: ", ") } .receive(on: RunLoop.main) .assign(to: \.text, on: emailErrorLabel)
这将允许实时更新有关验证错误的信息(在输入每个字符之后)。 这个想法的最初实现如下:
@propertyWrapper class Validated<Value> { private var _subject: Any! @available(iOS 13.0, *) private var subject: PassthroughSubject<[ValidationError], Never> { return _subject as! PassthroughSubject<[ValidationError], Never> } open var wrappedValue: Value? { didSet { if #available(iOS 13.0, *) { subject.send(errors) } } } public init(wrappedValue value: Value?, _ validators: [AnyValidator<Value>]) { wrappedValue = value self.validators = validators if #available(iOS 13.0, *) { _subject = PassthroughSubject<[ValidationError], Never>() } } @available(iOS 13.0, *) public var publisher: AnyPublisher<[ValidationError], Never> { subject.eraseToAnyPublisher() }
由于无法使用@available
批注标记存储的属性,因此我不得不使用_subject
和subject
属性。 否则,一切都应该非常清楚。 创建一个PassthroughObject
,每次wrappedValue
更改时都会发送消息。
结果,验证错误消息随着用户填写表格而改变。

在测试此解决方案的过程中,发现了一个错误。 每次属性更改时都会进行验证,而不管此事件的订阅者是否存在。 一方面,这不会影响结果,但另一方面,在不需要实时验证的情况下,将执行不必要的操作。 仅当至少有一个订户时,它才能正确验证并发送消息。 结果,此代码已重做。
@propertyWrapper class Validated<Value> { private var _subject: Any! @available(iOS 13.0, *) private var subject: Publishers.HandleEvents<PassthroughSubject<[ValidationError], Never>> { return _subject as! Publishers.HandleEvents<PassthroughSubject<[ValidationError], Never>> } private var subscribed: Bool = false open var wrappedValue: Value? { didSet { if #available(iOS 13.0, *) { if subscribed { subject.upstream.send(errors) } } } } public init(wrappedValue value: Value?, _ validators: [AnyValidator<Value>]) { wrappedValue = value self.validators = validators if #available(iOS 13.0, *) { _subject = PassthroughSubject<[ValidationError], Never>() .handleEvents(receiveSubscription: {[weak self] _ in self?.subscribed = true }) } }
结果,我得到了一个相对通用的解决方案,用于在应用程序中进行数据验证。 它可能无法解决某些问题,例如,比简单的消息输出更复杂的错误处理,但它适合于用户输入的简单验证。 您可以在GitHub上查看完整的解决方案。