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

Я — Михаил Зотьев, Tech Lead Flutter-команды в компании Surf и автор библиотеки Elementary. В статье расскажу, для решения каких проблем появилась эта библиотека, какие фундаментальные принципы и подходы легли в её основу, разберу внутреннее устройство.

Глобальные слои приложения, или почему я выбрал паттерн MVVM

Для начала давайте абстрагируемся от Flutter и попытаемся выделить в условном приложении некоторые глобальные слои. Построим абстракцию среднестатистического приложения — не важно, что оно делает и какие фичи в нём есть.

Пойдём от большого к малому. Сначала выделим два слоя: слой бизнес-логики и слой отображения.

Слой бизнес-логики заключает в себе данные, методы, правила и ограничения, которые позволят работать основной части нашего приложения. Этот слой корневой: именно вокруг него строится всё полезное, что приложение может предоставить пользователю. 

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

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

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

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

Простой пример: вводим текст в поле с валидацией. Ещё до окончания ввода может несколько раз смениться состояние отображения, применится форматирование. И это никаким образом не касается бизнес-логики. 

Это означает, что слой отображения придётся ещё раз разделить: на саму вёрстку и логику отображения.

Таким образом, мы получили целых три слоя:

  • отображение, 

  • презентационная логика, 

  • бизнес-логика. 

И на всё это отлично ложится паттерн Model-View-ViewModel (MVVM). Давайте вспомним, что в нём.

О концепте MVVM

Model-View-ViewModel — шаблон проектирования приложения. В основе него выделяют уровни:

Model — логика работы с данными и описание фундаментальных данных, необходимых для работы приложения.

View — графический интерфейс. Выступает подписчиком на события изменений значений свойств или команд, предоставляемых ViewModel. 

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

Важный момент: MVVM удобен в ситуациях, когда можно связывать изменение свойств ViewModel и реакцию View на это.

А что с Flutter?

От общих вещей мы пришли к Flutter. Что у нас в нём? 

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

Более того, если вам близок подход MVVM и хочется использовать именно его, ViewModel на минималках есть из коробки: State у стандартного StatefulWidget. Он хранит необходимое состояние для отображения, может содержать презентационную логику. Да, нет связи на изменение свойств, зато есть возможность сообщить об изменении состояния. Единственное, этот же стейт отвечает ещё и за отображение: в методе build возвращает описание своего поддерева. Но это ведь не такая большая проблема для небольшого приложения, правда? 

Когда приложение растёт, начинает сказываться отсутствие ограничений. Презентационная логика переплетается с отображением, велик соблазн впихнуть туда же и бизнес-логику — тогда всё будет под рукой. В результате можно получить весьма интересный артефакт, место которому не в сторах приложений, а в музее.

Нет, я сейчас не пытаюсь сказать, что в таком случае нельзя написать хорошо. Всё начинает упираться в то, насколько хорошо конкретный разработчик подходит к реализации.

Разбираем Elementary

Когда я начал разрабатывать Elementary, хотел решить задачи:

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

  • Решение должно быть комплексное и давать чётко разделённые по ответственностям слои.

  • Слои должны быть максимально изолированы и независимы друг от друга.

  • Решение должно быть легко тестируемым.

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

ElementaryModel

ElementaryModel соответствует слою Model в MVVM. Мне видится правильным заключить в неё всю работу с бизнес-логикой, которая требуется конкретному компоненту. Другими словами: это значит, что все бизнесовые зависимости нужно поставить в модель, и использовать их для достижения целей бизнес-логики именно в ней.

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

WidgetModel

Сущность WidgetModel соответствует ViewModel в концепте MVVM. Как и в MVVM, WidgetModel является адаптером состояния модели. Значит, ElementaryModel — это прямая зависимость WidgetModel, которая позволит получить связь с бизнес-логикой. Это единственная точка входа презентационной логики в бизнесовую.

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

initWidgetModel;

didUpdateWidget;

didChangeDependencies;

deactivate;

activate;

dispose.

При этом для слоя отображения WidgetModel должна быть некоторым контрактом — набором свойств для точечного изменения нужных частей UI, а также методов для взаимодействия с презентационной логикой со стороны отображения. Чтобы это обеспечить в виде свойств, изменение которых должно порождать изменение интерфейса, используется наследник ChangeNotifier — StateNotifier.

ElementaryWidget

ElementaryWidget является слоем представления и соответствует View в MVVM концепте. Во Flutter один из типов виджетов — компоновочные виджеты: например, Stateless и Stateful виджеты. Сами по себе они ничего не отображают, а лишь берут и, как лего, собирают отображение из других. Идеальный для нас вариант: поэтому ElementaryWidget стал компоновочным и мы сделали для него собственную реализацию. 

Чтобы компоновочные виджеты могли понимать, где именно выстраивать свою часть поддерева, им обычно передается BuildContext. В нашем случае это не нужно: единственным источником данных, опираясь на концепт MVVM, становится WidgetModel. Таким образом, слой отображения становится максимально «глупым». Его единственная роль — описать текущее представление, опираясь на свойства, которые ему предоставили. К тому же это позволяет абстрагировать каждый подобный компонент отображения от всего остального, ведь всё, что ему нужно, — WidgetModel. 

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

Element

Чтобы всё заработало вместе, потребуется механизм, который обеспечит связь всех слоев. И кто, как не Element во Flutter, должен этим заниматься. 

Использовать Element напрямую разработчику не потребуется. Но в статье, которая рассказывает про устройство Elementary, было бы кощунством не упомянуть столь важный механизм. Да что уж там: он настолько важен, что даже получил название, идентичное названию библиотеки, — Elementary.

Element:

  • отвечает за хранение WidgetModel, 

  • обеспечивает её работоспособность, организуя работу жизненного цикла,

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

В результате вместе с элементом устройство каждой связки выглядит примерно так:

Обработка ошибок

К обработке ошибок подходят с двух разных сторон: со стороны разработчика и со стороны пользователя. 

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

  • Пользователь должен получить привычный и приятный опыт даже при возникновении ошибок. 

Оба варианта предусмотрены в Elementary.

Для централизованной обработки всех ошибок ElementaryModel принимает специальную сущность ErrorHandler. В ней можно реализовать логирование и любую другую бизнесовую обработку произошедшей ошибки. О произошедшей ошибке мы также сообщим WidgetModel, чтобы была возможность обработать её с точки зрения пользователя: например, показать снэкбар. У WidgetModel для этого существует специальный метод жизненного цикла — onErrorHandle. Весь механизм обработки внутри модели запускается вызовом handleError.

В результате обработка ошибок может быть представлена следующей схемой.

Тестирование

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

ElementaryModel покрывается unit-тестами. Здесь всё довольно просто: бизнесовые зависимости поставляются классу, их можно замокировать. Больше обсуждать тут нечего.

ElementaryWidget можно проверить с помощью Widget и Golden-тестов. Процесс тестирования становится даже проще, чем в стандартном подходе Flutter: единственный источник данных для этого виджета — WidgetModel, представленная в виде интерфейса из свойств, а сам по себе виджет — лишь обёртка над поддеревом.

Тестирование WidgetModel изначально не было таким простым занятием. Концептуально всё просто: мы должны проверить, что при определенных условиях WidgetModel поставляет свойства в том виде, в котором мы и ожидали. Но готового механизма для удобного тестирования не было: тесты получались довольно многословными. 

Я написал библиотеку elementary_test. Она берёт на себя всю эмуляцию подкапотной работы, а наружу предоставляет «пульт управления» происходящими процессами. Теперь тестировать WidgetModel настолько же просто, насколько это выглядело в концепте.

Тому, как тестировать Elementary, мы посвятим отдельную статью. Подробно на примерах покажем, насколько это легко.

Плюсы и минусы

Мы все прекрасно знаем, что серебряной пули не существует: у любого решения есть сильные и слабые стороны. Elementary — не исключение. Большую часть плюсов я уже описал в статье. Кратко повторю их:

  • Это Flutter-like решение, которое соотносится с происходящем в самом фреймворке.

  • Решение следует простому и удобному MVVM паттерну.

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

  • Слои максимально изолированы и независимы друг от друга. На этом пункте стоит остановиться чуть подробнее. Это не просто «эстетически» приятная особенность. Благодаря ей можно распараллеливать задачи на любом этапе разработки и тестирования на несколько разработчиков: они работают без пересечений в своих частях и не аффектят своими действиями друг друга.

  • Код легко тестировать.

  • Лёгкая интеграция с другими решениями.

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

Один из ярких примеров — появление «BLoC для экрана» в приложениях или попытки затащить в BLoC BuildContext. Это в корне расходится с самим концептом: BLoC — Business Logic Component, компонент бизнес-логики. И происходит так лишь потому, что разработчику нужен инструмент для решения задач презентационной логики, и он пытается его найти в том, что у него уже есть.

Еще один стандартный кейс — загрузка некоторых данных. Добавляется стейт-машина с состояниями «загрузка», «ошибка», «данные». Но ведь такая же стейт-машина есть в самом языке — Future. Зачем же мы это делаем? Действительно ли это нужно именно бизнес-логике? Или мы решаем все задачи подряд инструментом, который есть под рукой, не задумываясь, даёт ли он что-то для решения этой проблемы.

В Elementary управление презентационной логикой формализовано, а бизнес-часть оставлена в свободной форме. Это значит, что вместе с Elementary можно использовать BLoC, Redux и другие подобные решения. При этом их можно будет использовать тогда, когда они действительно необходимы, — и это лишь раскроет их преимущества.

Пришло время и ложки дегтя. За всё надо платить: чтобы достичь подобного разделения слоев и распределения ответственностей, приходится писать более многословный код. Но решение этой проблеме легко найти. Мы уже поработали над вопросом: для Elementary существует CLI-утилита, которую можно использовать для генерации бойлерплейт кода — заготовок сущностей для реализации и даже тестов. А чтобы этой утилитой было удобно пользоваться, мы написали два плагина: для VSCode и Intellij.

Самостоятельно ознакомиться с кодом всего, о чём я рассказал в статье, можно в репозитории Elementary. А о том, как использовать Elementary в разработке на практике, читайте материал «Elementary: новый взгляд на архитектуру Flutter-приложений» моего коллеги Влада Коношенко. Он успел испробовать библиотеку в боевых условиях на реальном проекте и поделился опытом в статье.  А про тестирование пакета рассказано в статье «Элементарное тестирование, или тестирование Elementary».