О чём это?
Эта статья — обзор моей Android-библиотеки валидации, которая позволяет организовать сложную валидацию данных. В первую очередь библиотека рассчитана на проверку пользовательского ввода.
![](https://habrastorage.org/getpro/habr/upload_files/86d/f8f/23e/86df8f23e53a304db63c02b9f601d7c7.png)
Часто в мобильных приложениях приходится делать различные экраны для ввода пользователем информации. Но так как пользователи не отличаются умом и сообразительностью — приходится проверять, что они там написали: запрещенные символы, максимальная длина, соответствие RegExp и так далее.
Первое, что приходит в голову для решения проблемы — прицепить регулярку прямо на EditText
Например, вот так:
val pattern = "[a-z]+".toRegex() editText.addTextChangedListener { val text = it?.toString() ?: return@addTextChangedListener if (text.matches(pattern)) { hideError(editText) } else { showError(editText, "Only a-z symbols allowed") } }
Но а если теперь я хочу проверять поле по двум разным RegExp и выводить разные ошибки?
Ну тогда можно добавить второй слушатель:
editText.addTextChangedListener { val text = it?.toString() ?: return@addTextChangedListener if (text.length <= 10) { hideError(editText) } else { showError(editText, "Only 10 symbols allowed") } }
Однако, теперь если в поле ввести строку «12345» то ошибки не будет: первый слушатель выставит ошибку на поле, потому что поле содержит цифры, а вот второй слушатель скроет ошибку, потому что по его мнению — поле правильное, не более 10 символов.
И это только одна проблема. Дальше — веселее:
-
Как повесить на поле множество правил валидации, чтобы они правильно работали вместе?
-
Как добавлять или удалять правила?
-
Где хранить эту кучу валидаторов?
-
Как проверять любые типы данных, а не только строковые?
-
и многое другое…
Решение
Вся валидация должна происходить во ViewModel или Presenter, но не в UI слое. Задача UI — должным образом реагировать на результат проверки.
Не должно быть никаких специальных view-классов с поддержкой валидации. Валидацию можно привязать к любой view
Валидаторы должны поддерживать множество различных правил проверки, чтобы, например, каждое правило выдавало собственную ошибку.
Схематично всё выглядит примерно вот так:
![](https://habrastorage.org/getpro/habr/upload_files/413/b4f/3f1/413b4f3f1e53ee1d9889a5e6299ec271.jpg)
Всё это я реализовал в отдельной библиотеке.
Теперь рассмотрим её подробнее.
Condition — основа всего
В основе всей валидации лежит условие (Condition
) — простейший интерфейс с одним методом validate
.
![interface Condition interface Condition](https://habrastorage.org/getpro/habr/upload_files/1d5/52f/117/1d552f117a3b5f12605afee16ce05f91.png)
Как это работает?
На изи! У Condition
есть метод validate(data)
, который проверит данные и вернёт результат ValidationResult
. Внутри ValidationResult
будет булевый результат проверки isValid
и сообщение об ошибке, которое должно появляться если isValid == false
Сложений и умножение
Condition
можно складывать и умножать. Сложение работает как аналог булевого ИЛИ, а умножение как аналог булевого И
Сложение (ИЛИ)
ИЛИ |
Conditon(true) |
Conditon(false) |
---|---|---|
Conditon(true) |
Conditon(true) |
Conditon(true) |
Conditon(false) |
Conditon(true) |
Conditon(false) |
Умножение (И)
И |
Conditon(true) |
Conditon(false) |
---|---|---|
Conditon(true) |
Conditon(true) |
Conditon(false) |
Conditon(false) |
Conditon(false) |
Conditon(false) |
Точно так же склыдываются или умножаются ValidationResult
Validator — проверка по множеству условий
А что если надо проверять значение по множеству условий?
Как это работает?
Validator
по-сути является Condition
, только более прокаченный.
Внутри Validator
находится множуство условий Set<Condition>
. В момент проверки значение проверяется по каждому из условий, формируется набор результатов валидации Set<ValidationResult>
. Затем, этот набор с результатами передается на вход оператору (Operator
), который и решает, какой будет финальный результат валидации. Вот, Всё.
![Validator Validator](https://habrastorage.org/getpro/habr/upload_files/526/489/04b/52648904b0e49de497b81dd8cf1a1eca.png)
У валидатора есть свои приколы:
Оператор
Operator
— это просто Condition<Collection<ValidationResult>>
, то есть тупа проверяет коллекцию результатов валидации. Получается такой аналог логического оператора из начального курса булевой алгебры. По-умолчанию используется оператор-конъюнкция.
Но можно написать свой оператор, который, например, будет выдавать ValidationResult(true)
если количество валидных условий достигло порогового значения.
class ThresholdOperator(val validThreshold: Int) : Validator.Operator { override fun validate(value: Collection<ValidationResult>?): ValidationResult { val validCount = value?.count { it.isValid } ?: 0 return if (validCount >= validThreshold) { ValidationResult.valid() } else { ValidationResult.invalid("Less than $validThreshold valid conditions") } } }
Установка оператора
validator.setOperator(ThresholdOperator(validator.getConditionsSet().size / 2))
Удаление оператора
Нельзя удалять оператор! Validator
не может работать без оператора
Наблюдение за изменением оператора
Может быть такое, что необходимо отслеживать изменения оператора. Например, чтобы обновить view.
validator.addOperatorChangedListener { // on operator changed } //Или удаляем слушателя validator.removeOperatorChangedListener(operatorListener)
Набор условий
Добавление условия
validator.addCondition(Condition { string -> ValidationResult.obtain(string?.contains("target") == true, "String must contains target") })
Удаление условия
validator.removeCondition(condition)
Наблюдение за изменением условий
Чтобы следить за списком условий — добавьте слушателя OnConditionsChangedListener
, который будет вызываться при любом изменении условий
validator.addConditionsChangedListener { newConditions -> //on new conditions }
Изменение условий
Если нужно сделать много преобразований можно использовать changeConditionsSet
, чтобы слушатель OnConditionsChangedListener
сработал только один раз — после всех преобразований набора условий.
validator.changeConditionsSet { this.add(Conditions.RequiredField()) this.remove(condition2) this.add(Conditions.NotNull()) }
LiveDataValidator — реактивная валидация
Было бы удобно, если бы валидатор самостоятельно проверял данные при каждом их изменении. Так и сделаем! Сейчас модно молодежно использовать LiveData
. Так пусть валидатор подпишется на неё и будет проверять каждое значение.
![LiveDataValidator LiveDataValidator](https://habrastorage.org/getpro/habr/upload_files/95a/c33/ced/95ac33ced74b129767075d891651c25b.png)
LiveDataValidator
работает так же как и обычный Vlidator
, однако у него есть свои особенности:
Состояние (state)
Состояние это результат последней проверки. Представляет собой LiveData<ValidationResult>
, поэтому за состоянием валидатора можно удобно следить. LiveDataValidator
всегда в актуальном состоянии пока он подписан на источник (Validator.observe
; Validator.observeForever
)
liveDataValidator.state
Активация LiveDataValidator
LiveDataValidator начинает работать только тогда, когда хоть кто-нибудь подписан на него
liveDataValidator.state.observe(viewLifecycleOwner) { validationResult -> //apply validation result } //Или можно вот так, разницы нет liveDataValidator.observe(viewLifecycleOwner) { validationResult -> //apply validation result }
Реакция на другие LiveData
LiveDataValidator
умеет следить за другими LiveData и реагировать на их изменения
Для этого есть метод watchOn
liveDataValidator.watchOn(textMaxLength) { newTextMaxLength -> liveDataValidator.validate() }
В примере выше liveDataValidator
следит за полем textMaxLength
и как только значение textMaxLength
меняется liveDataValidator
принудительно валидируется
Для подобных случаев есть метод triggerOn
, который запускает валидацию всякий раз когда изменяется дополнительный источник
liveDataValidator.triggerOn(textMaxLength)
Пример
Есть 2 текстовых поля: на одном пики точены, на другом х** д*ы вовсе не пики Задача, чтобы второе поле не содержало в себе текст первого поля
val first: MutableLiveData<String?> = MutableLiveData<String?>() //Первое поле (с пиками) val second: MutableLiveData<String?> = MutableLiveData<String?>() //Второе поле (с другими пиками) val secondValidator: LiveDataValidator<String?> = LiveDataValidator(second).apply { addCondition(Conditions.RequiredField()) addCondition(Conditions.RegEx("[a-z]+".toRegex(), "only a-z symbols allowed")) addCondition(Conditions.TextMaxLength(10)) addCondition { // Внимание сюда: для проверки используется внешнее мутабельное поле first! if (it?.contains(first.value ?: "") == true) { ValidationResult.invalid("textField2 should not contains textField1") } else { ValidationResult.valid() } } triggerOn(first) //Теперь при каждом изменении first будет вызываться метод validate() }
Как видно, secondValidator
проверяет поле second
, но при этом использует исползует first
для проверки. Но что если first
изменился? Тогда валидатор будет висеть в неактуальном состоянии до следующего изменения second
. Поэтому валидатору нужно следить за first
и при каждом его изменении принудительно выполнять проверку Делается это методом triggerOn(LiveData<*>)
, который будет запускать валидатор при каждом изменении first
Вместо triggerOn
можно так же использовать watchOn
и самостоятельно прописать нужное действие
watchOn(textField1) { validate() }
MuxLiveDataValidator — объединяем валидаторы
А теперь, когда у нас есть куча полей с LiveDataValidator’ами надо каким-то образом опредилить общий результат валидации. Самый распространённый пример: если все поля на форме заполнены правильно — включаем кнопку «Далее».
Для этого есть MuxLiveDataValidator
. Он подписывается на множество LiveDataValidator'ов
и как только один из них изменяется — MuxLiveDataValidator
собирает состояния (ValidationResult
) всех LiveDataValidator’ов и отдаёт их на проверку оператору (Operator
). Operator выдаёт окончательный результат.
Короче, MuxLiveDataValidator
работает типа как мультиплексор. Отсюда и название.
![MuxLiveDataValidator MuxLiveDataValidator](https://habrastorage.org/getpro/habr/upload_files/6a7/220/717/6a722071718cc2ed286eeaf8b7b070bb.png)
Состояние (state)
Аналогично LiveDataValidator
у MuxLiveDataValidator
есть состояние
muxValidator.state
Состояние это LiveData<ValidationResult>
в котором находится последний результат проверки.
Активация MuxLiveDataValidator
Тут как у LiveDataValidator
— доступ только по подписке
viewModel.muxValidator.observe(viewLifecycleOwner) { validationResult -> // apply validatioin result }
Примечание
Когда вы подписываетесь на MuxLiveDataValidator
, то все его LiveDataValidator
активируются, то есть подписка распространяется и на них (такой вот аналог семейной подписки у MediatorLiveData
). То есть если вы подписались на MuxLiveDataValidator
, то не можно не подписываться на те LiveDataValidator
, за которыми он следит.
Добавление валидатора
Добавить LiveDataValidator
можно при создании MuxLiveDataValidator
val muxValidator = MuxLiveDataValidator( textField1Validator, textField2Validator )
Можно и после создания
muxValidator.addValidator(textField3Validator) //Можешь докинуть сразу несколько muxValidator.addValidators( listOf( textField4Validator, textField5Validator ) )
Удаление валидатора
Ну тут типа ваще всё изян
muxValidator.removeValidator(textField3Validator)
Установка оператора
По-умолчанию MuxLiveDataValidator
использует оператор-конъюнкцию. Чтобы поменять логику выдачи финального ValidationResult
нужно установить другой оператор
muxValidator.setOperator(Validator.Operator.Disjunction())
Есть возможность следить за сменой оператора чтобы, например, очистить ошибку на view.
muxValidator.addOperatorChangedListener { // on operator changed } //Удалить слушателя можно примерно вот так muxValidator.removeOperatorChangedListener(listener)
Подключение валидаторов к view
ConditionViewBinder
ConditionViewBinder
базовый связыватель view
и Condition
Работает так:
В момент вызова ConditionViewBinder.validate()
достает из view данные для проверки абстрактным методом getValidationData()
. Эти данные улетают в Condition
, который проверит их и вернет ValidationResult
. Затем этот ValidationResult
передаётся абстрактному методу onValidationResult()
в котором и происходит изменения view.
![ConditionViewBinder ConditionViewBinder](https://habrastorage.org/getpro/habr/upload_files/4d4/2db/5c0/4d42db5c06745e977cec5e519c30d247.png)
Пример
val editText1 = requireView().findViewById<EditText>(R.id.edit_text1) val condition = Conditions.TextMaxLength<String?>(10) val conditionBinder = object : ConditionViewBinder<TextView, String?>(WeakReference(editText1), condition) { override fun getValidationData(view: TextView?): String? { return view?.text?.toString() } override fun onValidationResult(view: TextView?, result: ValidationResult?) { if (result?.isValid == true) { view?.error = null } else { view?.error = result?.errorMessage } } } conditionBinder.check()
Таким образом можно привязать любой валидатор к любой view
ValidatorViewBinder
Предназначен для более удобной работы с Validator
: следит за изменениями оператора и условий валидатора.
LiveDataValidatorViewBinder
LiveDataValidator
— особый пациент. Для него свой binder, который:
-
сам подписывается/отписывается на
LiveDataValidator
( чтобы активировать его) -
getValidationData()
берется не из view, а прямо из валидатора (из егоsource
)
Активация
LiveDataValidatorViewBinder
нужно активировать. Тут 2 способа:
-
Через конструктор. В конструктор передать
LifeycleOwner
object : LiveDataValidatorViewBinder<TextView, String?>( viewLifecycleOwner, WeakReference(binding.editText1), viewModel.textField1Validator) { override fun onValidationResult(view: TextView?, result: ValidationResult?) { } override fun onConditionsChanged(conditions: Set<Condition<String?>>) { } override fun onOperatorChanged() { } }
-
Просто вызвать
attach
liveDataValidatorViewBinder.attach(viewLifecycleOwner)
Готовые реализации
TextConditionViewBinder
Связывает простые Condition
с TextView
. Проверяет поле при каждом изменении текста в нём
Использовать так:
val editText1 = requireView().findViewById<EditText>(R.id.edit_text1) val condition = Conditions.TextMaxLength<CharSequence?>(10) editText.validateBy(condition)
TextViewLiveDataValidatorBinder
Тут то же самое, что и TextConditionViewBinder
, но тут работаем с LiveDataValidator
.
Использовать так:
val editText = requireView().findViewById<EditText>(R.id.edit_text1) val liveDataValidator = LiveDataValidator(viewModel.textField1, Conditions.RequiredField()) editText.validateBy(viewLifecycleOwner, liveDataValidator)
Примеры
Простая валидация
-
Во ViewModel делаем простейший
Condition
val textFieldCondition = Conditions.RegEx<CharSequence?>( "[a-z]+".toRegex(), "only a-z symbols allowed" )
-
Во фрагменте (или активити) применяем условие к текстовому полю
val editText = requireView().findViewById<EditText>(R.id.edit_text1) editText.validateBy(viewModel.textFieldCondition)
-
Готово
Сложная валидация
Допустим у нас есть 3 поля: поле для ввода цифр, поле для ввода букв и поле, которое указывает максимальную длину поля ввода цифр. О как! А ещё нужно выводить общее состояние валидации всей формы в отдельное текстовое поле!
Пример доступен тут: https://github.com/Egor-Blagochinnov/ValidationSample
-
Для начала объявим сами поля и валидаторы к ним во ViewModel]
//Поле, которое определяет максимальную длину поля для ввода цифр val textMaxLength: MutableLiveData<String?> = MutableLiveData<String?>() //Цифровое поле val textField1: MutableLiveData<String?> = MutableLiveData<String?>() //Про этот валидатор - чуть ниже val textField1Validator = ExampleValidators.DigitsFieldValidator(textField1).apply { watchOn(textMaxLength) { val maxLength = kotlin.runCatching { it?.toInt() }.getOrNull() this.setMaxLength(maxLength) } } //Буквенное поле val textField2: MutableLiveData<String?> = MutableLiveData<String?>() val textField2Validator: LiveDataValidator<String?> = LiveDataValidator(textField2).apply { addCondition(Conditions.RequiredField()) addCondition(Conditions.RegEx("[a-z]+".toRegex(), "only a-z symbols allowed")) addCondition(Conditions.TextMaxLength(10)) } //Обобщенный валидатор val muxValidator = MuxLiveDataValidator( textField1Validator, textField2Validator )
-
Чтобы динамически менять условия валидации — лучше всего написать свой валидатор. Потому что для смены условий нужно хранить ссылки на эти самые условия, а это лучше сделать в отдельном классе
class ExampleValidators { class DigitsFieldValidator<S : CharSequence?>( source: LiveData<S>, initialCondition: Condition<S?>? = null, operator: Operator = Operator.Conjunction() ) : LiveDataValidator<S>( source, initialCondition, operator ) { val onlyDigitsCondition = Conditions.RegEx<S>("[0-9]+".toRegex(), "only digits allowed") private var maxLengthCondition = Conditions.TextMaxLength<S?>(5) //по-умолчанию пусть будет 5 init { addCondition(onlyDigitsCondition) addCondition(maxLengthCondition) } fun setMaxLength(maxLength: Int?) { if (maxLength == null || maxLength < 0) { removeCondition(maxLengthCondition) return } val newCondition = Conditions.TextMaxLength<S?>(maxLength) changeConditionsSet { remove(maxLengthCondition) maxLengthCondition = newCondition add(maxLengthCondition) } } } }
-
Теперь идём во фрагмент и подключаем всё это дело
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = ViewModelProvider(this).get(ExampleViewModel1::class.java) binding.viewModel = viewModel // Подключаем валидатор к цифровому полю binding.editText1.validateBy( viewLifecycleOwner, viewModel.textField1Validator ) // Подключаем валидатор к буквенному полю binding.editText2.validateBy( viewLifecycleOwner, viewModel.textField2Validator ) //Следим за обобщенным (mux) валидатором viewModel.muxValidator.observe(viewLifecycleOwner) { setGeneralValidationResult(it) } } //Отображем результат общей валидации в отдельном текстовом поле "state" private fun setGeneralValidationResult(validationResult: ValidationResult) { if (validationResult.isValid) { binding.state.text = "Correct!" binding.state.setTextColor(ContextCompat.getColor(requireContext(), R.color.state_success)) } else { binding.state.text = validationResult.errorMessage ?: "Error message is null" binding.state.setTextColor(ContextCompat.getColor(requireContext(), R.color.design_default_color_error)) } }
Готово!
![](https://habrastorage.org/getpro/habr/upload_files/822/f5e/ad1/822f5ead1f2ee1cb91e7e0826c42a014.png)
Общие рекомендации по использованию
-
Все валидаторы должны находиться во ViewModel (ну или в Presenter) Не надо выносить логику валидирования во фрагменты, активности и вообще на view уровень.
-
По-возможности используйте
LiveDataValidator
. Он самый прокаченный. И вообще вся библиотека ради него написана была -
Аккуратнее с множеством условий. Вы можете добавить на поле противоречащие друг другу условия и будет непонятно что!
-
Делайте свои реализации. Создавайте свои
ConditionViewBinder
ы, чтобы работать с кастомными view Создавайте свои валидаторы если вам нужна более сложная валидация
ссылка на оригинал статьи https://habr.com/ru/articles/579164/
Добавить комментарий