Всем привет, на связи Surf. В практике разработки Flutter-проектов мы используем внутреннее решение для создания чистой архитектуры под названием Elementary. Если вы уже знакомы с прародителем Elementary — Model-Widget-WidgetModel (MWWM), тогда вам будет всё достаточно знакомо и понятно.  Если нет, то пристегните ремни.

Меня зовут Влад Коношенко, я Flutter-разработчик. В статье разберём, как работает Elementary: на примере покажу, как отделить слой представления от бизнес-логики и сделать кодовую базу более чистой и поддерживаемой.

Пакет доступен на pub.dev. Исходный код можно посмотреть на GitHub.

Что такое Elementary

Elementary — библиотека, которая предоставляет механизмы для написания приложения по правилам Clean Architecture с разделением модулей на чёткие блоки. Опирается на паттерн Model-View-ViewModel (MVVM). В будущих статьях расскажем подробнее, как Elementary устроена внутри. В этой покажем, насколько просто её использовать.

Идея библиотеки — в разделении ответственности классов: UI, бизнес-логики и презентационной логики. Получаются независимые друг от друга модули, имеющие чёткую структуру. Для понимая достаточно запомнить три сущности.

ElementaryWidget — представление, в котором формируется структура экрана.

WidgetModel (WM) содержит презентационную логику.

ElementaryModel содержит бизнес-логику и логику компонента.

Визуальное представление взаимодействия компонентов Elementary
Визуальное представление взаимодействия компонентов Elementary

Разбираем Elementary на примере

Чтобы оценить простоту работы с Elementary, реализуем простое приложение прогноза погоды. Оно состоит из двух экранов: выбора города и прогноза погоды.

Готовое приложение смотрите в моём репозитории на GitHub.

Создание экрана выбора города

Исходник на Github

ElementaryModel 

Для работы с данными используется класс Model. Он имеет параметры AddressService и AppModel. Принимаем их как параметры класса для возможности легко тестировать класс. 

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

Cоздадим публичный метод для доступа к репозиторию. Мы не должны позволять иметь доступ к репозиторию в WM, так как это будет нарушать принцип единой ответственности. Готовый класс выглядит следующим образом:

class SelectAddressModel extends ElementaryModel {
 final ValueNotifier<List<Location>> predictions = ValueNotifier([]);
 final AddressService _addressService;
 final AppModel _appModel;

 SelectAddressModel(this._addressService, this._appModel);

 void onLocationSelected(Location location) {
   _appModel.selectedLocation = location;
 }

 void getCityPrediction(String text) {
   _addressService
       .getCityPredictions(text)
       .then((value) => predictions.value = value);
 }
}

Widget Model

Виджет модель хранит данные, необходимые для UI. В нашем случае — поле поиска и список городов, подходящих под параметры поиска.

WM наследуется от класса WidgetModel. Модель, которую получаем в качестве параметра, передаём в super метод. Далее эта модель доступна как поле класса WidgetModel — как показано в примере. К ней обращаемся через model.

Также подготовим функцию создания WM createSelectAddressWM, которая принимает в себя контекст, чтоб мы смогли взять зависимости через di и передать их в модель. Эта функция будет вызываться на слое UI.

class SelectAddressWM
   extends WidgetModel<SelectAddressScreen, SelectAddressModel> {
 TextEditingController searchFieldController = TextEditingController();
 ValueListenable<List<Location>> get predictions => model.predictions;

 SelectAddressWM(SelectAddressModel model) : super(model);

 @override
 void initWidgetModel() {
   super.initWidgetModel();

   searchFieldController.addListener(onTextChanged);
 }

 void onTextChanged() {
   model.getCityPrediction(searchFieldController.text);
 }

 void onTapLocation(Location e) {
   model.onLocationSelected(e);
   Navigator.of(context).push(MaterialPageRoute<void>(
     builder: (_) => const WeatherScreen(),
   ));
 }
}

SelectAddressWM createSelectAddressWM(BuildContext _) => SelectAddressWM(
     SelectAddressModel(
       AddressService(),
       getIt<AppModel>(),
     ),
   );

ElementaryWidget 

Всю вёрстку оставляем в методе build. Важный момент: при создании виджета передаём функцию создания WM, которую написали на предыдущем шаге.

class SelectAddressScreen extends ElementaryWidget<SelectAddressWM> {
 const SelectAddressScreen({Key? key})
     : super(createSelectAddressWM, key: key);

 @override
 Widget build(SelectAddressWM wm) {
   return Scaffold(
     body: Column(
				//...
       children: [
					//...
         SearchTextField(controller: wm.searchFieldController),
         Expanded(
           child: ValueListenableBuilder<List<Location>>(
             valueListenable: wm.predictions,
             builder: (_, data, __) {
               return data.isEmpty
                   ? const EmptyStateWidget()
                   : SingleChildScrollView(
                       child: Column(
                        //...
                         children: [
                           for (final location in data)
                             LocationTile(
                               location: location,
                               requestString: wm.searchFieldController.text,
                               onClick: wm.onTapLocation,
                             ),
                         ],
                       ),
                     );
             },
           ),
         ),
       ],
     ),
   );
 }
}

Создание экрана прогноза погоды

Исходник на Github

ElemetaryModel

Модель будет содержать сервис погоды и Location для получения информации о том, какая локация выбрана. В методе getWeather выполняем запрос на получение данных о погоде.

class WeatherScreenModel extends ElementaryModel {
 final WeatherService weatherService;
 final Location? _location;

 Location? get location => _location;

 WeatherScreenModel(this.weatherService, this._location);

 Future<List<Weather>?> getWeather() async {
   return weatherService.getWeather(location?.woeid ?? 0);
 }
}

WidgetModel

В данном случае WM — прослойка для предоставления данных виджет-элементу. Содержит лишь один метод повторной попытки загрузки данных в случае ошибки. Для отображения текущего состоянии данных используем EntityStateNotifier. Этот класс входит в состав пакета Elementary и содержит механизмы, основанные на работе ValueNotifier. 

У EntityStateNotifier есть методы content, error, loading. С их помощью устанавливаем текущее состояние без каких-либо дополнительных флагов. Дополнительный абстрактный класс IWeatherWm позволяет определить значения, к которым мы предоставляем доступ со стороны виджета.

class WeatherScreenWM extends WidgetModel<WeatherScreen, WeatherScreenModel> implements IWeatherWm {
 
 final EntityStateNotifier<List<Weather>?> _currentWeather = EntityStateNotifier(null);

 @override
 ListenableState<EntityState<List<Weather>?>> get currentWeather => _currentWeather;

 @override
 String get locationTitle => model.location?.title ?? '';

 @override
 double get topPadding => MediaQuery.of(context).padding.top + 16;

 WeatherScreenWM(WeatherScreenModel model) : super(model);

 @override
 void initWidgetModel() {
   super.initWidgetModel();
   _loadWeather();
 }

 void onRetryPressed() => _loadWeather();

 Future<void> _loadWeather() async {
   try {
     _currentWeather.loading();
     final weather = await model.getWeather();
     _currentWeather.content(weather);
   } on DioError catch (err) {
     _currentWeather.error(err);
   }
 }
}

WeatherScreenWM createWeatherScreenWM(BuildContext _) => WeatherScreenWM(
     WeatherScreenModel(
       WeatherService(),
       getIt<AppModel>().selectedLocation,
     ),
   );

abstract class IWeatherWm extends IWidgetModel {
 ListenableState<EntityState<List<Weather>?>> get currentWeather;

 double get topPadding;

 String get locationTitle;
}

ElementaryWidget

Экран содержит стандартный build, в котором используем виджет EntityStateNotifierBuilder (поставляется в пакете Elementary). 

Этот виджет имеет 3 важных колбека: errorBuilder, loadingBuilder, builder. Каждый соответствует состоянию EntityStateNotifier из нашей модели. Как итог — это позволяет разделить состояния на отдельные виджеты и повысить читаемость кода.

class WeatherScreen extends ElementaryWidget<WeatherScreenWM> {
 const WeatherScreen({
   Key? key,
   WidgetModelFactory<WeatherScreenWM> wmFactory = createWeatherScreenWM,
 }) : super(wmFactory, key: key);

 @override
 Widget build(WeatherScreenWM wm) {
   return Scaffold(
     body: Center(
       child: EntityStateNotifierBuilder<List<Weather>?>(
         listenableEntityState: wm.currentWeather,
         errorBuilder: (_, __, ___) {
           return ErrorScreen(onRetryPressed: wm.onRetryPressed);
         },
         loadingBuilder: (_, __) {
           return const LoadingPage();
         },
         builder: (_, data) {
           return WeatherDetailsPage(
             data: data,
             location: wm.locationTitle,
           );
         },
       ),
     ),
   );
 }
}

Советы из практического применения 

  1. Передавайте в модель все зависимости для получения данных в текущем модуле.

  2. В WM нужно передавать сервисы и обертки для работы с контекстом (theme, dialogs, navigation).

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

  4. Предпочтительнее использовать ValueChangeNotifiers или потоки, чтобы избегать лишних обновлений интерфейса.

  5. Использование интерфейсов для построения UI не позволяет нарушать закон Деметры. Не стоит обращаться к контексту в UI через wm.context. Не стоит обращаться к модели через wm.model. 

  6. Все стили и локализацию лучше получать из WM, обеспечивая UI только актуальной информацией.

  7. Все необходимые зависимости должны передаваться как параметры, чтобы иметь возможность подменить их на этапе тестирования.

  8. Иногда у виджета может отсутствовать работа с данными. Тогда можно создать StubModel и передавать ее как заглушку. 

  9. Используйте LiveTemplate для генерации кода.

Мы продолжаем развивать это решение, чтобы сделать разработку на Flutter ещё более комфортной и лёгкой. Актуальная версия доступна на pub.dev. Подписывайтесь на канал новостей об Elementary, чтобы не пропустить обновления.

Комментарии (11)


  1. slavap
    17.12.2021 14:54

    Ну не знаю, как по мне MobX делает тоже, но более читабельно.


  1. ziqq
    17.12.2021 15:39

    Ну, а по мне stacked удобнее и нет лишних прослоек, но чемто похож на Elementary


    1. vlkonoshenko Автор
      17.12.2021 15:42

      В Stacked у виджета остается доступ к контексту. В Elementary вся работа с контекстом вынесена в WM это позволяет лучше тестировать приложение. Концепции будут похожи так как MVVM взят за основу у обоих.


  1. alexandrim
    17.12.2021 15:42

    На недавнем DartUp был доклад о том, что в случае Flutter mvvm - это антипаттерн, так как эта архитектура создана, чтобы решать проблему, которой нет во Flutter.

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

    Прокомментируйте, пожалуйста.

    Спасибо!


    1. vlkonoshenko Автор
      17.12.2021 15:46

      О каком конкретно докладе идет речь?

      Не совсем понял, какой пакет уже менялся кардинально?

      По поводу проблемы, которой нет во flutter. Разве то, что слой представления так или иначе содержит бизнес-логику не выглядит как проблема? Плюс такое разделение позволяет протестировать отдельно Model, WidgetModel и сам виджет.


      1. alexandrim
        17.12.2021 16:07

        Речь о последнем докладе второго дня в англоязычном потоке от Кирилла Бубочкина.
        Ваш пакет Elementary, который вы продвигаете, наследует MWWM. Это просто переименование или всё же обратно несовместимые пакеты?
        Правильно ли я понимаю ваш тезис о тестировании, как главный и единственный аргумент за предлагаемый подход?
        Если так, то пишете ли вы сами UI-тесты в таком объёме, чтобы оправдать сопутствующие издержки?
        Могут ли golden-тесты с использованием презентера позволить тестировать виджеты не хуже предлагаемого вами подхода?


        1. mbixjkee
          17.12.2021 20:03

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

          На недавнем DartUp был доклад о том, что в случае Flutter mvvm - это антипаттерн, так как эта архитектура создана, чтобы решать проблему, которой нет во Flutter.

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

          Касаемо самого MVVM - он хорош удобен и полезен, когда у нас есть возможность реагировать на изменение свойств в отображении и у Flutter эта возможность есть.

          Правильно ли я понимаю ваш тезис о тестировании, как главный и единственный аргумент за предлагаемый подход?

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

          Ваш пакет Elementary, который вы продвигаете, наследует MWWM. Это просто переименование или всё же обратно несовместимые пакеты?

          Это разные пакеты, но в Elementary я постарался забрать удобные вещи из mwwm на основе опыта личного использования и избежать болей которые могли возникать при его использовании. Не смотря на то что это разные пакеты, миграция проходит довольно легко, @vlkonoshenko может это подтвердить, у него есть такой опыт.

          Если так, то пишете ли вы сами UI-тесты в таком объёме, чтобы оправдать сопутствующие издержки?

          Да мы активно пишем как юнит тесты, так и виджет/голден. Чуть реже используется e2e тестирование, но в данном случае не разработчиками а qa отделом. А издержки не такие большие от многословности, как вам кажется, а с учетом автоматизаций они становятся еще меньше. При этом Elementary изначально проектировался с таким учетом, чтобы тесты было писать просто, быстро и это лишь подталкивало к тому чтобы это делать еще полнее и качественнее на проектах.


          1. alexandrim
            18.12.2021 00:36

            Михаил, благодарю за развёрнытый ответ! Жду вашу статью!

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

            Ваши доклады я тоже смотрел и вполглаза следил за эволюцией MWWM -> Elementary, потому и задал вопрос о такой зависимости.

            Вероятно, мне следовало сразу обозначить свою боль, породившую изначальный вопрос. Попробую кратко изложить, полагая, что я не один такой.
            Я не противник MVVM и не сомневаюсь в его сильных сторонах.
            Flutter представляется фреймворком, позволяющим быстрее и проще создавать приложения. Он предлагает подход "всё есть виджет", но не обязывает ему следовать. Можно обнаружить огромное разнообразие библиотек, большая часть которых реализует технологии, пришедшие из других языков и платформ (redux, rx и т.п.) и выглядят чужеродными. В то же время есть примеры библиотек (Provider, graphql_flutter), следующие парадигме "всё есть виджет". Они предлагают несколько иной подход, который выглядит компактнее. Это позволяет надеяться, что быстро написанный MVP не придётся выбрасывать, а можно продолжать развивать, не боясь породить неподдерживаемого монстра.

            Возможно, это лишь путь смелых продуктовых компаний, а тем, кто рисковать не готов надёжнее полагаться на проверенные подходы. Но рисковать хотелось бы зная, что кто-то прошёл этой дорогой и не пожалел. Однако, мне не попадались развёрнутые доклады на эту тему. В докладах Surf лишь кратко упоминалась причина разработки MWWM (BLoC не понравился?), если я ничего не упустил.

            Да, стейт менеджер не обязательно определяет архитектуру проекта, но всё же влияет на удобство реализаций той или иной. Приходят ли вам на ум статьи, где вы сравниваете Elementary с другими пакетами? Например, с Clean Dart и чем-то попроще? Возможно, это будет в новой статье?

            Спасибо!


            1. mbixjkee
              18.12.2021 13:05

              Спасибо за фидбкек!

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

              В любом случае заинтересовали и теперь в список докладов, которые не успел посмотреть сразу на DartUP и посмотрю позже добавился доклад Кирилла.

              Касаемо моего решения, 1 из важных моментов было чтобы Elementary не просто следовал MVVM, а еще и укладывался в том числе в логику работы Flutter. В статье я как раз про это буду рассказывать. Ну и собственно если посмотрите исходники - он укладывается, используется для представления интерфейса виджет, источником правды для которого является сущность с презентационным состоянием и презентационной логикой. И живет она вместе с Element-ом. Вобщем реализация настолько близка к Flutter (к Stateful виджету если быть точнее) что местами они как два брата близнеца.

              В докладах Surf лишь кратко упоминалась причина разработки MWWM

              Ну мы кстати довольно много про это говорили в различных выступлениях. Не было блока когда отдел создавался. Да и если бы был скорее всего ничего бы не поменялось. 1ое чисто наша история - нужна была легкая конверсия разработчиков из андроида и потенциальная назад если ничего не получится. 2 - блок это часть бизнес логики, стейт машина по конкретному кейсу. Стейтмашина это только часть вашей архитектуры. А учитывая что мобилки это в большинстве тонкие клиенты - порой тот же блок на мой взгляд в огромном количестве кейсов просто избыточен. Есть кейсы когда он жизненно необходим, но это не запросы которые мы по сети делаем. В случае что MWWM что Elementary это более широкие вопросы чем просто организация стейтмашины, к слову я вообще туда не стал тянуть ее. Как мне кажется разработчик достаточно самостоятелен чтоб ему не диктовали сверху как ему писать бизнес логику в конкретной ситуации.

              Приходят ли вам на ум статьи, где вы сравниваете Elementary с другими пакетами?

              Вот тут вряд ли) мне это чужеродно, даже если я буду делать это объективно, это не будет выглядеть таковым. При этом я не люблю искать сор в чужом глазу, поэтому тыкать в проблемы каких то решений ну как-то не по мне. У любого решения есть сильные и слабые стороны. Если вам говорят что оно идеально, save some ram и в 9 раз быстрее всего остального, есть большая вероятность что вас обманывают. Поэтому я могу лишь честно анализировать свое решение. В нем есть слабая сторона - многословность. Поэтому мы создали тулинг чтобы уменьшить действие этой слабости. Опять же Elementary это инструмент, которым надо понять как пользоваться.


              1. alexandrim
                19.12.2021 19:04

                Михаил, благодарю за развёрнутый ответ!
                Полагаю, анонсированная статья будет действиткельно полезна.
                В вопросе о сравнении Elementary с другими я не подразумевал поиск сора в чужом глазу. Мне очень понравился доклад архитектора из Гугл, сделавшего таблицу по нескольким менеджерам состояния, чтобы можно было выбирать исходя из конкретных условий. Он её делал для себя, но сделал для всех.
                Полагаю, есть смысл объективно сравнить разные подходы, чтобы сделать доступнее процесс выбора архитектуры, подходящей конкретному проекту.
                Вероятно, это лучше делать не самим авторам, хотя и такой вариант возможен.

                Доклад Кирилла, на который я ссылался выше, появился в текстовом виде https://habr.com/ru/post/596503/


  1. Mitai
    19.12.2021 14:26

    Cпасибо, выглядит интересно