Обзор архитектур управления состоянием на Flutter
Привет! Меня зовут Антон Матрёнин, я — Senior Software Engineer в компании Avalanche Laboratory.
В последнее время рынок разработки стал пополняться кроссплатформенными проектами, которые должны выглядеть одинаково как на вебе, так и на Android/iOS. Одним из фреймворков, реализующим такую парадигму, является Flutter.
Многие уже слышали о Flutter, рассматривали преимущества и недостатки и даже пробовали создавать свой первый проект. Самое время поговорить о сердце любого приложения — архитектуре управления состоянием.
В этой статье мы рассмотрим четыре наиболее используемых подхода:
- Native state
- Provider (Scoped Model)
- BLoC
- Redux
Для демонстрации я создал простейшие приложения по архитектурным типам, каждое из которых состоит из двух экранов:
Первый экран:
- счетчик для демонстрации локального состояния;
- счетчик для демонстрации глобального состояния;
- кнопка инкремента локального и глобального состояния.
Второй экран:
- данные состояния с сайд-эффектами;
- счетчик для демонстрации синхронизации глобального состояния;
- кнопка инкремента глобального состояния.
Для демонстрации подходов и легкого понимания отлично подойдут максимально упрощенные примеры. Именно поэтому мною были использованы примеры со счетчиком. В конце статьи будет подведен небольшой итог по выбору того или иного подхода с перечнем ссылок для глубокого погружения. А ссылки на репозитории каждого проекта будут приведены в соответствующем разделе.
Native state
Все, что вы используете во Flutter, состоит из виджетов. Они могут быть видимыми, невидимыми, содержать дочерние виджеты и взаимодействовать между собой. Каждый из них может быть как виджетом без состояния (Stateless Widget), так и виджетом, у которого есть состояние (Stateful Widget). Основное отличие — возможность повторно отрисовывать виджеты во время выполнения приложения. Stateless Widget будет отрисовываться только один раз и является неизменяемым. Stateful Widget может отрисовываться множество раз в зависимости от изменения внутреннего состояния виджета.
Для создания Stateful Widget вам нужно создать 2 класса.
Первый класс должен наследоваться от Stateful Widget, который в свою очередь наследуется от Widget и является неизменяемым. Экземпляр этого класса не пересоздается при каждой отрисовке и используется для хранения переданных параметров и инициализации состояния.
Второй — класс состояния, который имеет доступ к Stateful Widget через внутреннее свойство и занимается непосредственно отрисовкой состояния, реагируя на его изменение.
Глобальное состояние приложения, так же как и локальное, может быть реализовано с помощью Stateful Widget. Этот виджет создается в самом верхнем узле приложения и передается вниз по виджетам с помощью InheritedWidget. Каждый дочерний виджет приложения может получить доступ к виджету глобального состояния для изменения и использования его полей.
Для управления состояниями с сайд-эффектами мы руководствуемся теми же принципами. Вызываем асинхронную функцию, которая последовательно устанавливает флаг для индикации о загрузке с сервера, выполняем нужный нам запрос на сервер и меняем состояние в зависимости от пришедших данных.
Преимущества и недостатки
Преимуществами такого подхода является простота и скорость внедрения в приложение с небольшим количеством экранов, которая выражается в отсутствии каких-либо дополнительных библиотек.
Из недостатков можно выделить такие:
- Представление и бизнес-логика никак не разделены.
- Сложность в модульном тестировании.
- Сложность в масштабировании и поддержке.
- Большое количество кода, который не может быть переиспользован.
Этот подход желательно применять только к очень маленьким приложениям с фиксированным количеством экранов. Полную реализацию демонстрационного проекта можно найти по ссылке.
Provider (Scoped Model)
Такой тип архитектуры позволяет вынести бизнес-логику из представления и дает возможность переиспользовать эту логику в разных модулях системы. Результат достигается с помощью создания модели и реагирования подписанных виджетов на ее изменение.
Изначально Brian Egan и Andrew Wilson разработали пакет scoped_model, который извлекли из кодовой базы Fuchsia и подвергли значительным улучшениям. Однако после Google I/O 2019 был представлен новый пакет, provider, который заменяет и улучшает Scoped Model, позволяя передавать модели вниз по дереву виджетов без ручного использования InheritedWidget.
Для локального состояния виджета первым делом необходимо создать модель со всеми полями, которые будут использоваться в вашем виджете. После изменения каждого поля (или полей) вам нужно сообщить подписчикам, что модель изменилась, и выполнить отрисовку подписанных виджетов.
Чтобы подписать виджет на модель, используется класс ChangeNotifierProvider, который является частью библиотеки provider. Подписка происходит непосредственно к тому виджету, который будет зависеть от данных из созданной модели.
Глобальное состояние ничем не отличается от локального, кроме того, что модель подписывается к самому верхнему виджету приложения. Имейте в виду, что сама модель должна иметь уникальный класс, чтобы не было конфликтов при ее поиске в дочерних виджетах.
В модели для хранения состояния с сайд-эффектами необходимо предусмотреть специальный метод, который установит индикатор начала асинхронной загрузки данных с сервера и установит данные, пришедшие с сервера, сбросив при этом индикатор загрузки. Однако здесь кроется проблема двойного уведомления подписчиков на изменение модели. Первое уведомление происходит во время изменения установки данных с сервера, второе — после изменения индикатора загрузки.
Преимущества и недостатки
Из преимуществ можно выделить разделение бизнес-логики и представления с помощью создания моделей и event-based-архитектуры. Тестировать такие модели легче, чем в Native state, за счет отсутствия дополнительных усилий на создание виджетов, в которых это состояние используется. Немаловажно, что этот тип архитектуры поддерживается Google, так что можно не беспокоиться о популярности и поддержке этого решения.
Одна из основных проблем такой архитектуры — сложность в понимании того, какое свойство было изменено и с какой модели произошло уведомление виджетов об изменении. Вариантом решения этой проблемы является соглашение на уровне команды о введении так называемых экшенов. Это единственное место, где модель может вызвать метод уведомления либо другие экшены. Также вам нужно быть готовым, что многие вещи вроде persist-хранилища моделей недоступны из коробки и придется написать большое количество сопровождающего кода для их внедрения.
Provider отлично подходит для средних проектов, в которых нет большого зацепления между модулями. Полная реализация проекта на GitHub.
BLoC
BLoC (Business Logic Component) — шаблон, созданный Google для управления сложным состоянием приложения, основываясь на реактивной парадигме.
Основная идея заключается в том, что наше приложение разбито на модули, реализующие бизнес-логику. Каждый модуль имеет одну или несколько Sink (труб), которые являются некоторым входным потоком для агрегирования событий извне. В качестве выходных данных выступает Stream (поток), который определяет асинхронный формат данных для наших виджетов. Чтобы воспользоваться модулем на уровне виджета, применяют StreamBuilder, который управляет потоком данных и автоматически решает проблемы подписки и перерисовки дочернего дерева виджетов.
Несмотря на это, использовать BLoC в чистом виде — достаточно сложная работа, поскольку надо применять библиотеку RxDart для манипуляции с потоками, вручную отписываться от потоков, иначе можно получить серьезную утечку памяти на больших приложениях. С целью решения этих проблем была изобретена библиотека bloc от Феликса Ангелова, одного из разработчиков BMW Tech, который по максимуму упростил использование этого шаблона и предоставил удобное API для управления состоянием с возможностью легкого тестирования модулей. Немаловажным преимуществом этого пакета является возможность автогенерации кода с помощью плагинов для наиболее популярных IDE (IntelliJ, VS Code). Таким образом, мы не тратим времени на написание лишнего кода и имеем гибкость в изменении без лишней магии внутри.
Преимущества и недостатки
Сам подход интересный и имеет большое количество преимуществ:
- богатое API при работе с потоками, что позволяет их легко группировать, совмещать и трансформировать;
- группировка логики в одном месте;
- легкость в тестировании состояния с сайд-эффектами за счет встроенного в Dart API тестирования потоков;
- минимальное количество отрисовок благодаря использованию StreamBuilder.
Из недостатков могу выделить только завышенную сложность для начинающих, поскольку не все разработчики могут быстро вникнуть в суть работы потоков, и отсутствие вменяемого инструмента для отладки каждого из модулей.
Полная реализация проекта BLoC (library) на GitHub.
Redux
Почти все, кто пришел во Flutter из мира фронтенда (в частности, из React), знают о Flux-архитектуре и самой популярной ее реализации — Redux. Причины популярности просты:
- Централизованность — состояние всего приложения находится в одном месте, что позволяет хранить его в любом удобном для вас хранилище.
- Предсказуемость — не нужно императивно менять зависящие друг от друга модели, мы просто реагируем на действия, которые посылает нам система.
- Простота отладки — всегда есть возможность посмотреть полное дерево состояния, а также возможность time-travel-отладки, когда вы можете последовательно пройтись по всем изменениям в стейте и своевременно найти и исправить ошибки.
- Гибкость — существует большое количество middleware-расширений на все потребности программиста в управлении состоянием.
Некоторые Redux-реализации портированы на другие платформы, и Flutter не стал исключением. В dartpub есть пакет redux, который может быть использован как в вебе, так и на мобильных платформах с внедрением дополнительного пакета flutter_redux. Как мы уже говорили выше, в Redux нет разделения на локальное и глобальное. Состояние всегда глобальное — доступно любому виджету и доступно к изменению через экшены в любом месте системы.
Формально у нас существуют такие понятия:
- State — модель состояния, которая может быть как скалярным, так и любым другим составным типом.
- Action — класс-идентификатор события, который хранит в себе payload для передачи нужных параметров.
- Reducer — обработчик экшенов, имеет доступ к текущему состоянию и экшену, который ждет обработки.
- Dispatch — метод вызова экшена, который обрабатывается одним из редьюсеров.
- Store — дерево состояния приложения, комбинирует в себе все редьюсеры, которые мы определили в приложении.
- StoreConnector — дает возможность дочернему виджету получить доступ к store.
В чистой реализации Redux нет возможности работать с побочными эффектами. Обычно для этого используются дополнительные библиотеки redux-thunk, redux-saga и т. п.
В dartpub есть следующие пакеты для этих целей: redux_thunk и redux_epics.
Преимущества и недостатки
Работать с Redux достаточно удобно благодаря развивающимся сопровождающим библиотекам:
- flutter_redux_dev_tools — отладка и time-travel debug;
- redux_thunk — работа с сайд-эффектами с помощью thunk;
- redux_epics — работа с сайд-эффектами с помощью эпиков, которые базируются на потоках;
- redux_logging — логирование стейта или экшенов;
- redux_persist_flutter — сохранение состояния в постоянном хранилище.
Однако недостатки наследуются от старшего родителя:
- каждый виджет может получить доступ ко всему состоянию приложения, что способно легко нарушить принцип единой ответственности;
- локальное состояние виджетов хранится в глобальном дереве состояния, что существенно увеличивает его размеры;
- проблема сайд-эффектов решается только через дополнительные middleware и может меняться от проекта к проекту.
Заключение
Все рассмотренные подходы могут использоваться в продакшене и имеют все шансы надолго засесть в экосистеме Flutter. Выбор архитектуры для вашего проекта будет зависеть от многих факторов: размера и типа приложения, уровня владения технологией у команды, прошлого опыта работы с похожими технологиями и библиотеками.
Для небольших проектов или MVP лучшим решением, на мой взгляд, является Provider. С его помощью вы сможете легко и быстро внедрить необходимый бизнесу функционал. Для чего-то более серьезного — BLoC/Redux. Для каждого из них написано необходимое для комфортной работы количество библиотек и middleware, так что окончательный выбор будет зависеть от функционала, который предусматривается в приложении.
Все, кому интересно следить за Flutter, участвовать в обсуждении технических деталей и делиться опытом внедрения, присоединяйтесь к нашей группе Art Flutter в Telegram.
Полезные ссылки
Native state
- Adding interactivity to your Flutter app
- Basic State Management in Google Flutter
- InheritedWidget class
- Inheriting Widgets
Provider
BLoC
- Effective BLoC pattern
- Unit Testing with «Bloc»
- Reactive Programming — Streams — BLoC
- Architect your Flutter project using BLOC pattern
Redux
- Animation Management with Flutter and Flux/Redux«
- Introduction to Redux in Flutter
- Flutter + Redux — How to make Shopping List App
- Flutter Redux Thunk, an example finally
- Building a (large) Flutter app with Redux
Читайте также: «Створення додатку на Flutter: перші кроки» и «Стоит ли инвестировать во Flutter. Сравнение Flutter и React Native»