company_banner

Архитектурный шаблон MVI в Kotlin Multiplatform, часть 1

Автор оригинала: Аркадий Иванов
  • Перевод


Около года назад я заинтересовался новой технологией Kotlin Multiplatform. Она позволяет писать общий код и компилировать его под разные платформы, имея при этом доступ к их API. С тех пор я активно экспериментирую в этой области и продвигаю этот инструмент в нашей компании. Одним из результатов, например, является наша библиотека Reaktive — Reactive Extensions для Kotlin Multiplatform.

В приложениях Badoo и Bumble для разработки под Android мы используем архитектурный шаблон MVI (подробнее о нашей архитектуре читайте в статье Zsolt Kocsi: «Современная MVI-архитектура на базе Kotlin»). Работая над различными проектами, я стал большим поклонником этого подхода. Конечно, я не мог упустить возможность попробовать MVI и в Kotlin Multiplatform. Тем более случай был подходящий: нам нужно было написать примеры для библиотеки Reaktive. После этих моих экспериментов я был вдохновлён MVI ещё больше.

Я всегда обращаю внимание на то, как разработчики используют Kotlin Multiplatform и как они выстраивают архитектуру подобных проектов. По моим наблюдениям, среднестатистический разработчик Kotlin Multiplatform — это на самом деле Android-разработчик, который в своей работе использует шаблон MVVM просто потому, что так привык. Некоторые дополнительно применяют «чистую архитектуру». Однако, на мой взгляд, для Kotlin Multiplatform лучше всего подходит именно MVI, а «чистая архитектура» является ненужным усложнением.

Поэтому я решил написать эту серию из трёх статей на следующие темы:

  1. Краткое описание шаблона MVI, постановка задачи и создание общего модуля с использованием Kotlin Multiplatform.
  2. Интеграция общего модуля в iOS- и Android-приложения.
  3. Модульное и интеграционное тестирование.

Ниже — первая статья серии. Она будет интересна всем, кто уже использует или только планирует использовать Kotlin Multiplatform.

Сразу замечу, что целью этой статьи не является обучение навыкам работы с самим Kotlin Multiplatform. Если вы чувствуете, что в этой области у вас не достаточно знаний, рекомендую сначала ознакомиться с введением и документацией (особенно с разделами “Concurrency” и “Immutability”, чтобы понимать особенности модели памяти Kotlin/Native). Я не буду описывать в этой статье настройку проекта, модулей и прочих вещей, не относящихся к теме.

MVI


Для начала давайте вспомним, что такое MVI. Аббревиатура расшифровывается как Model-View-Intent. В системе есть всего два основных компонента:

  • модель (Model) — слой логики и данных (также модель хранит текущее состояние (state) системы);
  • представление (View) — UI-слой, отвечающий за отображение состояний (states) системы и выдачу намерений (intents).

Следующая диаграмма наверняка уже многим знакома:



Итак, мы видим те самые основные компоненты: модель и представление. Всё остальное — это данные, которые циркулируют между ними.

Нетрудно заметить, что данные перемещаются только в одном направлении. Состояния исходят из модели и попадают в представление для отображения, намерения исходят из представления и попадают в модель для обработки. Эта циркуляция называется однонаправленным потоком данных (Unidirectional Data Flow).

На практике модель часто представляется сущностью под названием Store (оно заимствовано из Redux). Однако это происходит далеко не всегда. Например, в нашей библиотеке MVICore модель имеет название Feature.

Стоит также отметить, что MVI очень тесно связан с реактивностью. Представление потоков данных и их преобразование, а также управление жизненными циклами подписок очень удобно осуществлять с использованием библиотек реактивного программирования. Сейчас доступно достаточно большое их количество, однако при написании общего кода в Kotlin Multiplatform мы можем использовать только мультиплатформенные библиотеки. Нам нужна абстракция для потоков данных, нужна возможность соединять и разъединять их входы и выходы, а также осуществлять преобразования. На данный момент мне известно две таких библиотеки:

  • наша библиотека Reaktive — реализация Reactive Extensions на Kotlin Multiplatform;
  • корутины и Flow — реализация холодных потоков (cold streams) при помощи Kotlin coroutines.

Постановка задачи


Цель этой статьи — показать, как использовать шаблон MVI в Kotlin Multiplatform и какие есть преимущества и недостатки у такого подхода. Поэтому я не стану привязываться к какой-либо конкретной реализации MVI. Однако я буду использовать Reaktive, поскольку потоки данных всё-таки нужны. При желании, поняв идею, Reaktive можно заменить на корутины и Flow. В целом я постараюсь сделать наш MVI как можно проще, без лишних усложнений.

Чтобы продемонстрировать MVI, я попробую реализовать максимально простой проект, удовлетворяющий следующим требованиям:

  • поддержка Android и iOS;
  • демонстрация асинхронной работы (ввод-вывод, обработка данных и т. д.);
  • как можно больше общего кода;
  • реализация UI нативными средствами каждой платформы;
  • отсутствие Rx на стороне платформ (чтобы не пришлось указывать зависимости на Rx как “api”).

В качестве примера я выбрал очень простое приложение: один экран с кнопкой, по нажатию на которую будет загружаться и отображаться список с произвольными изображениями котиков. Для загрузки изображений я буду использовать открытый API: https://thecatapi.com. Это позволит выполнить требование об асинхронной работе, так как придётся загружать списки из Сети и парсить JSON-файл.

Весь исходный код проекта вы можете найти на нашем GitHub.

Начало работы: абстракции для MVI


Сначала нам нужно ввести некоторые абстракции для нашего MVI. Нам понадобятся те самые базовые компоненты — модель и представление — и пара typealias-ов.

Typealiases


Для обработки намерений введём актор (Actor) — функцию, принимающую намерение и текущее состояние и возвращающую поток результатов (Effect):


Нам также понадобится редуктор (Reducer) — функция, которая принимает эффект и текущее состояние и возвращает новое состояние:


Store


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

Давайте введём соответствующий интерфейс:


Итак, наш Store обладает следующими свойствами:

  • имеет два generic-параметра: входной Intent и выходной State;
  • является потребителем намерений (Consumer<Intent>);
  • является потоком состояний (Observable<State>);
  • он разрушаемый (Disposable).

Так как каждый раз реализовывать такой интерфейс не очень удобно, нам понадобится некий помощник:



StoreHelper — это небольшой класс, который облегчит для нас процесс создания Store. Он обладает следующими свойствами:

  • имеет три generic-параметра: входные Intent и Effect и выходной State;
  • принимает через конструктор начальное состояние, актор и редуктор;
  • является потоком состояний;
  • разрушаемый (Disposable);
  • незамораживаемый (чтобы подписчики тоже не были заморожены);
  • реализует DisposableScope (интерфейс из Reaktive для управления подписками);
  • принимает и обрабатывает намерения и эффекты.

Посмотрите на диаграмму нашего Store. Актор и редуктор в нём являются деталями реализации:



Рассмотрим подробнее метод onIntent:

  • принимает намерения как аргумент;
  • вызывает актор и передаёт в него намерение и текущее состояние;
  • подписывается на поток эффектов, возвращённый актором;
  • направляет все эффекты в метод onEffect;
  • подписка на эффекты выполняется с использованием флага isThreadLocal (это позволяет избежать заморозки в Kotlin/Native).

Теперь рассмотрим подробнее метод onEffect:

  • принимает эффекты как аргумент;
  • вызывает редуктор и передаёт в него эффект и текущее состояние;
  • передаёт новое состояние в BehaviorSubject, что приводит к получению нового состояния всеми подписчиками.

View


Теперь займёмся представлением. Оно должно принимать модели для отображения и выдавать поток событий. Тоже сделаем отдельный интерфейс:



Представление обладает следующими свойствами:

  • имеет два generic-параметра: входной Model и выходной Event;
  • принимает модели для отображения с помощью метода render;
  • выдаёт поток событий с помощью свойства events.

Я добавил префикс Mvi в название MviView, чтобы избежать путаницы с Android View. Также я не стал расширять интерфейсы Consumer и Observable, а использовал просто свойство и метод. Это для того, чтобы можно было выставить интерфейс представления в платформу для реализации (Android или iOS) без экспортирования Rx как “api”-зависимости. Хитрость в том, что клиенты не будут напрямую взаимодействовать со свойством “events”, а будут реализовывать интерфейс MviView, расширяя абстрактный класс.

Сразу добавим этот абстрактный класс для представления:


Этот класс поможет нам с выдачей событий, а также избавит платформы от взаимодействия с Rx.

Вот диаграмма, на которой видно, как это будет работать:



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

Это всё, что нам понадобится для реализации MVI. Приступим к написанию общего кода.

Общий код


План


  1. Мы сделаем общий модуль, задачей которого будет загрузка и отображение списка изображений котиков.
  2. UI абстрагируем интерфейсом и будем передавать его реализацию снаружи.
  3. Скроем нашу реализацию за удобным фасадом.

KittenStore


Начнём с главного — создадим KittenStore, который будет загружать список изображений:



Мы расширили интерфейс Store, указав типы намерений и тип состояния. Обратите внимание: интерфейс объявлен как internal. Наш KittenStore — это детали реализации модуля. Намерение у нас только одно — Reload, оно вызывает загрузку списка изображений. А вот состояние стоит рассмотреть подробнее:

  • флаг isLoading показывает, происходит в данный момент загрузка или нет;
  • свойство data может принимать один из двух вариантов:
    • Images — список ссылок на изображения;
    • Error — означает, что произошла ошибка.

Теперь начнём реализацию. Мы будем это делать поэтапно. Для начала создадим пустой класс KittenStoreImpl, который будет реализовывать интерфейс KittenStore:



Мы также реализовали уже знакомый нам интерфейс DisposableScope. Это необходимо для удобного управления подписками.

Нам понадобится загружать список изображений из Сети и парсить JSON-файл. Объявим соответствующие зависимости:


Network будет загружать текст для JSON-файла из Сети, а Parser — парсить JSON-файл и возвращать список ссылок на изображения. В случае ошибки Maybe будут просто заканчиваться без результата. В рамках данной статьи тип ошибки нас не интересует.

Теперь объявим эффекты и редуктор:


Перед началом загрузки мы выдаём эффект LoadingStarted, что приводит к выставлению флага isLoading. После окончания загрузки мы выдаём либо LoadingFinished, либо LoadingFailed. В первом случае мы сбрасываем флаг isLoading и применяем список изображений, во втором — тоже сбрасываем флаг и применяем состояние ошибки. Обратите внимание на то, что эффекты — это приватный API нашего KittenStore.

Теперь реализуем саму загрузку:


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

Ну и наконец используем StoreHelper и закончим реализацию KittenStore:


Наш KittenStore готов! Переходим к представлению.

KittenView


Объявим следующий интерфейс:


Мы объявили модель представления с флагами загрузки и ошибки и списком ссылок на изображения. Событие у нас всего одно — RefreshTriggered. Оно выдаётся каждый раз, когда пользователь вызывает обновление. KittenView — это публичный API нашего модуля.

KittenDataSource


Задачей этого источника данных будет загрузка текста для JSON-файла из Сети. Как обычно, объявим интерфейс:


Реализации источника данных будут сделаны для каждой платформы отдельно. Поэтому мы можем объявить фабричный метод, используя expect/actual:


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

Интеграция


Заключительный этап — интеграция всех компонентов.

Реализация интерфейса Network:



Реализация интерфейса Parser:


Здесь мы использовали библиотеку kotlinx.serialization. Парсинг выполняется на computation-планировщике во избежание блокировки главного потока.

Преобразование состояния в модель представления:


Преобразование событий в намерения:


Подготовка фасада:


Знакомый многим Android-разработчикам жизненный цикл. Он отлично подходит и для iOS, и даже для JavaScript. Диаграмма перехода между состояниями жизненного цикла нашего фасада выглядит так:


Поясню вкратце, что здесь происходит:

  • первым делом вызывается метод onCreate, после него — onViewCreated и затем — onStart: это переводит фасад в рабочее состояние (started);
  • в какой-то момент после этого вызывается метод onStop: это переводит фасад в остановленное состояние (stopped);
  • в остановленном состоянии может быть вызван один из двух методов: onStart или onViewDestroyed, то есть либо фасад может быть снова запущен, либо его представление может быть уничтожено;
  • когда представление уничтожено, либо оно может быть создано снова (onViewCreated), либо весь фасад может быть разрушен (onDestroy).

Реализация фасада может выглядеть следующим образом:


Как это работает:

  • сначала мы создаём экземпляр KittenStore;
  • в методе onViewCreated мы запоминаем ссылку на KittenView;
  • в onStart подписываем KittenStore и KittenView друг на друга;
  • в onStop зеркально отписываем их друг от друга;
  • в onViewDestroyed очищаем ссылку на представление;
  • в onDestroy уничтожаем KittenStore.

Заключение


Это была первая статья из моей серии про MVI в Kotlin Multiplatform. В ней мы:

  • вспомнили, что такое MVI и как он работает;
  • сделали простейшую реализацию MVI на Kotlin Multiplatform с использованием библиотеки Reaktive;
  • создали общий модуль для загрузки списка изображений с применением MVI.

Отметим наиболее важные свойства нашего общего модуля:

  • нам удалось вынести в мультиплатформенный модуль весь код за исключением кода UI; вся логика, плюс связи и преобразования между логикой и UI — общие;
  • логика и UI совершенно не связаны между собой;
  • реализация UI очень простая: необходимо только отображать входящие модели представления и выдавать события;
  • интеграция модуля тоже простая: всё, что нужно, — это:
    • реализовать интерфейс (протокол) KittenView;
    • создать экземпляр KittenComponent;
    • вызывать его методы жизненного цикла в нужный момент;
  • такой подход позволяет избежать «протекания» Rx (или корутин) в платформы, а это значит, что нам не придётся управлять какими-либо подписками на уровне приложений;
  • все важные классы абстрагированы интерфейсами и тестируемы.

В следующей части я покажу на практике, как выглядит интеграция KittenComponent в iOS- и Android-приложения.

Подписывайтесь на меня в Twitter и оставайтесь на связи!
Badoo
Big Dating

Комментарии 16

    0
    подскажите, а Kotlin Multipaltform насколько перспективный?
    Есть же Flutter, зачем Kotlin Multipaltform?
      0
      Ну это примерно как «зачем C#, есть же Qt».

      Kotlin Multipaltform — это просто «подмножество» (не совсем корректно, но лучше не придумал) языка, оптимизированное под нативную сборку на несколько платформ из одной кодовой базы.

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

      Они кстати не сказать, что взаимоисключают друг друга. Некоторые знающие толк в разработке мсье умудряются делать бизнесс-логику на Kotlin, а слой UI на Flutter в одном проекте.
        0

        Спасибо за вопрос! Я позволю себе ответить вопросом на вопрос. Зачем нужен Flutter с его "другим" Dart, если теперь есть Kotlin Multiplatform? На мой взгляд у Flutter было два преимущества: возможность программировать сразу под несколько платформ и декларативный UI. Первый пункт больше не является преимуществом, и даже теперь имеет минусы в виде "другого" Dart. Второй пункт пока ещё актуален, но я думаю мультиплатформенный Compose будет и тогда в этом вопросе будет поставлена точка.


        У KMP же есть следующие преимущества:


        • поддерживается больше платформ, среди которых есть нативные Linux x64, ARM и MIPS, watchOS, tvOS, Android Native, Windows и WASM. Полный список приведён здесь;
        • есть прямой доступ к API платформ с помощью expect/actual
        • можно легко управлять тем, какой код делать общим, а какой делегировать в платформы.
        • нативный UI (Android Views, Jetpack Compose, UIKit, SwiftUI и т.д.) позволяет поддерживать особенности платформ

        В целом, KMP как инструмент мне кажется на много мощнее.

          0
          получается, что Flutter может оказаться бесполезным в будущем, если Kotlin Multiplatform будет развиваться; правильно понимаю?
          В стратегическом плане лучше изучать Kotlin Multiplatform вместо Flutter?
            0

            На мой взгляд именно так. Могу дополнительно привести ссылку на твит одного известного в Андроид разработке человека. :-)

        +2

        Спасибо, очень интересно. Жду продолжения

          0

          Спасибо, что прочитали!

          +2

          Просто и доступно. Спасибо!

            0
            Зачем все эти сложности с Intent, да еще в сторе внутренний DSL из Effect? Почему нельзя сделать просто, например так
            //Stream of model state changes. Stateful view or controller subscribes to updates, and 
            //convert state changes into view/subview updates.
            public IObservable<StateChange> Updates { get; }
            
            public async Task Reload()
            {
                if(IsLoading) return;
                try
                {
                    IsLoading = true;
                    var netData = await network.LoadAsync();
                    Images = await parser.Parse(netData);
                }
                catch (Exception e)
                {
                     ImageLoadError = new Error(e);
                }
                finally 
                {
                    IsLoading = false;
                }
            }
            
            [State]
            public bool IsLoading { get; private set; }
            
            [State]
            public ImageData Images {get; private set; }
            [State]
            public Error ImageLoadError {get; private set; }
            
            


            Я могу представить, что сложная модель на сервере может использовать Effect, чтобы выполнять IO пачкой. Есть входная очередь запросов в модель, на выходе очередь из Effect, и обработчик эффектов, который их пачками складываем в базу, рассылает по веб-сокетам и т.п. Но зачем все эти сложности в мобильном приложении мне не понятно.
              0

              Спасибо за вопрос! В вашем примере Представление/контроллер — stateful, однако в MVI они должны быть stateless. Единым источником правды должна быть модель (Store). Таким образом Ваш вопрос сводится к "Зачем нужен MVI?".
              Это уже особо не относится к теме этой статьи, но я попробую ответить.
              Единый источник правды делает состояние всегда консистентным, что существенно упрощает поддержку и отладку когда. Также это даёт возможность делать крутые штуки вроде time travel отладки. Разделение на такие сущности, как Intent, State, Model и Event, позволяет делать представление и модель независимыми друг от друга. Опять же, компоненты, между которыми отсутствует связность, легче поддерживать и тестировать.


              Спасибо, что прочитали статью!

                0

                В приведённом мной примере контроллера может и не быть, может просто view с методом Render(StateChange). Все компоненты независимы, модель источник правды. Time travel нет, но он не стоит этих сложностей. В реальной жизни есть stateful widgets, которые с time travel не дружат.


                Собственно да, мой вопрос сводится к тому, что нам даёт заворачивание запросов к модели в Intent по сравнению с вызовом методов. Model.Dispatch(new Intent(DoSomething)) против простого Model.DoSomething()

                0

                Если у Представления есть метод render(StateChange) то по определению Представление зависит от от Состояния Модели, что делает Представление и Модель связанными. Введение отдельной Модели Представления избавляет от этой связности. Тоже самое в обратную сторону, судя по всему предполагается, что Представление будет обращаться к Модели на прямую (вызывать метод Reload). Это прямая зависимость Представления от Модели.


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


                MVI решает эти проблемы, представляя входы и выходы как потоки данных, которые можно преобразовывать. Имея класс Event можно преобразоывать его в Intent направить на вход Модели. Или же направить в некий AnalyticsTracker для аналитики. Можно преобразовать в Output Контроллера и выставить этот поток наружу.


                В целом за счёт небольшого количества "boilerplate" кода достигается меньшая связность, поддерживаемость и безопасность.

                  0
                  Если у Представления есть метод render(StateChange) то по определению Представление зависит от от Состояния Модели, что делает Представление и Модель связанными....


                  И что с того? Это же UI, модель делается для того, чтобы ее удобно было отображать. Задача модели, буквально, загрузить данные и представить их в виде, удобном для отображения. Вся цепочка, снизу вверх выглядит вот так
                  1. Сервисы, которые тянут данные из хранилища. Либо REST, WebSocket, Local File, DB, etc. Они работают с данными заточенными под удобное хранение и извлечение.
                  2. Модель для UI, которая транслирует данные их сервисов в вид удобный для отображения. Также содержит transient state, статус загрузки.
                  2.5 Опциональный компонент, который рендерит модель, использую методы view. Это если мы хотим, чтобы view не знала о модели. Тогда из view торчит API в терминах Color, Font, Border, Text и т.п. А компонент отображает изменения модели на это API.
                  3. View, который эту модель отображает, самостоятельно или с посредством помощника как описано выше.

                  Я не знаю ни одной причины иметь другую схему, кроме желания следовать каким-то там принципам. Если мы с этим согласимся, то связь между view и model становится желательной. Возможно они не имеют ссылок друг на друга, но их логика и структура согласованы — меняется одно, скорее всего меняется и другое. Чтобы избавится от явной ссылки есть контроллер, вы пишите «view.events.map(Event::toIntent).subscribeScoped(onNext = store::onNext)», я напишу «View.Event += () => Model.Reload()». Никакой разницы, кроме того, что у вас явно очереди прописаны, а у меня они неявно присутствуют.

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


                  Про потоки и синхронизацию нужно думать в любом случае, поэтому у вас есть «observeOn(mainScheduler)», без него все развалится. Работу с моделью вам нужно либо явно синхронизировать, либо делать ее в выделенном потоке. А это значит, что когда parser закончил работу он должен следующее действие делать в потоке модели. А если вдруг об этом кто-то забыл, то будет беда.

                  >Имея класс Event можно преобразоывать его в Intent направить на вход Модели. Или же направить в некий AnalyticsTracker для аналитики.

                  Я напишу «View.Event += e => Analytiсs.RecordEvent(e.ToAnalyticalEvent())» и не буду морочить себе голову высокими материями.

                  >В целом за счёт небольшого количества «boilerplate» кода достигается меньшая связность, поддерживаемость и безопасность.

                  1. Связность, осталась такой же, описал выше.
                  2. Поддерживаемость — стала хуже, больше кода, больше концепций, больше краевых случаев
                  3. Безопасность — осталась такой же, потоки все еще проблема.

                  Я знаю один кейс, кода ваш подход работает, я даже именно так модель и пишу, но на сервере. Модель сложная, сидит в памяти и нужно ее очень быстро обрабатывать. На входе в модель очередь инструкций (ваши Intent), на выходе очередь «изменений модели» (ваши Model). Выходная очередь позволяет изменения модели паковать в пачки и эффективно делать I/O в БД и сеть. Но дело не только в очередях — в такой модели машина состояний нужна в явном виде, потому что сервер может упасть и нужно будет с произвольной точки восстанавливаться.
                  +1

                  Похоже, обсуждение переходит в обсуждение полезности шаблона MVI, что выходит за рамки данной статьи. Каждая команда выбирает свой подход к разработке. Мы последовательным путём пришли именно к MVI. Мы пробовали разные подходы и остановились именно на этом. У нас есть необходимость переиспользовать код в разных проектах. Мы иногда имеем более одной реализации вью. Иногда имеем более одно реализации бизнес логики. От инкрементального обновления вью мы ушли впользу stateless. От размызывания состояния по переменным мы ушли в пользу единого неизменяемого "хранилища" и обновления из одного места — редуктора. Кроме того, жизненный цикл вью может быть уже чем у модели. Мы можем отсоеденить вью от модели и потом присоеденить новый экземпляр, может быть даже другую реализацию.

                    +1

                    Вообще интересный подход к разработке.
                    Если назвать KittenComponent — KittenPresenter, всё встаёт на свои места. По сути это тот же MVP, только state храниться в моделях, а Presenter уже больше proxy.
                    Из плюсов, явно, это восстановление состояния после пересоздания Fragment, не нужно никаких дополнительных фреймворков.
                    Хотел бы увидеть большой пример, не будет ли это всё громоздко.

                      0

                      Спасибо, что прочитали! Большого примера в открытом виде, к сожалению, нет. Но общий принцип — делить на модули и не допускать их распухания. Компонент (презентер) — необязательно экран, это может быть и его часть. Общение между ними можно наладить при помощи входов/выходов, по такому же принципу, как Store соединяется со View внутри модуля.

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

                    Самое читаемое