Всем привет, на связи 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, реализуем простое приложение прогноза погоды. Оно состоит из двух экранов: выбора города и прогноза погоды.

Готовое приложение смотрите в моём репозитории на 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 и передавать ее как заглушку. 

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

Читайте также:


Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!