Реактивний підхід до валідації полів введення на Android

Привіт! Мене звати Костянтин Черненко, я інженер в компанії Genesis, працюю на проекті BetterMe . Як ви, напевно, знаєте, валідація введення — одна з найпоширеніших задач, яку доводиться робити в мобільному додатку.

Приблизно в 2014 році в нас з'явився такий інструмент, як RxJava, і мислення Android-інженера початок перехід з імперативного до реактивного підходу в програмуванні. Як це пов'язано з валідацією полів? Я думаю, що не відкрию вам секрет: ми можемо інтерпретувати події вводу як потоки даних, на які можна як-небудь реагувати або як ними маніпулювати. Здається, що ви щось подібне вже чули, чи не так?

Бібліотека RxBinding

Звичайно, в цих наших інтернетах дуже багато інформації з цього приводу — статті, бібліотеки та відповіді на Stack Overflow. Найпоширеніший патерн, який можна зустріти, — це використання бібліотеки RxBinding (самі знаєте кого) і ваше базове рішення може виглядати наступним чином:

package tech.gen.rxinputvalidation

import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import com.jakewharton.rxbinding2.widget.RxTextView
import io.reactivex.Вами
import io.reactivex.disposables.Disposable
import io.reactivex.functions.Function4
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

 private var disposable: Disposable? = null

 override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R. layout.activity_main)
}

 override fun onStart() {
super.onStart()
validateFields()
}

 override fun onStop() {
super.onStop()
 disposable?.let { if (!it.isDisposed) it.dispose() }
}

 private fun validateFields() {
 // Wrap EditText views into Observables
 val nameObs = RxTextView.textChanges(nameEt)
 val surnameObs = RxTextView.textChanges(surnameEt)
 val emailObs = RxTextView.textChanges(emailEt)
 val passwordObs = RxTextView.textChanges(passwordEt)

 // Combine those views input events applying validation logic element to each
 disposable = Вами.combineLatest(nameObs, surnameObs, emailObs, passwordObs,
 Function4<CharSequence, CharSequence, CharSequence, CharSequence, Boolean> { name, surname, email, password ->
 // Validate each element and manipulate it's error visibility
 return@Function4 isNameValid(name.toString()) && isSurnameValid(surname.toString()) && isEmailValid(email.toString())
 && isPasswordValid(password).toString())
 }).subscribe {
 btnDone.isEnabled = it
}
}

 private fun isNameValid(name: String): Boolean {
 return if (name.isEmpty()) {
 nameInputLayout.isErrorEnabled = true
 nameInputLayout.error = getString(R. string.name_validation_error)
false
 } else {
 nameInputLayout.isErrorEnabled = false
true
}
}

 private fun isSurnameValid(surname: String): Boolean {
 return if (surname.isEmpty()) {
 surnameInputLayout.isErrorEnabled = true
 surnameInputLayout.error = getString(R. string.surname_validation_error)
false
 } else {
 surnameInputLayout.isErrorEnabled = false
true
}
}

 private fun isEmailValid(email: String): Boolean {
 return if (!email.contains("@", true)) {
 emailInputLayout.isErrorEnabled = true
 emailInputLayout.error = getString(R. string.email_validation_error)
false
 } else {
 emailInputLayout.isErrorEnabled = false
true
}
}

 private fun isPasswordValid(password: String): Boolean {
 return if (password).length < 4) {
 passwordInputLayout.isErrorEnabled = true
 passwordInputLayout.error = getString(R. string.password_validation_error)
false
 } else {
 passwordInputLayout.isErrorEnabled = false
true
}
}
}

Тут прихована досить проста логіка. Ви завертаєте ваші EditText в Вами, поєднуєте ці потоки даних і слухаєте їх останні зміни, застосовуючи логіку валідації до кожного з них.

Якщо ми не будемо прив'язуватися до самої логіці валідації, то виходить наступна картина.

Плюси:

Мінуси:

Звичайно ж, ми хочемо надавати нашому користувачеві максимально гладкий досвід використання при взаємодії з необхідними (нам) полями.

План валідації

Перед тим як почати, давайте складемо невелику карту-план, що нам необхідно зробити, щоб реалізувати максимально дружню валідацію полів:

  1. Якщо користувач відкрив екран логіна/реєстрації, то він не повинен бачити ніяких помилок — ніякої інформації в поля ще не надходило, тому нам не про що скаржитися.
  2. Якщо користувач тільки почав вводити дані в полі, то ми не повинні показувати ніякої помилки. Якщо покажемо, то тільки даремно відірвемо користувача від процесу введення (ми взагалі повинні бути дуже вдячними, що користувач довіряє нашого додатком настільки, що вирішив поділитися своєю персональною інформацією з нами).
  3. Користувач закінчив вводити дані в поточний поле і перейшов до наступного? Тепер-то ми і повинні перевірити поточне поле на утримання помилок.
  4. Користувач повернувся на поле, яке містить помилку і почав її виправляти? Ми повинні приховати помилку, тому що користувач досить розумний, і ми не повинні набридати йому цією помилкою.

З інженерної точки зору, ми хочемо код без колбэков, який досить легко читати.

Реалізація

Якщо ви уважно читали план, описаний вище, то, напевно, помітили, що, як інженер, ви повинні якось маніпулювати фокусом EditText'а і подіями введення . Коли наше уявлення у фокусі, то ми повинні відключити для нього валідацію. Коли фокус воно втратило, то перевірка повинна бути застосована і повинні бути показані помилки (якщо в цьому є необхідність). Якщо користувач повернувся до цього поданням (воно знову у фокусі) і почав виправляти помилку (подання отримує події вводу), то ми повинні приховати помилку.

Давайте подивимося бібліотеку RxBinding і спробуємо знайти методи, які допоможуть вирішити цю задачу.

В першу чергу, там є метод focusChanges(View view), який знаходиться в класі RxView. Його призначення, як нескладно здогадатися, — спостереження за подіями фокуса. Якщо ви зараз спробуєте використовувати цей метод, то помітите, що ви отримаєте івент фокуса моментально, і це буде порушенням нашого 1-го правила в плані, який ми склали вище. В бібліотеці RxBinding є метод skipInitialValue(), який дозволить нам пропустити цей перший івент під час підписки.

Тепер ми повинні застосовувати логіку валідації, коли наш EditText втрачає фокус. Тут нам допоможе звичайний map(), використовуючи який ми можемо визначити момент, коли наше уявлення втратило фокус, і виконати блок валідації (лямбда-вираз, тому що, звичайно ж, кожне поле може мати свій вид перевірки).

fun validateInput(inputView: TextView, body: () -> Unit): Disposable {
 return RxView.focusChanges(inputView) // Listen for events focus
 .skipInitialValue() //Skip first emission that occurs when we subscribe.
 .map {
 if (!it) { // If view lost focus, lambda (our check logic) should be applied
body()
}
 return@map it
 }.subscribe { }
}

Тепер нам необхідно вирішити 4-й пункт нашого плану. Ми повинні ховати помилку, коли користувач повернувся до поля і почав виправляти помилку. Для цього нам необхідно передати посилання на TextInputLayout і слухати зміни тексту. Бібліотека RxBinding надає метод textChanges(View view), який знаходиться в класі RxTextView. Також у той час, як користувач набирає текст, нам необхідно:

Тому оновлена версія нашого методу validateInput може виглядати наступним чином:

fun validateInput(inputLayout: TextInputLayout, inputView: TextView, body: () -> Unit): Disposable {
 return RxView.focusChanges(inputView)
 .skipInitialValue() // Listen for focus events.
 .map {
 if (!it) { // If view lost focus, lambda (our check logic) should be applied.
body()
}
 return@map it
}
 .flatMap { hasFocus ->
 return@flatMap RxTextView.textChanges(inputView)
.skipInitialValue()
 .map { 
 if (hasFocus && inputLayout.isErrorEnabled) inputLayout.disableError() 
 } // Disable error when user typing.
 .skipWhile({ hasFocus }) // don't react text on change events when we have a focus.
 .doOnEach { body() }
}
 .subscribe { }
}

У Kotlin, якщо функція приймає лямбда-вираз, то її можна позначити як inline, щоб її тіло було скопійовано в місце виклику. Тому повне рішення може виглядати так:

inline fun validateInput(inputLayout: TextInputLayout, inputView: TextView, crossinline body: () -> Unit): Disposable {
 return RxView.focusChanges(inputView)
 .skipInitialValue() // Listen for focus events.
 .map {
 if (!it) { // If view lost focus, lambda (our check logic) should be applied.
body()
}
 return@map it
}
 .flatMap { hasFocus ->
 return@flatMap RxTextView.textChanges(inputView)
.skipInitialValue()
 .map { 
 if (hasFocus && inputLayout.isErrorEnabled) inputLayout.isErrorEnabled = false 
 } // Disable error when user typing.
 .skipWhile({ hasFocus }) // don't react text on change events when we have a focus.
 .doOnEach { body() }
}
 .subscribe { }
}

Давайте подивимося на валідацію введення, яку ми зараз реалізували:

На повну реалізацію:

package tech.gen.rxinputvalidation

import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

 private var disposable = CompositeDisposable()

 override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R. layout.activity_main)
}

 override fun onStart() {
super.onStart()
validateFields()
}

 override fun onStop() {
super.onStop()
 if (!disposable.isDisposed) disposable.clear()
}

 private fun validateFields() {
 with(disposable) {
clear()
 add(validateInput(nameInputLayout, nameEt, { isNameValid(nameEt.text.toString()) }))
 add(validateInput(surnameInputLayout, surnameEt, { isSurnameValid(surnameEt.text.toString()) }))
 add(validateInput(emailInputLayout, emailEt, { isEmailValid(emailEt.text.toString()) }))
 add(validateInput(passwordInputLayout, passwordEt, { isPasswordValid(passwordEt.text.toString()) }))
}
}

 private fun isNameValid(name: String) {
 if (name.isEmpty()) {
 nameInputLayout.isErrorEnabled = true
 nameInputLayout.error = getString(R. string.name_validation_error)
 } else {
 nameInputLayout.isErrorEnabled = false
}
}

 private fun isSurnameValid(surname: String) {
 if (surname.isEmpty()) {
 surnameInputLayout.isErrorEnabled = true
 surnameInputLayout.error = getString(R. string.surname_validation_error)
 } else {
 surnameInputLayout.isErrorEnabled = false
}
}

 private fun isEmailValid(email: String) {
 if (!email.contains("@", true)) {
 emailInputLayout.isErrorEnabled = true
 emailInputLayout.error = getString(R. string.email_validation_error)
 } else {
 emailInputLayout.isErrorEnabled = false
}
}

 private fun isPasswordValid(password: String) {
 if (password).length < 4) {
 passwordInputLayout.isErrorEnabled = true
 passwordInputLayout.error = getString(R. string.password_validation_error)
 } else {
 passwordInputLayout.isErrorEnabled = false
}
}
}

Висновки

З допомогою RxJava (окрема подяка RxBinding) ми можемо досить просто реалізувати дружній для користувача валідацію полів, не жонглюючи великою кількістю колбэков. Логіку перевірки, звичайно ж, краще винести в окремий клас і покрити його тестами, але це вже інша історія.

Опубліковано: 08/06/18 @ 10:17
Розділ Різне

Рекомендуємо:

DOU Проектор: CleverStaff — сервіс для автоматизації рекрутингу
Не малюванням єдиним: навіщо дизайнеру розуміти бізнес замовника і впливати на продукт
Я, девелопер
Хто, де і як буде вчити тестувальників в Києві в 2026 році
Финстрип за Травень 2018. Просів з 80 до 60К