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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Комментарии (10)


  1. BlackJet
    11.01.2024 17:12
    +2

    Но почему GetX? Чем вам Bloc в комплекте с тем же autoRoute не угодил? Вообще, удивительно использовать фреймворк на фреймворка, это так оптимизировано.


    1. olegskiryuk
      11.01.2024 17:12
      +1

      Изначально был mobx, далее мы искали более так сказать "коробочное" решение и выбрали GetX, по началу всё было ок, но потом обнаружились проблемы с ним.

      Во-первых как вы сказали фреймворк в фреймворк, много не используемого, к своим дефектам плюсуются дефекты и проблемы GetX, много неиспользуемого например материал виджеты (у нас Купертино вэй), много оверхеда различного.

      Во-вторых некоторые проблемы с роутингом, таб навигацией (если интересно могу подробнее рассказать)

      Понравилась идея MVC и DI, и хотелось их оставить в дальнейшем в разработке.

      Решили отказаться от использования фреймворков в пользу отдельных пакетов, если с навигацией понятно пакетов много, то с DI боле менее подошёл GetIt, но не совсем на 100%, пришлось подкрутить

      Теперь отвечая на ваш вопрос, не bloc потому что хотелось что-то в духе MVC подобное GetX только без оверхеда, только самое необходимое. Bloc как альтернатива в принципе можно использовать, но там несколько другой подход на ивентах. Тоже самое с auto_route это лишь выбор, дело вкуса, можно использовать go_router тем более что он неплохо доработался


      1. Krushiler
        11.01.2024 17:12
        +1

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


  1. XeL077
    11.01.2024 17:12

    Вроде, обычный Bloc теперь используют без rx и всего этого.


    1. olegskiryuk
      11.01.2024 17:12
      +1

      Вероятно так, не отслеживаю новости по bloc'у. Возможно другие точнее подскажут


    1. Krushiler
      11.01.2024 17:12

      Rx можно для слоя данных юзать, чтобы в блоке подхватывать изменения


  1. ivanesi
    11.01.2024 17:12
    +1

    Добрый день. Спасибо за статью. Можно несколько вопросов?

    1. Чем не подошел go_router, какие плюсы auto_route по сравнению с ним?

    2. В ViewWidgetState будет не только UI, но и презентейшн-логика, которой может быть довольно много (в вашем примере onPressed), в контроллере нет доступа к методам жизненного цикла виджета - не хотелось ли вам вынести такую логику в другую сущность и оставить голую вьюшку?

    3. Не возникало ли ситуаций когда в контроллере был нужен контекст? Как решали.

    4. Тема модулей не раскрыта - выносятся ли фича модули в отдельные пакеты, используются ли для них отдельные репозитории, используете ли melos, если нет и просто в фича-папках лежит, почему называете это модулями - можно подробнее про этот момент?

    Спасибо.


    1. olegskiryuk
      11.01.2024 17:12
      +1

      Добрый! Спасибо за вопросы)

      1. Выбор пакета для роутинга (а точнее для вложенной таб навигации) мы рассматривали года 2 назад, тогда с go_router возник ряд сложностей (вроде как не сохранялось состояние открытых вкладок на каждом табе, сложности с навигацией между вкладками таба, к примеру перейти с экрана 2 на табе 1, на экран 3 таба 2).

        Вдобавок документация описывала простые случаи и требовалось искать как сделать что-то посложнее. Сейчас попробовал найти сайт с документацией, но не нашел, видимо переехал в другое место. Кажется даже что по-другому описывалась конфигурация роутов нежели сейчас. Насколько помню в DevTools с использованием go_router отрисовывались все табы сразу и были проблемы с жестами ios и хардверной android кнопкой назад. Возможно сейчас все стало намного лучше и есть смысл попробовать go_router.

        Говоря про auto_route на самом деле мы не сравнивали его с другими пакетами, просто он первый который подошел и решил основные проблемы.

        Из плюсов стоит отметить: поддержка Cupertino, наличие наблюдателей за навигацией, гибкие возможности создания вложенной навигации в т.ч навигация между вкладками и родительской навигацией, осуществление навигации без контекста, возможность создания "промежуточного" виджета (например таба) где можно разместить общие состояния необходимые дочерним виджетам/экранам открывающихся по дочерним роутам.

        Из минусов: конечно же кодогенерация (тут надеемся на статическое метапрограммирование), сложности с передачей параметров в виджет при роутинге (мы написали отдельное расширение виджета для пробрасывания параметров сразу в контроллер), встречались местами непонятные ситуации к примеру как осуществить навигацию с одного таба на другой и на определенный экран

      2. Про onPressed, как вариант, всю эту логику можно вынести в controller. Только если необходим контекст придется возвращаться в view. Для навигации например есть способ навигации без контекста, да это не очень правильно, но жутко удобно. В контроллере расширении (ViewController) есть хуки-методы жизненного цикла (наподобии как в GetX), там можно располагать логику. Т.е можно в виджете, можно в контроллере либо оба варианта.

      3. Да, возникали ситуации. Здесь необходимо возвращаться во view и там уже выполнять необходимую логику с context. Context пробрасывать в контроллер крайне нежелательно

      4. Тут на самом деле несколько подходов, можно делать монорепозиторий (привет melos) тогда модули будут в виде пакетов в одном воркспейсе (есть у нас такие проекты) плюс в том что разработчик не сможет импортировать что-то "левое" из другого модуля, как бы контроль. При желании каждый пакет-модуль можно вынести в отдельный репозиторий. Другой вариант - монолит с папочкой features, в которой как раз и лежат папки (а-ля модули) с фичами. Т.е какой-то специальной сущности модуль нет, так нам удобнее называть чтобы не путаться.

        Напишите если нужно что-то подробнее пояснить еще)


    1. Krushiler
      11.01.2024 17:12
      +1

      По поводу гороутера

      1) Гороутер сырой и часто меняет апи

      2) Для мобилок строковая навигация - не очень то удобная вещь. Особенно, когда до передачи аргументов доходит. Да, комплексные данные передавать не надо, однако и чтобы передать айдишник нужно не забыть про структуру роута -> городить фасад для этого

      А в statefulShellRoute я ещё и баги неприятные ловил пару раз, которые именно с реализацией шелл роута свзязаны


  1. Pardych
    11.01.2024 17:12
    +1

    Ага.
    1) "Большое" это какое? Сотня экранов - это сотня экранов, под ними может быть не так много домена в облачных решениях. Три оси измерения: объем кода, соотношение объема домен/ui и количество людей показывают сложность проекта (если честно - моя личная метрика). Растет домен/объем или объем/люди - сложность растет. При этом, понятное дело, что прибавлению людей нелинейно работает. То есть вы доводите объем/чел до расслабленного 10-15К строк на человека как в тиньке, но у вас общий объем так же как там два ляма - тут чисто инфраструктурной ереси будет с лям. Уже просто потому что у вас так много людей и надо много бойлерплейта чтобы они друг другу не мешались. Но и 300К на 2-3 человека при толстом домене это офигеть как сложно, я делал. Так что тут закономерный вопрос - насколько большое и почему именно оно такое. Экраны это эмоциональная метрика, имхо.

    2) Логика ui-слоя утаптывается в bloc(mvi)|mvvm(cubit)|mvp|mvc(mvc морально устарел уже потому что связность у него выше прочих, mvp, впрочем, тоже, хотя жить и с ним можно). Вы точно уверены что mvc в его классическом понимании где все завязаны на всех без модификаций это нормально по солиду? И логика ui-слоя - она логика отображения стейта. Стейт формируется от домена.

    3) Если взять во внимание клин (я беру, он хороший). Стейт вьюшек композится из массы доменных стейтов как раз в "логике ui" то есть в контроллерах, презентерах, вьюмоделах, блоках. В этом архитектурном плане нет домена. Вообще. Фича-модули построенные на стейт-менеджмент паттернах - ок, но это, по сути, подходит под очень простое фронтенд-приложение и не более. Объем тут вторичен, бьется и параллелится оно легко на любом подходе, коммона там мизер, в основном сессия+ui-кит.

    4) Собственно фича-модули. Часто (хотя не всегда, в виде исключения) есть доменные сущности типа той же сессии юзера, или сложносочиненных взаимозависимых репозиториев с синхронизацией замудренной в зависимости от авнономности аппа, и это делает дерево модулей сложным в обслуживании. Если оно не таково, то коммон-штуки дублируются не только прим большом количестве людей которые не знают что уже есть имплементации, но и даже в одно жало. Потому что проще копипаст, чем думать как слабосвязно прокинуть, я такое поддерживал, это боль. Вот это почему? Большое значит бьем, вы уверены что оно из большого не станет именно сложным?