Об одном способе реализации архитектуры крупного Flutter-приложения

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

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

Мы обсудим его особенности, технические сложности, и посмотрим, как реализовать такое крупное приложение. Затем — углубимся в вопрос управления состоянием, зависимостями, приложением.

Немного о нашем приложении

У каждой крупной компании есть свои партнеры. У билайна они тоже есть, мы их называем дилерами. К дилерам часто обращаются клиенты с разными просьбами — приобрести сим-карту, подключить тариф, услугу, совершить абонентские операции и прочее. Как раз для них мы делаем мобильное приложение, которое позволяет автоматизировать и упростить их работу. Мобильное приложение называется «Дилер онлайн».

6d86bcffe333898b18c982faecba8851.png

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

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

После анализа этого приложения мы выявили ряд особенностей. 

Во-первых, приложение у нас довольно крупное. Интерфейс насчитывает более 100 экранов со своим дизайном, что привносит сложность при проектировании и реализации. В целом сам дизайн у нас выполнен в Купертино-стиле (используем Купертино-виджеты). Также мы заметили, что ряд виджетов повторяется. У нас есть дизайн-система с общими цветами и шрифтами общими — всё это целесообразно выносить в отдельную библиотеку, модуль UI-kit. Это позволяет нам вынести дизайн-систему и использовать её не только в этом проекте, но и в ряде других.

Ещё из особенностей — у нас есть нижняя навигация, довольно сложная. Сложность заключается в том, что необходимо сохранять состояние этих вкладок (табов), а также нужно осуществлять навигацию, например, из одного таба в другой. При этом некоторые разделы в приложении у нас могут дублироваться и повторяться. Так, например, создание договора есть как с главного экрана, так и с списка договоров. То есть одновременно у нас в двух разных местах один один и тот же функционал. 

Важно, чтобы это всё не конфликтовало между собой и жило дружно.

Как это реализовать технически 

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

ed94647fb379259c4adee3449f7f08f7.png

У нас эта стратегия состоит из двух пунктов. 

Первый — набор архитектурных паттернов. Мы решили следовать общим принципам разработки — SOLID, DRY, KISS и прочие. Плюс принципы строительства архитектуры, паттерн MVC нам показался очень полезным. Здесь же — принципы реактивности и направленности. Чтобы в приложении не нужно было вызывать какие-то функции руками, апдейт и подобное, все это должно происходить автоматически. Мы используем принцип слабой связности компонентов системы, что подразумевает под собой использование механизма СО СЛАЙДА на различные вариации. 

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

  1. UI-кит. Тут хранятся дизайн-система, шрифты, общие виджеты для приложений и так далее. 

  2. Модуль самого приложения. 

  3. Фича-модули. Да, для каждой фичи у нас заводится отдельный модуль. 

  4. Core-модуль, куда сложить разные core-вещи, модельки, абстрации, юзкейсы и подобное.

Структура модулей

Каждый модуль у нас состоит из двух частей. 

c53d2f6de889351e13a91ddaad8476ef.png

  1. Core-часть, где располагаются общие вещи, которые необходимы всему модулю в целом. Здесь у нас располагаются, например, общие модели, глобальные состояния, репозитории и так далее. В общем, все то, что общее, нужное для конкретного модуля. 

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

И если же обычно с core-частью все понятно, то с UI-частью есть некоторые вопросы — существует множество вариантов ее реализации. Здесь важно выбрать более эффективный, который подойдет именно нашему проекту. Ведь у нас экран — это не только данные, но еще и состояние, логика и само представление. А ещё у экрана могут быть зависимости, и ими тоже необходимо управлять, контролировать этот процесс, чтобы эффективно и гибко вести разработку.

Как управлять логикой, состоянием и нашими зависимостями 

Давайте сначала рассмотрим, какие вообще есть подходы: что нам предлагают сообщество и крупные компании для решения вопросов управления зависимостями и состоянием.

Первый подход, который мы рассмотрим, это концепция Mobx, она довольно известная. У Flutter тоже есть реализация этой концепции в одноименном пакете Mobx. 

6f746530ad24e957eb6e9fa62ea18eee.png

Напомню основную идею Mobx — наряду с виджетом у нас есть некая сущность Store, это класс, в котором объявляются наблюдаемые переменные. И при изменении этих переменных виджет реагирует соответствующим образом и перестраивается. Получается такая живая связь между ними, что достаточно удобно. 

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

Ещё в Mobx есть такая сущность, как Реакция. С помощью Реакций мы тоже можем подписываться на изменения наблюдаемых переменных и реагировать, выполнять какие-то действия, называемые сайд-эффектами. В общем, по своей сути Store — это некая смесь логики и состояния. 

Мы же у себя на проекте столкнулись с тем, что в некоторых случаях нам необходимо отделять логику от состояния. Иногда нам нужно только состояние, а иногда — только логика. Конечно, с помощью Mobx этого можно достичь, допустим, создавая два стора, один — для логики, а другой — для состояния. Такой вариант усложняет проект, но в целом возможен.

Что же касается управления зависимостями и навигации, то по концепции Mobx все отдается на откуп разработчику. Он может использовать для управления зависимостями то, к чему он привык. Это может быть и Provider, и GetIt, то есть то, что он хочет, и что нравится. Навигация же идет стандартно из коробки. Тут тоже можно либо подключать пакеты для навигации, если необходимо, либо использовать как есть.

Другой рассмотренный нами подход — GetX.

b0198fb6fbafc5d1d5fe22cb724118ba.png

GetX — это фреймворк. То есть там много чего готового идет из коробки. С одной стороны, это хорошо, с другой — не очень. Зато здесь уже можно разделять логику и состояния. Если нам необходимо состояние отдельно, то его можно вынести из контроллера в отдельный класс, и там уже потом по dependency injection использовать. Тут нет ничего лишнего. Что же касается управления зависимостями и навигации, то все это идет сразу из коробки.

Например, чтобы описать наши зависимости, нам необходимо наследовать от класса Bindings, и там уже декларативно описать все наши зависимости. Для навигации же нужно изучить отдельный API, который нам предоставляет GetX, и дальше применять его.

Что получилось на нашем проекте 

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

Затем мы рассмотрели Flutter modular — это тоже фреймворк, похожий на GetX, но с некоторым отличием. Основной идеей Flutter modular является разбиение проекта на ряд модулей.

74b68327ab3f27ed17da768f958e84ea.png

И именно на уровне модуля уже описываются все наши зависимости, роуты и экраны, которые открываются по этим роутам. Что касается управления состоянием, то здесь тоже всё отдается ве на откуп разработчику. Здесь можно использовать либо тот же MobX, с которым мы ранее посмотрели, либо блок, что больше нравится. 

Теперь про навигацию. Здесь API идет опять же из коробки, тут такая система модулей — вся навигация описывается в модуле. И идет вот как раз открытие по модулю, так сказать. 

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

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

Другая проблема фреймворков состоит в том, что с выходом мажорных релизов SDK необходимо ждать. Плюс эти доработки чаще всего сопровождаются кучей багов, которые ведут к изменению проекта. А иногда и его переписыванию.

e8c23db9a594aa1202b393609992bc32.png

Для примера — GetX, у них сейчас мажорная версия 5.0, она пока еще так и не вышла, и все опасаются, что там поменяется вообще всё, что можно, и придется ощутимо обновлять проекты.

Что мы выбрали

Так что же мы можем улучшить? Какое решение нам выбрать, чтобы решить наши проблемы и с навигацией, и с управлением состоянием?  

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

Поэтому мы сделали ряд вспомогательных виджетов. 

38f1f260e7f4cf73ba869fbedb465f15.png

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

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

Еще мы создали ряд расширений для удобной работы с роутингом. Кстати, для роутинга мы выбрали пакет auto-route. Остановились на нем, так как он наиболее полно решил нашу задачу в плане сложной навигации.

Как всё это работает

Допустим, нам необходимо сделать экран авторизации.

Первым делом мы его, естественно, должны сверстать. Поле ввода, кнопочки, логотипы и прочее.

7b11116e4307f641eb2c829adb0f4273.png

Но мы наследуемся не от stateful-виджета в этот раз, а от нашего вспомогательного виджета, который как раз просит нас описать наши зависимости и нашу логику в контроллере. 

Давайте посмотрим, как это сделать более детально.

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

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

66c47a1401a7e64b39b641aecc0fe006.png

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

Потом мы авторизуемся на сервере. Если авторизация прошла успешно, то мы идем дальше по флоу наших экранам. Если произошла какая-то ошибка, то мы ее выводим на экран пользователю.

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

a92effa138011a49b0fdae4dd430b05d.png

Пользователь вводит номер телефона. В этот момент у нас номер телефона сохраняется в наше состояние. Далее нажимаем кнопку Войти / Авторизоваться. В этот момент у нас срабатывает логика авторизации в нашем контролере. Если все ОК, то мы идем дальше на следующий экран. Если же ошибка, то мы ее показываем пользователю на экране поверх кнопки. Вот таким образом это работает на таком простеньком примере. 

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

Если у вас есть особый опыт реализации больших Flutter-приложений, буду рад комментариям.

© Habrahabr.ru