Валидация данных в iOS приложениях

Думаю, каждый из нас сталкивался с задачей валидации данных в приложениях. Например, при регистрации пользователя нужно убедиться что email имеет правильный формат, а пароль удовлетворяет требованиям безопасности и так далее. Можно привести массу примеров, но все в итоге сводится к одной задаче — валидация данных перед отправкой формы.


Работая над очередным проектом, я задумался над тем, чтобы создать универсальное решение, а не писать методы валидации для каждого экрана с формой отдельно. В 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, то мы не можем создать переменную типа


var validators:[Validator] //Protocol 'Validator' can only be used as a generic constraint because it has Self or associated type requirements

Для решения данной проблемы используем стандартный подход, а именно, создание структуры 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)
    }
}

Property wrapper


С валидаторами разобрались, можно переходить непосредственно к реализации обертки @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 и какой синтаксис они используют. Если вам еще не удалось с ними познакомиться, то советую прочитать мою другую статью How to Approach Wrappers for Swift Properties(English).


Данная реализация позволяет нам объявлять свойства, требующие валидации следующим образом:


@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

image
Есть вероятность, что некоторые комбинации валидаторов (например, валидация email) будут встречаться в приложении на нескольких экранах. Для того, чтобы избежать копирования кода, можно в таких случаях создавать отдельный wrapper, унаследованный от 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 и projectedValue, в противном случае мы получим ошибку компиляции. Выглядит это как баг реализации, так что возможно, что в будущих версиях Swift это будет исправлено.


Добавляем Reactive


Вместе с 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()
    }
// The rest of the code
}

Из-за того, что stored property не может быть помечено аннотацией @available, пришлось применить work around со свойствами _subject и subject. В остальном все должно быть предельно понятным. Создается объект PassthroughObject, который отправляет сообщения каждый раз, когда меняется wrappedValue.


В результате сообщения об ошибках валидации меняются по мере заполнения пользователем формы.
image
В процессе тестирования данного решения был выявлен один баг. Валидация происходит при каждом изменении свойства вне зависимости от наличия подписчиков у этого события. С одной стороны, это никак не влияет на результат, но с другой, в случае, когда нам не нужна валидация в реальном времени, будут выполняться ненужные действия. Правильно будет выполнять валидацию и отправлять сообщения только если есть хоть один подписчик. В результате код был переделан с учетом данного требования


@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
            })
        }
    }

    // The rest of the code
}

В результате я получил относительно универсальное решение для валидации данных в приложении. Возможно, оно не решает некоторые задачи, например, более сложную обработку ошибок, чем простой вывод сообщения, но для простой валидации введенных пользователем данных оно подходит. Ознакомиться с полным решением вы можете на GitHub.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 2

    0
    Спасибо за статью.
    А почему используете .receive(on: RunLoop.main) вместо .receive(on: DispatchQueue.main)?
      0
      Спасибо за отзыв. Если честно, затрудняюсь ответить в чем разница между этими двумя вариантами, поскольку пока не было времени глубоко изучить Combine. Вариант с RunLoop.main взял из Документации

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое