Я хочу поделиться своим опытом создания кроссплатформенного приложения на базе kotlin-multiplatform (KMP), организацией его архитектуры и настройкой для работы с различными библиотеками. Я буду рассказывать о процессе создания приложения поэтапно и опишу каждую из особенностей его работы. Статья подойдет больше разработчикам, которые уже имеют опыт с многомодульными проектами в android и начинают изучать KMP. В конце я опишу свою реализацию архитектурного паттерна MVI и его применение в проекте. Также хотелось бы отметить, что в данной статье описано мое личное видение способов организации кода в проекте и я никого не принуждаю их использовать и статья носит лишь информативный характер.
Полный код проекта вы можете посмотреть - тут.
О приложении
В таблице указаны подходы и библиотеки, используемые мной в тестовом приложении:
Архитектура приложения |
Многомодульная архитектура |
Архитектура внутри модуля |
Clean |
Архитектура UI |
MVI |
Локальное хранение информации |
|
Работа в сети |
|
DI фреймворк |
|
Способ отрисовки UI |
|
Навигация |
|
Работа с ресурсами |
Тестовое приложение будет использовать открытое REST-API DogAPI для получения изображения собачек. После получения из сети ссылки на изображение будет возможность сохранить ее в БД для последующего отображения. Тестовое приложение будет разделено на 3 экрана.
MainScreen — главный экран приложения с кнопками для перехода к следующим экранам,
DogsScreen — экран для загрузки картинки и сохранения ссылки на нее в БД,
SavedDogsScreen — экран со списком сохраненных картинок.
Создание проекта
Для создания проекта удобнее всего использовать Kotlin Multiplatform Wizard. С помощью него указывается основное имя проекта, id и выбираются платформы, под которые будет настроен проект. В моем случае проект будет работать на android и iOS платформах. Далее после создания проекта его можно открыть с помощью Android Studio. В итоге мы получим структуру проекта, выполненную в одном модуле.
Принцип разделения логики для платформ в KMP
В основе кроссплатформенного модуля лежит несколько подмодулей:
common-подмодуль, где реализуется общая для всех платформ логика,
platform-подмодули, где реализуется логика для каждой из платформ отдельно.
Для подключения той или иной платформы необходимо прописать их в build.gradle.kts файле модуля, также в нем описываются зависимости для каждой из платформ.
Для доступа к платформозависящей логики в common-подмодуле необходимо ее объявить и пометить ключевым словом expect, а далее в каждом подмодуле платформы реализовать данный код с ключевым словом actual. Например, в сгенерированном проекте можно увидеть следующий код:
Многомодульная архитектура
В сгенерированном проекте имеется всего один заглавный модуль — composeApp. Для реализации многомодульности необходимо определить архитектуру, в которой она будет выполнена.
В моем варианте она будет делится на common, core, component, feature и app модули.
Common |
Модули, которые не зависят от проекта, их можно с легкостью переиспользовать в любых проектах. Желательно независящие от других модулей. Своего рода локальные библиотеки. |
Core |
Модули, которые зависят от проекта, в них находится общая логика, которую используют компоненты и фичи. Могут зависеть от Common и других Core модулей. |
Component |
Модули, в которых находится логика работы с каким-либо компонентом. Могут зависеть друг от друга, от Common и Core модулей. |
Feature |
Модули, в которых находится логика работы с каким-либо экраном или группой экранов. Как правило, делятся на API и IMPL части для возможности ссылаться друг на друга. Могут зависеть друг от друга, от Component, Common и Core модулей. |
App |
Главный модуль приложения, который собирает в себе все остальные модули и проводит начальную инициализацию проекта. |
После завершения создания приложения в структуре моего проекта будут следующие модули:
Далее я расскажу о особенностях каждого модуля.
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, это означает что он является доступным для всех платформ.
Старт compose UI и инициализация навигации.
Для подключения compose-ui необходимо вызвать метод setContent для android и iOS платформ.
Для android в данном случае все просто: этот метод вызывается внутри метода onCreate класса MainActivity.
Для iOS же необходимо вызвать его внутри специального метода, который создает compose контейнер в swiftUI — ComposeUIViewController, далее MainViewController необходимо вызвать внутри ContentView.swift проекта xcode.
Для реализации навигации на базе Voyager необходимо вызвать Composable метод Navigator внутри метода setContent.
Инициализация.
В Android вся инициализация происходит в App классе проекта.
В iOS же первичной точкой входа является iOSApp, который реализован в xcode проекте. Он в свою очередь вызывает методы из kotlin проекта. Возможны ситуации, когда специфичные для iOS платформы методы необходимо вызвать в kotlin проекте, для этого возможно предоставить в kotlin интерфейс и реализовать его в swift, а затем предать данную реализацию при вызове метода инициализации.
Database
Для реализации базы данных я буду использовать БД на основе room-multiplatform. В первую очередь для ее реализации необходимо подключить в build.gradle.kts плагины ksp и room, добавить необходимые зависимости на room и sqlite, а также подключить ksp к room компилятору.
Далее необходимо создать модели Entity, интерфейсы Dao и RoomDatabase класс. Они реализуются аналогично room для android.
После появится возможность предоставить реализацию AppDatabase с помощью DI. Данная реализация различается на платформах, поэтому необходимо описать ее для каждой из платформ отдельно.
Для iOS необходимо указать factory как AppDatabase::class.instantiateImpl(). Данный экстеншен является сгенерированным плагином room и будет доступен после сборки проекта.
Также отмечу, что в моем случае расположение файлов для iOS платформы в каталоге iosMain приводило к недоступности вышеуказанного экстеншена, для решения данной проблемы необходимо было реализовать данный код в каждом из iOS каталогов платформ (iosX64Main, iosArm64Main, iosSimulatorArm64Main).
Далее platformDatabaseModule возможно использовать для предоставления доступа к БД, а также для удобства возможно предоставить Dao через DI.
Network
Для реализации http клиента на основе ktor необходимо его создать с необходимыми параметрами и создать koin-модуль для возможности инжектировать ktor-клиент в необходимые репозитории. Также полезными будут обертки вокруг ktor-запросов в более удобный для обработки kotlin.Result.
Resources
Compose-multiplatform поддерживает использование ресурсов практически в том же виде, как и в рамках стандартного проекта android. Я в проекте использую png, xml и string ресурсы. Все ресурсы необходимо добавлять в common-подмодуль в директорию “composeResources”. После синхронизации будет сгенерирован объект Res и его методы расширения для доступа к ресурсам.
В сгенерированном объекте Res и его экстеншенах указан тип доступа — internal, что не дает возможность использовать их в других модулях. Для решения данной проблемы можно указать в build.gradle.kts настройки генерации ресурсов.
В итоге мы получаем готовый для использования объект Res и его расширения.
Component
Компонент-модуль представляет собой data и domain слои clean-архитектуры. Я считаю, что единственными точками входа в данный модуль должны являться usecase-ы для выполнения той или иной операции. Также все реализации предоставляются через koin-модуль.
В рассматриваемом приложении имеется один component-модуль — dogs. В нем сосредоточена вся логика по работе с DogsAPI и базой данных, в которой хранятся все полученные от API данные. Далее разберем его подробнее.
Domain
В domain слое определяются модели, интерфейс репозитория, в котором регистрируются методы по работе с компонентом и usecase-ы для доступа к определенному функционалу. Usecase-ы разделены на интерфейс и его реализацию для возможности ее подмены в рамках тестирования или процесса отладки.
Data
В data слое реализуется интерфейс репозитория, а также описываются методы по работе с api. Сюда посредством DI предоставляются Dao, описанные в модуле базы данных, а также описываются мапперы для преобразования domain моделей в data и наоборот.
DI
Реализация всех вышеуказанных интерфейсов предоставляется с помощью koin-модуля.
В итоге структура component-модуля выглядит следующим образом:
MVI
Далее я постараюсь описать свой подход к реализации архитектурного паттерна MVI. Я не заявляю о его уникальности или идеальности, просто, на мой взгляд, с такой реализаций MVI максимально удобно работать и не нужно создавать большое количество файлов для описания всего процесса. Начнем с основных понятий.
MVI — архитектурный паттерн, при котором существует поток намерений и управление состоянием.
Однако в контексте мобильного приложения видение MVI немного меняется, т. к. поток намерений, как правило, не однонаправленный, а имеет два направления:
Event — то что нужно сделать View (запрос разрешений, показ диалога и т. д.):
-
Action — событие на View, которое нужно обработать в моделе (нажатие кнопки).
Для обработки данных намерений нам нужны сущности, которые будут с ними работать, а именно:
Reducer — по Effect меняет State.
Actor — обрабатывает Action, генерирует Event или Effect;
Bootstrap — загрузчик, выполняет инициализацию и предзагрузку данных, генерирует Event или Effect;
Model - контейнер для хранения основных сущностей и сохранения текущего стейта.
И теперь можно представить, как данная архитектура впишется в общую clean-архитектуру с использованием component-модуля, о котором говорилось ранее:
Реализация MVI
Вся логика работы MVI делится на две части.
Первая часть — general
Тут описывается логика, независящая от платформы и сторонних библиотек, а именно:
основные объекты MVI;
интерфейс MVI, описывающий логику работы MVI;
реализация MVI (описана в классе MviImpl).
Общий процесс его работы следующий:
В качестве аргументов данному классу подаются: параметры логирования, coroutine-scope и dispatcher, а также reducer, actor и bootstrap, которые являются лямбдами.
В нем определены три канала для eventChannel, effectChannel, actionChannel и методы для отправки в них нового значения.
Также определен bufferStateFlow, который служит хранилищем текущего MviState. Данный буфер определен как MutableSharedFlow и имеет емкость равной единице (для того, чтобы не копить неактуальные стейты) и поведение при переполнении — пропуск старых значений (для того, чтобы не обрабатывать старые состояния экрана).
Далее определен eventFlow с MviEvent, который лениво получается из канала eventChannel
и stateFlow для MviState, который лениво получается из bufferStateFlow.
При страте подписки на bufferStateFlow происходит:
подписка на канал actionChannel, где каждый MviAction отправляется в Actor;
подписка на канал effectChannel, где каждый MviEffect преобразуется в MviState c помощью Reducer и отправляется в bufferStateFlow;
происходит вызов Bootstrap.
Схема работы 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 в фиче-модуле происходит посредством koin с тегом названия класса, реализующего интерфейс MviView. Для этого создан вспомогательный метод, возвращающий KoinDefinition. В него с помощью дженериков передается класс реализующий MviView и имя данного класса используется в качестве ключа для создания фабрики MviModel.
MviView является интерфейсом, который наследуется от интерфейса Screen из библиотеки Voyager. В нем мы определяем метод Content, в котором по имени текущей реализации данного интерфейса находится MviModel, и вызываем метод для отрисовки compose-ui - content, который будет определен конкретной реализаций. В данный метод передается State, Flow с MviEvent и лямбда для выполнения MviAction.
Также имеется метод, упрощающий подписку на Flow с MviEvent.
Далее возможно создавать фичи-модули с использованием вышеупомянутых файлов и реализовывать в них логику с помощью реализаций MviAction, MviEffect, MviEvent и MviState.
Пример реализации фичи
Модуль фичи разделен на Api- и Impl-модули для возможности ссылаться друг на друга.
в Api-модуле представлен интерфейс с функционалом данного модуля. В данном случае это два метода для вызова экранов;
в Impl-модуле находится реализация интерфейса из Api-модуля и реализация самой фичи (экранов).
Реализация предоставляется посредством DI, а именно koin-модуля, также в нем предоставляются MviModel экранов с помощью экстеншена упомянутого ранее. Данный koin-модуль в дальнейшем добавляется в список модулей проекта в app модуле.
Сам экран описывается четырьмя основными элементами:
экран - реализует MviScreen;
модель - реализует MviModel;
compose-контент;
модели реализующие MviAction, MviEffect, MviEvent и MviState.
Модели для экрана с сохраненными собачками выглядят следующим образом:
SavedDogsScreenAction — события на экране, в данном случае имеется событие нажатия кнопки “назад”;
SavedDogsScreenState — состояние экрана, на котором мы имеем список с собачками;
SavedDogsScreenEffect — намерения по изменению состояния, в данном случае имеется намерение по обновлению списка собачек в состоянии;
SavedDogsScreenEvent — события для экрана, а именно навигация назад.
Экран SavedDogsScreen работает следующим образом:
определяет текущий навигатор (из voyager библиотеки);
подписывается на Flow с SavedDogsScreenEvent;
отрисовывает compose-контент посредством вызова метода SavedDogsScreenContent, в который передается текущий SavedDogsScreenState и принимаются колбеки для обработки SavedDogsScreenAction.
Модель SavedDogsScreenModel работает следующим образом:
переопределяет метод bootstrap, в котором выполняется подписка на сохраненных в БД собачек с помощью ObserveRandomDogUseCase, при получении данных происходит вызов метода push(SavedDogsScreenEffect.DogsUpdated);
переопределяет метод actor, который при обработки события SavedDogsScreenAction.ClickButtonBack вызывает метод push(SavedDogsScreenEvent.NavigateToBack);
переопределяет метод reducer, который при обработке SavedDogsScreenEffect.DogsUpdated обновляет текущее состояние.
Логирование
В реализацию MVI добавлена возможность получения логов для отслеживания процесса работы приложения. Вывод лога формируется следующим образом:
Логирование происходи на всех этапах: обновление состояния, получение MviEvent, получение MviAction, получение MviEffect.
Данный функционал позволяет полностью отслеживать процесс работы того или иного экрана, а также возможноcть расширить функционал логирования и добавить запись логов в файл или БД, с последующей отправкой на удаленный сервер.
Итог
Описанный выше подход к архитектурному решению для кроссплатформенного приложения является, на мой взгляд, наиболее удобным и практичным с точки зрения расширяемости. Также хочется отметить что реализацию MVI возможно с легкостью менять используя другие библиотеки для навигации и инверсии зависимостей.
В дальнейшем планирую покрыть данное приложение тестами, а именно хотелось бы протестировать работоспособность MVI реализации при большом объеме данных.
Комментарии (2)
Takeite4zy
24.06.2024 13:59Альтернативная архитектура для более простых проектов https://github.com/vladimirlogachov/MoviesPot
Lucker216
Хороший выбор библиотек для проекта, единственное имхо, но судя по количеству багов с которыми сам столкнулся при разработки, да и странной передаче аргументов навигации, возможно лучше было бы использовать тот же Decompose от Аркадия и как раз сразу можно было бы взять mvikotlin для готовой реализации.
Но также небольшие вопросы возникли к composeResource. Есть, хоть и с нюансами, но несколько прогрессивнее для kmp библиотека moko-resource, которая генерирует нативные ресурсы для платформ, в отличии от ComposeResource, что позволяет в том же андроиде нормально разделить ресурсы по сплитам в итоговом бандле для сгрузки в гугл плей