Я хочу поделиться своим опытом создания кроссплатформенного приложения на базе 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 реализации при большом объеме данных. 

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


  1. Lucker216
    24.06.2024 13:59
    +2

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

    Но также небольшие вопросы возникли к composeResource. Есть, хоть и с нюансами, но несколько прогрессивнее для kmp библиотека moko-resource, которая генерирует нативные ресурсы для платформ, в отличии от ComposeResource, что позволяет в том же андроиде нормально разделить ресурсы по сплитам в итоговом бандле для сгрузки в гугл плей


  1. Takeite4zy
    24.06.2024 13:59

    Альтернативная архитектура для более простых проектов https://github.com/vladimirlogachov/MoviesPot