Библиотека Elementary
Задача
Что такое Elementary, и для чего мы придумали эту библиотеку
Мы в Surf начали работать с технологией Flutter в 2019 году. С тех пор мы создали большой отдел Flutter-разработки, участвуем в развитии фреймворка, развиваем и поддерживаем профессиональное сообщество в России: записываем подкасты, пишем экспертные статьи, задаём собственные стандарты архитектуры. Мы постоянно оптимизируем и улучшаем свои процессы разработки. Одним из таких решений, которое зародилось внутри Surf, стала библиотека Elementary.
Его автором стал экс-техлид отдела Flutter-разработки Surf Михаил Зотьев. Перспективы, которые открыла библиотека, понравились техлидам проектов, и они начали активно обкатывать новое решение, давая детальный фидбэк. Например, Владислав Коношенко поделился опытом практического применения Elementary в серии статей. Затем к развитию присоединились и другие разработчики: так, туллинг для IDE написал Алексей Букин. Число пользователей решения постепенно росло. Сейчас уже развитие библиотеки не замыкается на наших сотрудниках — у неё есть и сторонние контрибьюторы.
Сегодня мы используем Elementary в большинстве Flutter-проектов для создания чистой архитектуры и легко тестируемого кода. Она очень полезна в e-commerce и финтех проектах: там, где заложена работа с анимацией, выпадающими списками, изменением интерфейса при определённых действиях.
Экс-техлид отдела Flutter-разработки в Surf
Все эти ограничения вкупе с особенностями Flutter хорошо ложатся на классический паттерн Model-View-ViewModel (MVVM) → Model-Widget-WidgetModel (MWWM). Он и стал прародителем Elementary.
MVVM — это шаблон проектирования архитектуры приложения.
MWWM — это архитектура и реализация паттерна Model-View-ViewModel, которую мы в Surf переложили на Flutter.
У нас уже был опыт реализации этого паттерна в самом начале формирования Flutter-отдела, который вырос из отдела Android-разработки. MVVM был отличным выбором — так появилась библиотека MWWM. Но тогда, в начале развития нашего отдела, да и самого Flutter, мы ещё не досконально знали внутреннее устройство фреймворка. Поэтому у библиотеки были не только сильные, но и слабые стороны.
Экс-техлид отдела Flutter-разработки в Surf
Идея библиотеки, как и у самого паттерна, — в разделении ответственности классов: UI, бизнес-логики и презентационной логики. Получаются независимые друг от друга модули, имеющие чёткую структуру. Для понимания достаточно запомнить три сущности:
- elementaryWidget — представление, в котором формируется структура экрана;
- widgetModel (WM) содержит презентационную логику;
- elementaryModel содержит бизнес-логику и логику компонента.
Решение
Как устроена Elementary
Elementary — библиотека, которая предоставляет механизмы для написания приложения по правилам Clean Architecture с разделением модулей на блоки. Это архитектурный пакет для Flutter, который позволяет чётко разделить слои согласно ответственностям, сделать эти ответственности прозрачнее, а код проще для восприятия и тестирования.
Библиотека включает несколько основных модулей с чёткой структурой.
ElementaryModel
ElementaryModel соответствует слою Model в MVVM. Обычно мы заключаем в неё всю работу с бизнес-логикой, которая требуется конкретному компоненту. Это значит, что все бизнесовые зависимости нужно поставить в модель и использовать их для достижения целей бизнес-логики именно в ней.
При этом мы не диктуем разработчику, в каком виде реализовывать бизнес-логику — Elementary хорошо сочетается с другими решениями, нацеленными на работу с ней.
WidgetModel
Сущность WidgetModel соответствует ViewModel в концепте MVVM. Как и в MVVM, WidgetModel является адаптером состояния модели. Значит, ElementaryModel — это прямая зависимость WidgetModel, которая позволит получить связь с бизнес-логикой. Это единственная точка входа презентационной логики в бизнесовую.
WidgetModel является тем местом, где необходимо инкапсулировать всю презентационную логику.
ElementaryWidget
ElementaryWidget является слоем представления и соответствует View в MVVM концепте. Во Flutter один из типов виджетов — компоновочные виджеты: например, Stateless и Stateful виджеты. Сами по себе они ничего не отображают, а лишь берут и, как конструктор, собирают отображение из других. Поэтому ElementaryWidget стал компоновочным и мы сделали для него собственную реализацию.
Чтобы компоновочные виджеты могли понимать, где именно выстраивать свою часть поддерева, им обычно передаётся BuildContext. Единственным источником данных, опираясь на концепт MVVM, становится WidgetModel. Таким образом, слой отображения становится максимально простым. Его единственная роль — описать текущее представление, опираясь на свойства, которые ему предоставили. К тому же это позволяет абстрагировать каждый подобный компонент отображения от всего остального, ведь всё, что ему нужно, — WidgetModel.
Изменение свойств должно точечно обновлять часть UI. Поэтому для свойств в виде StateNotifier добавлены билдеры, опирающиеся на их значение. При грамотно продуманной WidgetModel такой подход позволяет получать хорошую производительность: мы обновляем лишь необходимое и не затрагиваем большие фрагменты вёрстки, которые этого не требовали.
Element
Чтобы всё работало корректно, необходим механизм, который обеспечит связь всех слоёв. Element во Flutter нужен как раз для этого. Использовать Element напрямую разработчику не потребуется. Однако этот механизм настолько важен, что даже получил название, идентичное названию библиотеки, — Elementary.
Element:
- отвечает за хранение WidgetModel;
- обеспечивает её работоспособность, организуя работу жизненного цикла;
- предоставляет её виджету в виде контракта, чтобы тот мог представить отображение.
В результате вместе с элементом устройство каждой связки выглядит примерно так:
Как Elementary обрабатывает ошибки
К обработке ошибок обычно подходят с двух разных сторон: со стороны разработчика и со стороны пользователя.
- С точки зрения разработчика нужно иметь информацию об ошибках, собирать данные, что именно произошло и где. Это позволит оперативно узнавать о проблемах и исправлять их.
- Пользователь должен получить привычный и не травмирующий опыт даже при возникновении ошибок.
Оба варианта предусмотрены в Elementary.
Для централизованной обработки всех ошибок ElementaryModel принимает специальную сущность ErrorHandler. В ней можно реализовать логирование и любую другую бизнесовую обработку произошедшей ошибки. О произошедшей ошибке мы также сообщим WidgetModel, чтобы была возможность обработать её с точки зрения пользователя: например, показать снэкбар — небольшое сообщение в верхней части интерфейса, которое несёт фидбэк в ответ на только что выполненное действие.
У WidgetModel для этого существует специальный метод жизненного цикла — onErrorHandle. Весь механизм обработки внутри модели запускается вызовом handleError.
В результате обработка ошибок может быть представлена следующей схемой:
Как в Elementary реализована тестируемость
Одним из требований к Elementary была тестируемость. Поэтому мы стремились сделать все три слоя легко тестируемыми.
ElementaryModel покрывается unit-тестами, которые проверяют конкретный модуль системы. Здесь всё довольно просто: бизнесовые зависимости поставляются классу, их можно замокировать — то есть отправлять запросы не на реальный сервер, а на «заглушку».
ElementaryWidget можно проверить с помощью Widget и Golden-тестов. Процесс тестирования становится даже проще, чем в стандартном подходе Flutter: единственный источник данных для этого виджета — WidgetModel, представленная в виде интерфейса из свойств, а сам по себе виджет — лишь обёртка над поддеревом.
Тестирование WidgetModel изначально не было таким простым. Мы должны проверить, что при определённых условиях WidgetModel поставляет свойства в том виде, в котором мы и ожидали. Но готового механизма для удобного тестирования не было: тесты получались довольно многословными.
Для этого мы написали библиотеку elementary test. Она берёт на себя всю имитацию программной части, а наружу предоставляет «пульт управления» происходящими процессами. Теперь тестировать WidgetModel просто.
Приложение, написанное при помощи пакета Elementary, можно легко протестировать всеми видами тестов. Это стало возможно благодаря разделению приложения на слои и отсутствию большой связанности между ними.
Результат
Экс-техлид отдела Flutter-разработки в Surf