Я хочу поделиться своим опытом создания кроссплатформенного приложения на базе kotlin-multiplatform (KMP), организацией его архитектуры и настройкой для работы с различными библиотеками. Я буду рассказывать о процессе создания приложения поэтапно и опишу каждую из особенностей его работы. Статья подойдет больше разработчикам, которые уже имеют опыт с многомодульными проектами в android и начинают изучать KMP. В конце я опишу свою реализацию архитектурного паттерна MVI и его применение в проекте. Также хотелось бы отметить, что в данной статье описано мое личное видение способов организации кода в проекте и я никого не принуждаю их использовать и статья носит лишь информативный характер.

Полный код проекта вы можете посмотреть - тут.

О приложении

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

Архитектура приложения

Многомодульная архитектура

Архитектура внутри модуля

Clean

Архитектура UI

MVI

Локальное хранение информации

Room

Работа в сети

Ktor

DI фреймворк

Koin

Способ отрисовки UI

Compose Multiplatform

Навигация

Voyager

Работа с ресурсами

Compose Multiplatform

Тестовое приложение будет использовать открытое REST-API DogAPI для получения изображения собачек. После получения из сети ссылки на изображение будет возможность сохранить ее в БД для последующего отображения. Тестовое приложение будет разделено на 3 экрана.

  • MainScreen — главный экран приложения с кнопками для перехода к следующим экранам,

  • DogsScreen — экран для загрузки картинки и сохранения ссылки на нее в БД, 

  • SavedDogsScreen — экран со списком сохраненных картинок.

Экраны тестового приложения
Экраны тестового приложения
  1. Создание проекта

Для создания проекта удобнее всего использовать Kotlin Multiplatform Wizard. С помощью него указывается основное имя проекта, id и выбираются платформы, под которые будет настроен проект. В моем случае проект будет работать на android и iOS платформах. Далее после создания проекта его можно открыть с помощью Android Studio. В итоге мы получим структуру проекта, выполненную в одном модуле.

Генерация проекта через Kotlin Multiplatform Wizard
Генерация проекта через Kotlin Multiplatform Wizard
  1. Принцип разделения логики для платформ в KMP

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

  • common-подмодуль, где реализуется общая для всех платформ логика,

  • platform-подмодули, где реализуется логика для каждой из платформ отдельно.

Для подключения той или иной платформы необходимо прописать их в build.gradle.kts файле модуля, также в нем описываются зависимости для каждой из платформ.

Настройка  build.gradle.kts
Настройка build.gradle.kts

Для доступа к платформозависящей логики в common-подмодуле необходимо ее объявить и пометить ключевым словом expect, а далее в каждом подмодуле платформы реализовать данный код с ключевым словом actual. Например, в сгенерированном проекте можно увидеть следующий код:

Реализация кода для разных платформ
Реализация кода для разных платформ
  1. Многомодульная архитектура

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

В моем варианте она будет делится на common, core, component, feature и app модули. 

Common

Модули, которые не зависят от проекта, их можно с легкостью переиспользовать в любых проектах. Желательно независящие от других модулей. Своего рода локальные библиотеки.

Core

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

Component

Модули, в которых находится логика работы с каким-либо компонентом. Могут зависеть друг от друга, от Common и Core модулей.

Feature

Модули, в которых находится логика работы с каким-либо экраном или группой экранов. Как правило, делятся на API и IMPL части для возможности ссылаться друг на друга. Могут зависеть друг от друга, от Component, Common и Core модулей.

App

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

Общая архитектура проекта
Общая архитектура проекта

После завершения создания приложения в структуре моего проекта будут следующие модули:

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

Далее я расскажу о особенностях каждого модуля.

  1. app-модуль

Главным модулем приложения является app-модуль, он зависит от всех остальных модулей и инициализирует приложение.

Функции app модуля:

  • точка входа в приложение;

  • держатель DI графа;

  • старт compose-ui и инициализация навигации;

  • инициализация.

Точка входа.

Для каждой из платформ необходима своя точка входа в приложение, которая располагается в app модуле в своем подмодуле платформы:

  • для android - Application; 

  • для iOS - MainViewController (используется в xcode проекте). 

Держатель DI графа.

В этом приложении для реализации инверсии зависимостей я использую koin. Для подключения DI-графа к app-модулю необходимо вызвать метод startKoin и указать в его лямбде необходимые koin-модули. 

  • Для android данный метод вызывается в методе onCreate класса Application;

  • Для iOS необходимо вызвать метод initKoin из iOSApp.swift проекта xcode. 

Для сбора всех koin-модулей используется список appModules, в котором находятся все koin-модули от других модулей проекта. Данный список описан в common-подмодуле модуля-app, это означает что он является доступным для всех платформ. 

Подключение DI-графа для различных платформ
Подключение DI-графа для различных платформ

Старт compose UI и инициализация навигации.

Для подключения compose-ui необходимо вызвать метод setContent для android и iOS платформ.
Для android в данном случае все просто: этот метод вызывается  внутри метода onCreate класса MainActivity.
Для iOS же необходимо вызвать его внутри специального метода, который создает compose контейнер в swiftUI — ComposeUIViewController, далее MainViewController необходимо вызвать внутри ContentView.swift про��кта xcode.

Для реализации навигации на базе Voyager необходимо вызвать Composable метод Navigator внутри метода setContent.

Реализация навигации на базе Voyager
Реализация навигации на базе Voyager

Инициализация.

В Android вся инициализация происходит в App классе проекта. 

В iOS же первичной точкой входа является iOSApp, который реализован в xcode проекте. Он в свою очередь вызывает методы из kotlin проекта. Возможны ситуации, когда специфичные для iOS платформы методы необходимо вызвать в kotlin проекте, для этого возможно предоставить в kotlin интерфейс и реализовать его в swift, а затем предать данную реализацию при вызове метода инициализации.

Инициализация приложения
Инициализация приложения
  1. Database

Для реализации базы данных я буду использовать БД на основе room-multiplatform. В первую очередь для ее реализации необходимо подключить в build.gradle.kts плагины ksp и room, добавить необходимые зависимости на room и sqlite, а также подключить ksp к room компилятору.

Настройка gradle для работы с room
Настройка gradle для работы с room

Далее необходимо создать модели Entity, интерфейсы Dao и RoomDatabase класс. Они реализуются аналогично room для android.

Реализация моделей для БД
Реализация моделей для БД

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

Реализация БД
Реализация БД

Для iOS необходимо указать factory как AppDatabase::class.instantiateImpl(). Данный экстеншен является сгенерированным плагином room и будет доступен после сборки проекта.

Также отмечу, что в моем случае расположение файлов для iOS платформы в каталоге iosMain приводило к недоступности вышеуказанного экстеншена, для решения данной проблемы необходимо было реализовать данный код в каждом из iOS каталогов платформ (iosX64Main, iosArm64Main, iosSimulatorArm64Main).

Далее platformDatabaseModule возможно использовать для предоставления доступа к БД, а также для удобства возможно предоставить Dao через DI.

Предоставление доступа к БД и Dao через DI
Предоставление доступа к БД и Dao через DI
  1. Network

Для реализации http клиента на основе ktor необходимо его создать с необходимыми параметрами и создать koin-модуль для возможности инжектировать ktor-клиент в необходимые репозитории. Также полезными будут обертки вокруг ktor-запросов в более удобный для обработки kotlin.Result.

Реализация ktor-клиента
Реализация ktor-клиента
  1. Resources

Compose-multiplatform поддерживает использование ресурсов практически в том же виде,  как и в рамках стандартного проекта android. Я в проекте использую png, xml и string ресурсы. Все ресурсы необходимо добавлять в common-подмодуль в директорию “composeResources”. После синхронизации будет сгенерирован объект Res и его методы расширения для доступа к ресурсам.

Генерация объекта Res для доступа к ресурсам
Генерация объекта Res для доступа к ресурсам

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

Настройка gradle для доступа к ресурсам из других модулей
Настройка gradle для доступа к ресурсам из других модулей

В итоге мы получаем готовый для использования объект Res и его расширения.

  1. Component

Компонент-модуль представляет собой data и domain слои clean-архитектуры. Я считаю, что единственными точками входа в данный модуль должны являться usecase-ы для выполнения той или иной операции. Также все реализации предоставляются через koin-модуль. 

Архитектура component-модуля
Архитектура component-модуля

В рассматриваемом приложении имеется один component-модуль — dogs. В нем сосредоточена вся логика по работе с DogsAPI и базой данных, в которой хранятся все полученные от API данные. Далее разберем его подробнее.

Domain

В domain слое определяются модели, интерфейс репозитория, в котором регистрируются методы по работе с компонентом и usecase-ы для доступа к определенному функционалу. Usecase-ы разделены на интерфейс и его реализацию для возможности ее подмены в рамках тестирования или процесса отладки.

 Domain слой component модуля
Domain слой component модуля

Data

В data слое реализуется интерфейс репозитория, а также описываются методы по работе с api. Сюда посредством DI предоставляются Dao, описанные в модуле базы данных, а также описываются мапперы для преобразования domain моделей в data и наоборот.

Data слой component модуля
Data слой component модуля

DI

Реализация всех вышеуказанных интерфейсов предоставляется с помощью koin-модуля.

DI component-модуля
DI component-модуля

В итоге структура component-модуля выглядит следующим образом:

Структура component-модуля
Структура component-модуля
  1. MVI

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

MVI — архитектурный паттерн, при котором существует поток намерений и управление состоянием.

Классическая схема MVI
Классическая схема MVI

Однако в контексте мобильного приложения видение MVI немного меняется, т. к. поток намерений, как правило, не однонаправленный, а имеет два направления:

  • Event — то что нужно сделать View (запрос разрешений, показ диалога и т. д.):

  • Action — событие на View, которое нужно обработать в моделе (нажатие кнопки).

    Схема MVI для мобильного приложения
    Схема MVI для мобильного приложения

Для обработки данных намерений нам нужны сущности, которые будут с ними работать, а именно:

  • Reducer — по Effect меняет State.

  • Actor — обрабатывает Action, генерирует Event или Effect;

  • Bootstrap — загрузчик, выполняет инициализацию и предзагрузку данных, генерирует Event или Effect;

  • Model - контейнер для хранения основных сущностей и сохранения текущего стейта.

Схема работы MVI совместно с основными сущностями
Схема работы MVI совместно с основными сущностями

И теперь можно представить, как данная архитектура впишется в общую clean-архитектуру с использованием component-модуля, о котором говорилось ранее:

Общая схема работы MVI с clean-архитектурой
Общая схема работы MVI с clean-архитектурой
  1. Реализация MVI

Вся логика работы MVI делится на две части. 

Первая часть — general

Тут описывается логика, независящая от платформы и сторонних библиотек, а именно:

  • основные объекты MVI;

Объекты MVI
Объекты MVI
  • интерфейс MVI, описывающий логику работы MVI;

Интерфейс MVI
Интерфейс MVI
  • реализация MVI (описана в классе MviImpl).

Общий процесс его работы следующий:

В качестве аргументов данному классу подаются: параметры логирования, coroutine-scope и dispatcher, а также reducer, actor и bootstrap, которые являются лямбдами.

Реализация логики работы MVI
Реализация логики работы MVI

В нем определены три канала для eventChannel, effectChannel, actionChannel и методы для отправки в них нового значения.

Каналы с данными
Каналы с данными

Также определен bufferStateFlow, который служит хранилищем текущего MviState. Данный буфер определен как MutableSharedFlow и имеет емкость равной единице (для того, чтобы не копить неактуальные стейты) и поведение при переполнении — пропуск старых значений (для того, чтобы не обрабатывать старые состояния экрана).

Буфер состояний
Буфер состояний

Далее определен eventFlow с MviEvent, который лениво получается из канала eventChannel

Flow с Event
Flow с Event

и stateFlow для MviState, который лениво получается из bufferStateFlow.

При страте подписки на bufferStateFlow происходит:

  1. подписка на канал actionChannel, где каждый MviAction отправляется в Actor;

  2. подписка на канал effectChannel, где каждый MviEffect преобразуется в MviState c помощью Reducer и отправляется в bufferStateFlow;

  3. происходит вызов Bootstrap.

Получение Flow с состоянием
Получение Flow с состоянием

Схема работы MVI выглядит следующим образом:

Итоговая схема работы MVI
Итоговая схема работы MVI

Вторая часть — mvi-koin-voyager

Тут описан абстрактный класс MviModel и интерфейс MviView. По факту, это модуль с конкретной реализацией Model и View на основе библиотек koin и voyager. Данную реализацию можно заменить на любую другую, например, с использованием стандартной VewModel и hilt в случае использования только на android. 

В текущем примере, MviModel — реализует интерфейс ScreenModel из библиотеки Voyager и ранее упомянутый интерфейс Mvi, а также инкапсулирует объект mvi реализующий интерфейс Mvi и объявляет методы bootstrap, actor и reducer для передачи их в реализацию объекта mvi. Можно заметить, что почти все описание MviModel можно вынести в general часть, кроме определения coroutineScope, в котором работает MVI. Однако я не стал этого делать, чтобы иметь возможность унаследовать MviModel от ViewModel android, которая является абстрактным классом.

MviModel на основе ScreenModel от voyager
MviModel на основе ScreenModel от voyager

Дальнейшая реализация MviModel в фиче-модуле происходит посредством koin с тегом названия класса, реализующего интерфейс MviView. Для этого создан вспомогательный метод, возвращающий KoinDefinition. В него с помощью дженериков передается класс реализующий MviView и имя данного класса используется в качестве ключа для создания фабрики MviModel.

Предоставление MviModel через DI
Предоставление MviModel через DI

MviView является интерфейсом, который наследуется от интерфейса Screen из библиотеки Voyager. В нем мы определяем метод Content, в котором по имени текущей реализации данного интерфейса находится MviModel, и вызываем метод для отрисовки compose-ui - content, который будет определен конкретной реализаций. В данный метод передается State, Flow с MviEvent и лямбда для выполнения MviAction.

MviView на основе Screen из voyager
MviView на основе Screen из voyager

Также имеется метод, упрощающий подписку на Flow с MviEvent.

Подписка на Flow с Event
Подписка на Flow с Event

Далее возможно создавать фичи-модули с использованием вышеупомянутых файлов и реализовывать в них логику с помощью реализаций MviAction, MviEffect, MviEvent и MviState.

  1. Пример реализации фичи

Модуль фичи разделен на Api- и Impl-модули для возможности ссылаться друг на друга. 

  • в Api-модуле представлен интерфейс с функционалом данного модуля. В данном случае это два метода для вызова экранов;

  • в Impl-модуле находится реализация интерфейса из Api-модуля и реализация самой фичи (экранов).

Api и Impl части модуля фичи
Api и Impl части модуля фичи

Реализация предоставляется посредством DI, а именно koin-модуля, также в нем предоставляются MviModel экранов с помощью экстеншена упомянутого ранее. Данный koin-модуль в дальнейшем добавляется в список модулей проекта в app модуле.

DI модуля фичи
DI модуля фичи

Сам экран описывается четырьмя основными элементами:

  • экран - реализует MviScreen;

  • модель - реализует MviModel;

  • compose-контент;

  • модели реализующие MviAction, MviEffect, MviEvent и MviState.

Структура модуля фичи
Структура модуля фичи

Модели для экрана с сохраненными собачками выглядят следующим образом:

  • SavedDogsScreenAction — события на экране, в данном случае имеется событие нажатия кнопки “назад”;

  • SavedDogsScreenState — состояние экрана, на котором мы имеем список с собачками;

  • SavedDogsScreenEffect — намерения по изменению состояния, в данном случае имеется намерение по обновлению списка собачек в состоянии;

  • SavedDogsScreenEvent — события для экрана, а именно навигация назад.

Объекты MVI в модуле фичи
Объекты MVI в модуле фичи

Экран SavedDogsScreen работает следующим образом:

  • определяет текущий навигатор (из voyager библиотеки);

  • подписывается на Flow с SavedDogsScreenEvent;

  • отрисовывает compose-контент посредством вызова метода SavedDogsScreenContent, в который передается текущий SavedDogsScreenState и принимаются колбеки для обработки SavedDogsScreenAction.

Реализация экрана SavedDogsScreen
Реализация экрана SavedDogsScreen

Модель SavedDogsScreenModel работает следующим образом:

  • переопределяет метод bootstrap, в котором выполняется подписка на сохраненных в БД собачек с помощью ObserveRandomDogUseCase, при получении данных происходит вызов метода push(SavedDogsScreenEffect.DogsUpdated);

  • переопределяет метод actor, который при обработки события SavedDogsScreenAction.ClickButtonBack вызывает метод push(SavedDogsScreenEvent.NavigateToBack);

  • переопределяет метод reducer, который при обработке SavedDogsScreenEffect.DogsUpdated обновляет текущее состояние.

Реализация модели SavedDogsScreenModel
Реализация модели SavedDogsScreenModel
  1. Логирование

В реализацию MVI добавлена возможность получения логов для отслеживания процесса работы приложения. Вывод лога формируется следующим образом:

Структура сообщения из логов
Структура сообщения из логов

Логирование происходи на всех этапах: обновление состояния, получение MviEvent, получение MviAction, получение MviEffect.

Вывод логов в LogCat
Вывод логов в LogCat

Данный функционал позволяет полностью отслеживать процесс работы того или иного экрана, а также возможноcть расширить функционал логирования и добавить запись логов в файл или БД, с последующей отправкой на удаленный сервер.

Итог

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

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