Как стать автором
Обновить

Как я научился не волноваться и полюбил управление состояниями Flutter. MVI и Clean Code в комплекте

Время на прочтение13 мин
Количество просмотров10K

Цель статьи — показать, как сделать ваш проект на Flutter более понятным для усовершенствования и удобным в сопровождении. Текст может быть полезным как для тех, кто только начинает изучать Flutter, так и более продвинутых пользователей, так как здесь мы рассмотрим актуальные подходы к разработке.

Если вы читали мою прошлую статью, или позапрошлую, то, наверное, уже знаете что я nerd vulgaris увлекаюсь программированием всякого. В комментариях к одной из прошлых статей было высказана идея провести параллель между приложением на Flutter и нативным приложения для Android на Kotlin c использованием view model, live data, view binding и найти аналоги привычным по Kotlin языковым средствам.

Для этого материала я решил выбрать немного необычный формат “туториала”. Назовем его “кодлайф” — по аналогии со screenlife.  По правилам screenlife действие будет происходить на экране, декорации — коммиты Github и страницы Story Reader (codingstories.io),  который делает их интерактивными. В центре внимания повествования — развитие обычного “хеллоу ворлд” кода для Flutter.

На старте — это счетчик, “хелло ворлд” для Flutter-мира, по коммитам видно, как он  изменяется и становится другим. Внешне на экране ничего не меняется, изменяется только внутренняя суть кода, а результат выполнения и дизайн остаются неизменными. У него, по  заветам  Clean Code, от пользовательского интерфейса отделяется состояние и логика, общение между ними налаживается по правилам MVI.  

Также в этот праздник метамодерна хорошо вписывается новый подход к просмотру и изучению — проделать код ката по этой статье: Story Reader (codingstories.io). Ссылки на соответствующие страницы codingstory (код ката) вы найдете в начале  соответствующих пунктов.

Кратко о теории

Clean Code — что мы имеем в виду

Я предполагаю, что вы уже создавали приложения, используя Flutter, Native или что-то еще.

Представим, что результат нам нужно получить быстро. Обычно мы не сосредотачиваемся на том, как работает поток данных, написании независимых слоев данных, разделении кода, реальном использовании ООП, тестировании, масштабировании функций и т.д. Все, что нам нужно, — как можно скорее завершить разработку и выпустить наш код в продакшн. Очень характерный для стартапов подход “фигак фигак и в продакшн” (личное оценочное суждение), усугубленным примением паттерна “и так сойдет”.

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

Если вы не понаслышке имеете представление о нюансах, описанных выше, тогда точной  понимаете что значит эта цитата:

«На первые 90 процентов кода уходит 10 процентов времени, потраченного на разработку. На оставшиеся 10 процентов кода уходит оставшиеся 90 процентов»

Том Каргилл, Bell Labs

Если вы понимаете, что  вносить изменение в систему — дорого, лучше заранее заложить возможность изменений. Вот это и зовется “The Clean Architecture”.

Количество статей на эту тему неимоверно велико: тыц тыц тыц. Можно припасть к первоисточнику — в 2012 году была опубликована статья The Clean Architecture.

Ссылка на источник иллюстрации: https://izj7qqsm1dorsnetlz95uq-on.drv.tw/codelabs/clean-framework-introduction/#0
Ссылка на источник иллюстрации: https://izj7qqsm1dorsnetlz95uq-on.drv.tw/codelabs/clean-framework-introduction/#0

Если коротко: Чистая архитектура является наиболее мощным решением для построения систем, в которых несколько команд могут работать над независимыми слоями кода. Для чистой архитектуры характерны такие качества, как масштабируемость для добавления/удаления компонентов; проверяемые, легко заменяемые компоненты; простота поддержки в  любой жизненной стадии приложения.

Теперь о MVVM, MVC, MVP, MVI, MVU и прочих

Я склонен к обобщению, согласно которому эти шаблоны (MVVM, MVC, MVP, MVI, MVU и т.д.) по общему признаку называются шаблонами потока данных. Что более или менее точно описывает их цель — вывести данные на экран, будь то однонаправленный или двунаправленный поток. Если вывести наименование из концепции "объекта, который их соединяет", то это может быть сущность Presenter, Controller, ViewModel, а сущность потока данных — Cubit, StateNotifier, ChangeNotifier, BLOC и т.д. 

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

Мой opus magnum MVVM, MVC, MVP, MVI см. в предыдущей простыне. Аналогии и сравнения я провожу из хорошо знакомого мне мира нативной разработки под Android.

Выбор примера для первого шага

Сначала я хотел было реализовать пример из этой или этой статьи, но посчитал, что он будет не совсем показателен. Все-таки, пример “хелло ворлд” flutter со счетчиком неплох и показывает двунаправленный поток событий — т.е. от юзера и к юзеру. 

Итак, реализуем счетчик с отдельным счетчиком для четных чисел, приводя код к MVI и руководствуясь Clean Code. 

К этой статье прилагается кодинг ката — см. на Coding Stories. Далее в рассказе под каждым шагом идут ссылки на репозиторий Github и соответствующие страницы codingstories.

Для единообразия и удобства сравнения кода (и потому что могу) написал как код ката на:

Рассмотрим нюансы Riverpod

Начнем

В начале каждого пункта будут ссылки на коммит на Github и соответствующую страницу codingstory (код ката): см. на github / см. как Сodingstory (codingstories.io)

Android studio по умолчанию создаст проект со счетчиком. Его мы используем дальше. Добавим зависимость от Riverpod.

Отделим состояние и логику от пользовательского интерфейса

см. на github / см. как Сodingstory (codingstories.io)

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

Начнем изменять. Уберу управление состоянием из виджетов.

А что будет если запустить приложение?

см. на github / см. как Сodingstory (codingstories.io)

Это практически то же самое базовое приложение счетчика, что и созданное Flutter по умолчанию, но со всеми удаленными  состояниями и логикой.

MyHomePage — это StatelessWidget вместо StatefulWidget. А что будет если запустить приложение?

Проверьте сами — приложение перестало работать, нажатия на FAB ни к чему не приводят. Ничего удивительного, если в onPressed кнопки FAB пусто:

Спасибо, кэп!

см. на github / см. как Сodingstory (codingstories.io)

Чтобы отделить состояние и логику от представления, нужно это представление где-то отдельно хранить. 

Класс модели представляет состояние какой-то части вашего приложения. В нашем случае он будет отображать состояние счетчика. Мы будем использовать этот класс для хранения и отображения текущего значения счетчика в середине экрана:

//class to represent the state
class CounterModel {
 const CounterModel(this.count);
 //state is immutable
 final int count;
}

Этот объект является только оберткой для целого числа. Фактически, мы можем просто использовать целое число напрямую. Однако в данном случае мы пойдем другим путем. Обратите внимание, что count это final. Это значит, что состояние неизменно. Альтернативный вариант можно глянуть в демонстрационном примере к Riverpod здесь.

На мой взгляд, неизменяемое состояние — это хорошо. Предлагаю ознакомится с этой статьей, чтобы понять почему. Каждый раз, когда состояние изменяется, мы создаем совершенно новый CounterModel объект. Это может показаться дополнительной работой, но зато предотвращает некоторые тонкие ошибки, которые могут возникнуть из-за изменения внутренних значений изменяемого состояния.

Также есть конструктор const, который, помимо принудительной неизменяемости, также позволяет вам объявлять оптимизированные константы времени компиляции. Неплохое чтиво по этой теме здесь.

Заканчиваем с лирикой, возвращаемся к практике.

Мы создали Counter Model класс для представления состояния счетчика. Теперь нам также нужен класс для управления состоянием, в частности для хранения его текущего значения и увеличения этого значения, когда пользователь нажимает кнопку FAB (+):

class CounterNotifier extends StateNotifier<CounterModel> {
 CounterNotifier() : super(_initialValue);
 static const _initialValue = CounterModel(0);
  void increment() {
   state = CounterModel(state.count + 1);
 }
}

Пройдемся по CounterNotifier. Он наследник StateNotifier класса управления состоянием (state management class). StateNotifier  похож на включенный во Flutter по умолчанию ValueNotifier или даже на Cubit из пакета Bloc без базовых потоков. Этот тип управления неизменным состоянием (immutable state management) отлично подходит к нашей задаче, и в дальнейшем я предпочитаю использовать именно его, плюс неизменяемое состояние, для предотвращения неприятных сюрпризов.

State Notifier под капотом имеет единственную внутреннюю переменную с именем; state, которая содержит текущее состояние, в нашем случае это экземпляр CounterModel. Мы можем изменить значение state, но CounterModel неизменен. Это значит, что для изменения  CounterModel.count нам нужно создать новый объект.

Мне необходимо инициализировать state, это сделаем передав начальное значение super конструктору. Наш приведенный код инициализирует counter значением 0.

Извне может быть вызвана функция increment, которая выполняет замену state новым экземпляром CountModel, внутреннее count значение которого на единицу больше, чем у последний экземпляр CountModel.

При изменении state StateNotifier уведомляет любые объекты, которые его слушают.

Make it run

см. на github / см. как Сodingstory (codingstories.io)

А теперь наступило время сделать наше приложение рабочим. Чтобы магия  Riverpod заработала, нужно обернуть все приложение ProviderScope виджетом:

runApp(
 const ProviderScope(child: MyApp()),
);

Создаем глобального провайдера.

В main.dart добавляю следующую переменную верхнего уровня:

final _counterProvider =
   StateNotifierProvider<CounterNotifier, CounterModel>((ref) {
 return CounterNotifier();
});

Поскольку _counterProvider — это глобальная константа, мы можем получить к ней доступ из любого места (без необходимости в контексте сборки, как это делал Provider, если вы пробовали им пользоваться). Обычно в программировании вы слышите, что не следует использовать глобальные переменные. Одна из причин заключается в том, что легко получить незаметные ошибки, если разные части вашего кода будут изменять переменную. Однако эта глобальная переменная неизменна, поэтому нет опасности ее изменить. Еще одна причина быть осторожным с глобальными переменными (и константами) — это создание проблем с зависимостями. 

В Riverpod есть много разных провайдеров. В этом примере мы используем  StateNotifierProvider потому что состояние находится в StateNotifier class (CounterNotifier). 

MyHomePage в настоящее время является наследником StatelessWidget. Изменим StatelessWidget на ConsumerWidget, чтобы получить объект «ref». Этот объект позволяет нам взаимодействовать с провайдерами, будь то виджет или другой провайдер.

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

Добавим следующую строку внутри build метода MyHomePage (прямо перед return оператором):

final counter = ref.watch(_counterProvider).count;

.watch будет прослушивать изменения в _counterProvider.state, который является экземпляром CounterModel. И соответственно мы можем получить доступ к полю .count,в котором CounterModel хранит значение счетчика.

Для отображения состояния счетчика изменим Text виджет:

Text('Count: $counter'),

Обновление счетчика. Когда пользователь нажимает кнопку +, нужно вызвать метод increment() в нашем классе управления состоянием счетчика (CounterNotifier).

Заменим onPressed обратный вызов (коллбек, callback) в FloatingActionButton следующим:

onPressed: () => ref.read(_counterProvider.notifier).increment(),

У объекта ref есть метод read. В отличие от watch метода read дает вам ссылку на ваш класс управления состоянием ( CounterNotifier), не отслеживая изменения в состоянии. Причина, по которой это важно, заключается в том, что watch за CounterNotifier вызовет  повторный запуск метода build виджета при изменении состояния. Если виджет, в данном случае FAB ( FloatingActionButton), никак не меняется визуально, как-то  бесполезно перерисовывать его. Тем не менее, текущий метод build уже используется и будет вызван, так как внутри виджета есть вызов watch. Это означает, что все виджеты в этом методе build (включая FAB) все равно будут перестроены. Мы оптимизируем это потом. Попробуем запустить приложение. Счетчик начинает реагировать на нажатия. 

Попробуем добавить фичу

см. на github / см. как Сodingstory (codingstories.io)

Пускай приложение уже работает, но нет предела совершенству. Попробуем добавить фичу — пусть у нас будет еще один счетчик, который будет показывать какое количество четных чисел увидел пользователь. Решать задачу будем в лоб, как будто у нас KPI на количество строк. Хранить значение и добавлять будем в новый счетчик, назовем его EvenCounter, каждый раз, когда Counter будет делится на два без остатка.

А дальше заводим  EvenCounterNotifier и EvenCounterModel по аналогии с Counter. Как провайдер по полной аналогии с _counterProvider.

final _evenCounterProviderAsSeparateState =
   StateNotifierProvider<EvenCounterNotifier, EvenCounterModel>((ref) {
 return EvenCounterNotifier();
});

Так же заведем провайдера свойства состояния _isEvenProvider.

final _isEvenProvider = Provider<bool>((ref) {
 final counter = ref.watch(_counterProvider);
 return (counter.count % 2 == 0);
});

Он будет передавать на четное или нечетное число изменение значения _counterProvider.

Ранее мы заметили, что не оптимально перерисовывать виджет каждый раз, когда будет изменяться провайдер. Так как новые провайдеры передают значения в два раза реже, чем _counterProvider, то для новых провайдеров создадим новые виджеты:CounterIsEven() и EvenCounter(). Их система может перерисовывать только когда будет передавать значения соответствующий провайдер.

Цепочки провайдеров

см. на github / см. как Сodingstory (codingstories.io)

А теперь в этот праздник кода добавим немного более продвинутого и короткого. Для этого углубим наше умение выстраивать цепочки провайдеров.

//  StateProvider and even numbers counter in a state provider
final _evenCounterProvider = Provider<int>((ref) {
 ref.listen<bool>(_isEvenProvider, (previous, next) {
   if (next) {
     ref.state++;
   }
 });
 return 0;
});

Этот провайдер делает почти то же самое, что и _evenCounterProviderAsSeparateState, но без лишнего кода и полностью автоматически. Легко увидеть, что как только меняется значение провайдера  _isEvenProvider на true, то к значению нашего провайдера (_evenCounterProvider) добавляется единица. Оказывается, так тоже можно.

А теперь кастанем на этом коде заклинание Clean Code (Clean Architecture)

см. на github / см. как Сodingstory (codingstories.io)

Уберем из файлов классов все лишнее и разместим их в соответствующих пакетах (каталогах, package), называя их по связи с Clean Architecture:

  • lib/main.dart — убираем лишнее

  • lib/presentation/widgets/counter_widget.dart — группируем виджеты в пакет /presentation/widgets/

  • lib/presentation/pages/home_page.dart — в пакет страницы (аналог  fragment из Android), а вся структура приложения будет напоминать подход Single Activity 

  • lib/presentation/manager/bindings/counter_view_binding.dart — выделяем биндинг провайдеров в отдельный файл в пакет presentation/manager/bindings

  • lib/data/models/counter_model.dart — переносим модели на уровень данных

Добавим оздоровительного MVI

Добавим View State’ов

Для полного представления MVI нам нужно добавить состояния представления, в Kotlin мы добавляем его в запечатанный (sealed) класс. 

Давайте еще раз посмотрим на изящество sealed классов Kotlin:

sealed class MainFragmentUiStatesModel {
   object Odd : MainFragmentUiStatesModel()
   object Even : MainFragmentUiStatesModel()
}

Для Kotlin аналога посмотрите сюда: см. на github / см. как Сodingstory (codingstories.io)

Состояния описаны в классе MainFragmentUiStatesModel. Он объявлен в виде sealed class. Так сделано из-за того, что каждая из реализаций sealed class сама по себе является полноценным классом. Это означает, что каждый из них может иметь свои собственные наборы свойств независимо друг от друга. При добавлении нового объекта состояния должна быть предусмотрена защита от случайного “отсутствия сопоставления” с состоянием пользовательского интерфейса. 

Вернемся к Flutter: см. на github / см. как Сodingstory (codingstories.io)

«Вложенные» запечатанные классы (nested sealed classes):

@freezed
class ViewState with _$ViewState {
 /// Odd/default state
 const factory ViewState.odd() = _Odd;

 /// Data is loading state
 const factory ViewState.even() = _Even;
}

Наличия так просто описываемых sealed классов уже достаточно, чтобы использовать freezed.

Минутка рекламы: Во второй части с более серьезным примером мы рассмотрим как freezed помогает в работе с БД и json_annotation for data serialization.

Кодогенерация

см. на github / см. как Сodingstory (codingstories.io)

Freezed работает на кодогенерации, нет ничего страшного — Kotlin творит эту же магию точно так же. Одна есть проблема с freezed, ведь его процесс кодогенерации не автоматизирован. Для нас это значит, что прямо сразу, как мы написали наш код, ничего не заработает. Нам нужно не забывать вручную запускать процесс перестройки код базы. Для этого просто напишите в терминале:

'' 'flutter pub run build_runner build' ''

И все необходимые файлы будут сгенерированы и помещены рядом с файлами описаний.

Рендер контракт

см. на github / см. как Сodingstory (codingstories.io)

Аналог для Kotlin находится здесь .

Продолжим приведение вот этого всего к MVI и наименованиям, принятым в части Kotlin. Добавляем контракт для View, отвечающий за состояния и логику их отображения. Методы для переключения View в разные состояниях определены в контракте и описаны во View, реализующей ViewStatesRenderContract.(это mixin). Сами состояния находятся во ViewsState. 

Где проблема и почему я внес эти изменения

«Наши провайдеры становятся раздутыми божественными классами с разбросанной повсюду логикой состояния и бизнеса»

Где-то в Reddit

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

На мой взгляд, будет достаточно одного провайдера на страницу (или область), например [ViewModel] (lib / presentation / manager / counter_view_model.dart: 9).Тогда он действительно не может стать большим классом. Он должен содержать только модели этой страницы и некоторые методы.

Таким образом мы можем легко вызывать [notifyListerns](lib/presentation/manager/counter_view_model.dart:47) всякий раз, когда что-то делаем со своими моделями, и контролировать обратные вызовы и обновления, оптимизируя перерисовку виджетов. В дополнение к этому у вас могут быть классы для обслуживания другой логики, которые не имеют ничего общего с провайдером и содержат только бизнес-логику.

И да, вы можете видеть, что порядок виджетов изменяется при изменении состояния.

Заодно, чтобы два раза не вставать, мы добавили логику переключения состояний в наш вью модель lib/presentation/manager/counter_view_model.dart:45)

if (isEven()) {
 setEvenState();
} else
 resetState();

В итоге этот код выглядит простым и понятным: github / Story Reader (codingstories.io)

Полируем Чистым Кодом

См. на github / см. как Сodingstory (codingstories.io)

Нет предела совершенству. Чтоб быть совсем как взрослые (и избежать дополнительных объяснений во втрой части) перенесем данные (counters) туда, где им и положено быть — в источники данных (Data source) в соответствующий Repository.оступ к ним будет предоставлен через соответствующий интерфейс Use Case.

Что же такое “Use Case” или, говоря проще, “сценарий использования”? Uncle Bob в видео-выступлении говорит о книге “Object-Oriented Software Engineering: A Use Case Driven Approach”, которую написал Ivar Jacobson в 1992 году, и о том, как тот описывает Use Case:

“Use case” – это детализация, описание действия, которое может совершить пользователь системы.

Выводы

Не буду повторять поливание сахаром Clean Architecture, MVI и MVVM. Все вместе это, по моему опыту, помогает строить системы, на код которых потом, скажем, через полгода, не стыдно взглянуть без фейспалма.

Во второй части этого материала я в том же формате (MVI, Clean Code) планирую рассмотреть приложение, которое будет обращаться к внешнему API. Что-то вроде того, как это было в одном из моих старых примеров отображения курса валют ( подробнее см. репозиторий). Писалось оно пару лет назад на самодельном BloC и самописном DI с использованием RxDart.

Надеюсь, для кого-то мой опыт, положенный на этот текст, будет полезен. Буду рад вашим рекомендациям, вопросам и, конечно, разумной критике. 

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии5

Публикации

Истории

Работа

Swift разработчик
31 вакансия
iOS разработчик
24 вакансии

Ближайшие события