Работа с формами — очень распространенная задача, с которой мы сталкиваемся как разработчики мобильных приложений. С формами приходит и валидация форм. Необходимо показывать соответствующие предупреждения пользователям, когда они заполняют форму не так, как должны были. Для этого нам необходимо написать определенную логику валидации. Однако декларативный подход к UI в flutter приводит к тому, что многие разработчики пишут логику валидации прямо в коде UI, а это плохо, очень плохо.
Пишете ли вы логику валидации в пользовательском интерфейсе? Если да, то эта статья для вас. Я расскажу о том, как правильно работать с валидацией форм, которая не просто работает, но и является архитектурно чистой и разумной.
Подход, которым я буду делиться и который я часто использую в своих личных проектах, вдохновлен учебником Reso Coder по Domain-driven design.
Валидация формы в пользовательском интерфейсе (плохой подход)
Давайте посмотрим на сниппет, который проверяет форму с логикой валидации в пользовательском интерфейсе.
class MyCustomForm extends StatefulWidget {
const MyCustomForm({super.key});
@override
MyCustomFormState createState() {
return MyCustomFormState();
}
}
class MyCustomFormState extends State<MyCustomForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) { // Validation Logic
return 'Please enter some text';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
}
},
child: const Text('Submit'),
),
),
],
),
);
}
}
Как вы можете видеть, именно так кто-то будет выполнять валидацию формы со всей логикой валидации прямо в пользовательском интерфейсе. Это определенно работает так, как задумано, но это плохо с архитектурной точки зрения.
Многие могут возразить, что для чего-то настолько простого, как это, нет необходимости заботиться об архитектуре и делать это так, как показано выше, но как разработчики, мы должны иметь этику разработчика и делать вещи правильным образом. Никогда не должно быть места для простого обходного пути.
Если вы продолжаете читать эту статью, я полагаю, вы согласны со мной. Теперь давайте рассмотрим, как выполнить валидацию формы — правильным способом.
Прежде всего, давайте добавим некоторые зависимости и dev-зависимости, которые понадобятся нам для этого проекта. В качестве зависимостей вам понадобятся dartz, equatable, flutter_bloc и freezed_annotation. А также build_runner и freezed в качестве dev-зависимостей.
Слой домена
Давайте посмотрим на то, что мы часто видим в простых проектах, где реализован метод login()
.
Future<void> login({
required String email,
required String password,
}) async {}
Обычно это сигнатура метода login()
. Хотя мы определенно можем сделать что-то с методом, показанным выше, это не помешает некоторым разработчикам в будущем все испортить.
Например, можно вызвать метод следующим образом
await login(
email: "email123",
password: "pw",
);
Синтаксически это нормально. В том, что мы здесь сделали, нет ничего плохого. Но с точки зрения логики, email не может быть просто email123
, а пароль не должен состоять из двух символов. Итак, как мы можем решить эту проблему, заставляя нас передавать только email-подобный ввод в email и то же самое для пароля?
Ответом на этот вопрос является создание классов EmailAddress
и Password
. Наличие класса для каждого атрибута позволит нам написать некоторую пользовательскую логику, которую мы увидим через некоторое время.
class EmailAddress {
const EmailAddress(this.value);
final String value;
}
Теперь у нас есть класс EmailAddress
со свойством value типа String. Но это ничем не отличается от того, что я показал ранее, потому что свойство value
по-прежнему является String
и ему можно передать любую строку. Если мы подумаем о свойстве value
, то оно может быть либо законным значением String, либо незаконным String. Например, если значение свойства для класса EmailAddress
равно 'abc@gmail.com'
, то это легитимное строковое значение для email. Но если свойство value
имеет вид 'abc'
, то это нелегитимное строковое значение.
Итак, какой тип данных мы можем использовать для значения свойства, чтобы сказать, что оно может иметь либо легитимное значение, либо нелегитимное? И ответом на этот вопрос будет использование типа Either из пакета dartz.
Either
— это сущность, значение которой может быть двух разных типов, называемых левым и правым. По соглашению,Right
предназначен для случая успеха, аLeft
— для случая ошибки. Это распространенный паттерн в функциональном сообществе.
class EmailAddress {
const EmailAddress(this.value);
final Either<ValueFailure, String> value;
}
Теперь, используя тип Either
, мы можем сказать компилятору, что значение может иметь один из двух типов. В данном случае значение свойства может быть либо типа ValueFailure
, либо типа String
.
Итак, что такое ValueFailure
? ValueFailure
— это просто союз для представления недопустимой строки электронной почты. Мы можем создавать союзы с помощью пакета freezed.
part 'value_failure.freezed.dart';
@freezed
class ValueFailure with _$ValueFailure {
const factory ValueFailure.invalidEmail({
required String failedValue,
}) = _InvalidEmail;
}
Теперь выполните команду
flutter pub run build_runner watch --delete-conflicting-outputs
Это должно сгенерировать кучу кода, и все ошибки должны исчезнуть.
Теперь, если мы посмотрим на наш класс EmailAddress
, все выглядит хорошо, за исключением логики проверки. Нам каким-то образом нужно добавить логику проверки, пока мы находимся на уровне домена. Было бы здорово, если бы мы могли запускать проверку сразу после инстанцирования класса EmailAddress
.
К счастью, мы можем сделать это с помощью фабрики-конструктора. Сначала мы создадим приватный конструктор, чтобы его нельзя было использовать для создания экземпляра класса EmailAddress
. А затем добавим фабричный конструктор, который будет действовать как конструктор по умолчанию. Конструктор фабрики будет принимать на вход строку, которая будет подвергаться валидации.
Для проверки электронной почты мы будем использовать Regex.
class EmailAddress extends Equatable {
factory EmailAddress(String input) =>
EmailAddress._(_validateEmailAddress(input));
const EmailAddress._(this.value);
final Either<ValueFailure, String> value;
@override
List<Object?> get props => [value];
}
Either<ValueFailure, String> _validateEmailAddress(String input) {
const emailRegex =
r"""^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+.[a-zA-Z]+""";
if (RegExp(emailRegex).hasMatch(input)) {
return right(input);
} else {
return left(
ValueFailure.invalidEmail(failedValue: input),
);
}
}
Мы видим, что если мы попытаемся сделать что-то вроде
var email = EmailAddress('abc@gmail.com');
то сразу же переданная строка пройдет через нашу логику проверки и либо вернет ValueFailure
, либо String
. Также обратите внимание, что мы расширяем наш класс EmailAddress
с помощью Equatable, чтобы обеспечить равенство значений, а не равенство ссылок, что требует переопределения геттера props
.
Вот и все.
Теперь мы сделаем то же самое для класса Password
. Отличаться будет только логика валидации, остальное останется неизменным. Кроме того, нам нужно будет добавить еще один перенаправляющий конструктор в наш класс объединения ValueFailure
. Таким образом, наши value_failure.dart
и password.dart
будут выглядеть следующим образом:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'value_failure.freezed.dart';
@freezed
class ValueFailure with _$ValueFailure {
const factory ValueFailure.invalidEmail({
required String failedValue,
}) = _InvalidEmail;
const factory ValueFailure.shortPassword({
required String failedValue,
}) = _Password;
}
class Password extends Equatable {
factory Password(String input) => Password._(_validatePassword(input));
const Password._(this.value);
final Either<ValueFailure, String> value;
@override
List<Object?> get props => [value];
}
Either<ValueFailure, String> _validatePassword(String input) {
if (input.length >= 5) {
return right(input);
} else {
return left(
ValueFailure.shortPassword(failedValue: input),
);
}
}
Презентационный слой
Теперь мы начнем работать с нашим презентационным слоем. Позвольте мне показать вам конечный результат нашего приложения.
Мы начнем с создания нашего блока.
Класс событий
Рассматривая события или действия, которые пользователи могут выполнять для взаимодействия с пользовательским интерфейсом, события могут быть следующими
-
EmailChanged (когда пользователь добавляет символ ввода в текстовое поле электронной почты)
-
PasswordChanged (когда пользователь добавляет символ ввода в текстовое поле пароля)
-
ObscurePasswordToggled (когда пользователь нажимает на опцию show password или hide password в текстовом поле пароля)
-
LoginSubmitted (когда пользователь нажимает на кнопку входа).
Мы создадим класс union для представления событий в нашем проекте.
part of 'login_form_bloc.dart';
@freezed
class LoginFormEvent with _$LoginFormEvent {
const factory LoginFormEvent.emailChanged(String emailString) = _EmailChanged;
const factory LoginFormEvent.passwordChanged(String passwordString) =
_PasswordChanged;
const factory LoginFormEvent.obscurePasswordToggled() =
_ObscurePasswordToggled;
const factory LoginFormEvent.loginSubmitted() = _LoginSubmitted;
}
Класс State
Какое поле должно присутствовать в классе state? Нам нужно передать обратно в наш пользовательский интерфейс подтвержденный email и пароль.
Кроме того, при нажатии кнопки входа в систему появляется индикатор загрузки, пока не будет получен ответ от бэкенда. Поэтому нам понадобится поле isSubmitting
boolean
, которое по умолчанию будет иметь значение false.
Мы не хотим, чтобы логика проверки срабатывала сразу после запуска нашего приложения. Только после нажатия кнопки входа и если комбинация email и пароля недействительна, мы хотим, чтобы начали отображаться предупреждения. Поэтому нам также необходимо поле showErrorMessage
boolean
, которое по умолчанию будет иметь значение false.
У нас также есть опция показать/скрыть пароль, и по умолчанию пароль будет оставаться скрытым (затуманенным). Поэтому нам нужно поле obscurePassword
boolean
, которое по умолчанию будет true.
После нажатия на кнопку входа в систему мы получим либо успех, либо неудачу. В этом демо мы покажем закусочную, если мы войдем в систему, имея действительный email и пароль. В реальном приложении вы покажете закусочную с соответствующим сообщением, если пользователь не сможет войти в систему, или перейдете на главный экран в случае успешного входа. Поскольку процесс входа будет либо успешным, либо неудачным, нам понадобится поле authSuccessOrFailure
Either<AuthFailure, Unit>?
. Это поле является нулевым, поскольку при запуске приложения мы не можем определить, успешно или нет проходит процесс входа в систему. Поэтому, если authSuccessOrFailure
равно null, это означает, что мы еще не пытались войти в систему.
part of 'login_form_bloc.dart';
@freezed
class LoginFormState with _$LoginFormState {
const factory LoginFormState({
required EmailAddress emailAddress,
required Password password,
@Default(false) bool isSubmitting,
@Default(false) bool showErrorMessage,
@Default(true) bool obscurePassword,
Either<AuthFailure, Unit>? authFailureOrSuccess,
// Unit comes from Dartz package and is equivalent to void.
}) = _LoginFormState;
factory LoginFormState.initial() => LoginFormState(
emailAddress: EmailAddress(''),
password: Password(''),
);
}
AuthFailure
также является объединенным классом для представления неудачи аутентификации, а поскольку процесс аутентификации может не пройти по нескольким причинам, мы используем объединенный класс.
part 'auth_failure.freezed.dart';
@freezed
class AuthFailure with _$AuthFailure {
const factory AuthFailure.invalidEmailAndPasswordCombination() =
_InvalidEmailAndPasswordCombination;
const factory AuthFailure.serverError() = _ServerError;
}
Поскольку ранее мы выполнили команду build_runner watch, нам не нужно снова запускать команду build. По какой-то причине, если команда build_runner watch перестала выполняться, вам нужно будет запустить ее снова.
flutter pub run build_runner watch --delete-conflicting-outputs
Блок
Здесь располагается логика презентации.
part 'login_form_bloc.freezed.dart';
part 'login_form_event.dart';
part 'login_form_state.dart';
class LoginFormBloc extends Bloc<LoginFormEvent, LoginFormState> {
LoginFormBloc() : super(LoginFormState.initial()) {
on<LoginFormEvent>(
(event, emit) async {
await event.when<FutureOr<void>>(
emailChanged: (emailString) => _onEmailChanged(emit, emailString),
passwordChanged: (passwordString) =>
_onPasswordChanged(emit, passwordString),
obscurePasswordToggled: () => _onObscurePasswordToggled(emit),
loginSubmitted: () => _onLoginSubmitted(emit),
);
},
);
}
void _onEmailChanged(Emitter<LoginFormState> emit, String emailString) {
emit(
state.copyWith(
emailAddress: EmailAddress(emailString),
authFailureOrSuccess: null,
),
);
}
void _onPasswordChanged(Emitter<LoginFormState> emit, String passwordString) {
emit(
state.copyWith(
password: Password(passwordString),
authFailureOrSuccess: null,
),
);
}
void _onObscurePasswordToggled(Emitter<LoginFormState> emit) {
emit(state.copyWith(obscurePassword: !state.obscurePassword));
}
Future<void> _onLoginSubmitted(Emitter<LoginFormState> emit) async {
final isEmailValid = state.emailAddress.value.isRight();
final isPasswordValid = state.password.value.isRight();
if (isEmailValid && isPasswordValid) {
emit(
state.copyWith(
isSubmitting: true,
authFailureOrSuccess: null,
),
);
// Perform network request to get a token.
await Future.delayed(const Duration(seconds: 1));
}
emit(
state.copyWith(
isSubmitting: false,
showErrorMessage: true,
// Depending on the response received from the server after loggin in,
// emit proper authFailureOrSuccess.
// For now we will just see if the email and password were valid or not
// and accordingly set authFailureOrSuccess' value.
authFailureOrSuccess:
(isEmailValid && isPasswordValid) ? right(unit) : null,
),
);
}
}
Теперь осталось только создать два текстовых поля и обернуть их виджетом BlocBuilder.
Чтобы проверить оставшийся исходный код пользовательского интерфейса, я бы предложил вам взглянуть на это репозиторий, который содержит исходный код всего проекта.
Заключение
В этой статье было показано, как можно выполнить проверку формы во Flutter, используя правильные техники и не имея никакой бизнес-логики в пользовательском интерфейсе. Существует несколько других способов сделать то же самое. Одним из них является пакет formz.
Я надеюсь, что после прочтения этой статьи те, кто писал свою логику валидации в пользовательском интерфейсе, теперь будут располагать ее в доменном слое, сохраняя ваш презентационный слой аккуратным.
Если вы хотите увидеть несколько проектов Flutter с правильной архитектурой, следите за мной на GitHub. Я также активен в Twitter @b_plab.
Исходный код
Мои социальные сети:
-
GitHub
-
LinkedIn
-
Twitter
До следующего раза, счастливого кодинга!!! 👨💻
— Биплаб Дутта