Это цикл статей, посвященных построению и архитектуре KMP SDK. Содержание для удобства навигации:
Построение KMP SDK: наш опыт, плюсы и минусы, и как это изменило разработку
Построение KMP SDK: базовая архитектура для общей библиотеки
Построение KMP SDK: проектирование архитектуры для feature-модулей
Построение KMP SDK: единая дизайн-система и управление ресурсами
Построение KMP SDK: инсайты и подводные камни из нашего опыта
В прошлой статье было много текста о том, почему мы стали смотреть в сторону кроссплатформенной разработки и почему мы в Instories выбрали именно Kotlin Multiplatform (далее — KMP) для своего решения.
Кратко напомним про контекст и продукты: Instories — мобильный видеоредактор для маркетологов, SMM-специалистов и блогеров. Контекст проекта: желание получить ряд SDK (мы называем их Kit-ами, по сути это разные сборки SDK для разных продуктов, со своими ресурсами, фичами и дизайн системой) для наших уже существующих приложений, которые содержали бы в себе коробочные фичи (и бизнес-логику, и UI), готовые к подключению, а также были бы легко расширяемыми и переиспользуемыми для разных приложений компании.
Мы выбрали использовать многомодульную архитектуру, чтобы каждая фича была инкапсулирована в своем модуле и не влияла на другие фичи, а также разделили проект на несколько крупных пакетов: kits, core и feature. В данной статье мы рассмотрим первые два пакета с технической точки зрения, а также поделимся причинами и инсайтами для выбранных решений.
Core-модули
Сразу было очевидно, что для нашей библиотеки нужно будет выделить какую-то базу, которая будет единой для всех Kit-ов и всех фичей. Обычно это связано с работой с сетью, с базовыми классами для работы с DI и базами данных, какими-то утилитами и расширениями. Как итог, выделилось 5 модулей в этом пакете:
core-base
— модуль с базовыми классами для Kit‑ов, с классами expect/actual, с базовыми классами для работы с сетью, а также с расширениями и вспомогательными утилитами.core-contract
— модуль, где лежат базовые контракты для работы с нашим SDK.core-feature
— модуль с базовыми классами, необходимыми для правильной архитектуры фичей.core-design
— модуль с дизайн системой и ресурсами.core-di
— модуль со вспомогательными классами для работы с DI внутри фичей.
core-base
Это модуль, не зависящий ни от какого‑либо другого модуля. При этом там нет никакой строгой архитектуры, это просто набор вспомогательных пакетов, содержащих:
Модели для работы с AB-тестами.
-
Контракт для логгера
ILogger
и вспомогательные классы для логгера (например,LogLevel
). Реализация логгера может отличаться на разных платформах и у разных продуктов, поэтому оставили тут свободу выбора.@OptIn(ExperimentalObjCName::class) @ObjCName("ILogger", exact = true) interface ILogger { fun log(tag: String, message: String, lvl: LogLevel) }
Различные универсальные вспомогательные методы для работы с БД Room.
Модели для работы с файлами, которые может выбрать пользователь в приложениях (в нашем случае это только фото и видео), основная из них —
MediaFile
. У наших продуктов уже были реализованы MediaPicker‑окна со своим дизайном и своими алгоритмами, уже используемые на других нативных экранах, поэтому мы решили не усложнять и работать с MediaPicker‑ами через контракты. КлассMediaFile
нам нужен как раз для того, чтобы получить результат выбора пользователя в известном в SDK формате.Классы для работы с сетью. Мы используем известную библиотеку ktor, и она предоставляет довольно широкие возможности, однако иногда нам требовались расширения для существующего функционала. Так вот, они все в этом пакете.
Классы expect/actual. Вообще, механизм expect/actual в KMP — это мощная фича, которая позволяет создавать платформо‑специфичные реализации при сохранении общего API. Когда объявляется expect класс, интерфейс или функция в общем коде, вы по сути создаете контракт, который должен быть выполнен каждым платформо‑специфичным модулем. Имплементация actual при этом находится уже внутри платформы, и там можно обращаться к платформенным методам (например, Activity в Android или UIViewController в iOS). В нашем случае в этом пакете лежат методы работы с файловой системой, с base64, с датой и временем (хотя уже появилось готовое решение для этого). Этим методы в основном статические, поэтому всегда доступны в любой фиче и классе.
core-contract
Модуль core-contract
имеет зависимость только на core-base
и служит для реализации контрактного подхода для работы с SDK. Мы выделили несколько контрактов, которые должно реализовать любое приложение при добавлении нашего SDK:
-
NavigationContract
— это контракт, который по большей части имеет методы навигации куда-либо в нативные экраны или методы работы с UI.@OptIn(ExperimentalObjCName::class) @ObjCName("NavigationContract", exact = true) interface NavigationContract { //открыть медиапикер с параметрами fun showMediaPicker( title: String, mediaType: MediaType, minItemsCount: Int, maxItemsCount: Int, onSelect: (List<MediaFile>) -> Unit, onCancel: () -> Unit ) //показать тост с текстом и состоянием ошибки fun showToast( text: String, error: Boolean ) //открыть внутренний редактор для медиа-файлов в приложении fun showEditor( fileUrls: List<String>, ) //сохранить медиа-файлы на девайсе fun saveToFiles( fileUrls: List<String>, ) //показать нативное контекстное меню fun showContextMenu( model: ContextMenuModel ) ... }
-
PlatformContract
— технический контракт, в котором мы получаем от платформы различные настройки и реализации.@OptIn(ExperimentalObjCName::class) @ObjCName("PlatformContract", exact = true) interface PlatformContract { //получить локализованную строку по ключу fun getString(key: String): String? //получить реализацию логгера fun getLogger(): ILogger? //получить конфигурацию окружения fun getEnvironment(): Environment //отправить событие в аналитику fun logAnalyticsEvent(section: String, event: String, params: Map<String, Any>) //повибрировать fun vibrate() ... }
-
VideoPlayerContract
— контракт для работы с видео плеерами. В Android это ExoPlayer, в iOS — AVPlayer. Он содержит самые базовые методы для работы с видео, так как сильных усложнений нам не потребовалось.@OptIn(ExperimentalObjCName::class) @ObjCName("VideoPlayerContract", exact = true) interface VideoPlayerContract { //установить ссылку на видео-файл fun setFilePath(filePath: String) //запустить воспроизведение видео fun play() //остановить воспроизведение видео fun pause() //промотать до милисекунды fun seekTo(positionMs: Long) //задать режим повторения fun setRepeatMode(repeatMode: VideoPlayerRepeatMode) }
Эти контракты довольно статичны и почти не расширяются со временем. Платформе достаточно универсально реализовать их один раз и прокидывать в методе инициализации SDK (речь об этом пойдет ниже).
core-design
Модуль core-design
хранит в себе независимые классы для работы с дизайн системой, а также готовые универсальные compose-компоненты. Также в этом модуле хранятся все векторные иконки, которые используются в SDK. Да, при сборке конкретного Kit-a, какие-то иконки могут остаться неиспользуемыми, но они неплохо отрезаются компилятором.
core-di
Модуль core-di
зависит только от core-contract
и core-base
и представляет собой набор классов для корректной работы с DI. После нескольких попыток, о которых речь пойдет в другой статье про сравнение имеющихся кроссплатформенных DI-библиотек, мы остановились на библиотеке koin. Для работы DI мы заготовили в этом модуле несколько базовых классов для скоупов (описываются в порядке зависимости друг от друга):
-
KitScopeComponent
— скоуп всего кита. Объекты в нем должны оставаться в памяти с момента старта приложения и не умирать, пока приложение не будет убито. Это обычно логгер, сетевой клиент и тд.abstract class KitScopeComponent : KoinScopeComponent() { private val baseKitScopeName: String = "KitScope" val koinApp: KoinApplication = KoinApplication.init() override val scope: Scope get() = getKoin().getOrCreateScope(baseKitScopeName, named(baseKitScopeName)) override fun getKoin(): Koin { return koinApp.koin } }
-
FeatureScopeComponent
— скоуп для конкретной фичи. Он один на фичу, существует пока жива фича. У нас существует 2 типа фичей: те, которые должны создаваться на старте приложения и жить, пока живо приложение, и те, которые могут быть созданы по требованию и релизнуты после того, как закончат свою работу. Для универсальной работы и с первым, и со вторым типом, мы используем этот скоуп. Это обычно различные классы для фоновой работы фичи, repositories и тд.const val FeatureScopeName = "FeatureScope" abstract class FeatureScopeComponent( private val component: KitScopeComponent, private val featureName: String, ) : KoinScopeComponent() { override val scope: Scope get() { val id = FeatureScopeName + "_" + featureName return getKoin().getOrCreateScope(id, named(id)) } override fun getKoin(): Koin { return component.scope.getKoin() } }
-
FeatureFlowScopeComponent
— скоуп для конкретного флоу конкретной фичи. Не все фичи при старте показывают какие‑то свои экраны (например, если фича имеет фоновый поллинг, то она должна быть инициализирована при старте приложения, однако ее экраны юзер может и не увидеть, если не нажмет на нужную кнопку). Флоу мы называем цепочку экранов со своей навигацией внутри фичи. Этот скоуп должен создаваться всякий раз, когда пользователь заходит на любой из экранов фичи и очищаться, когда его работа с UI-частью фичи завершена. Это обычно классы с бизнес-логикой, такие как useCases, scenarios и тд.const val FeatureFlowScopeName = "FeatureFlowScope" abstract class FeatureFlowScopeComponent( private val component: FeatureScopeComponent, internal val featureName: String, ) : KoinScopeComponent() { override val scope: Scope get() { val id = FeatureFlowScopeName + "_" + featureName return getKoin().getOrCreateScope(id, named(id)) } override fun getKoin(): Koin { return component.scope.getKoin() } }
-
FeatureScreenScopeComponent
— скоуп для конкретного экрана внутри флоу фичи. Он связан с жизненным циклом экрана, создается при заходе на него и очищается при закрытии экрана (в терминах Android — это onCreate и onDestroy). С этим скоупом связана ViewModel для экрана.const val FeatureScreenScopeName = "FeatureScreenScope" abstract class FeatureScreenScopeComponent( private val component: FeatureFlowScopeComponent, private val screenName: String, ) : KoinScopeComponent() { override val scope: Scope get() { val id = FeatureScreenScopeName + "_" + component.featureName + "_" + screenName return getKoin().getOrCreateScope(id, named(id)) } override fun getKoin(): Koin { return component.scope.getKoin() } init { loadViewModelsModule() } abstract fun initViewModelsModule(): Module private fun loadViewModelsModule() { component.scope.getKoin().loadModules(listOf(initViewModelsModule())) } }
Также в этом модуле лежит базовый класс для управления DI объектом в фиче. При его инициализации, мы получаем скоуп всего Kit-а на вход и создаем необходимые скоупы для фичи: FeatureScopeComponent и FeatureFlowScopeComponent. DI-объект создается в конструкторе фичи (будет показано ниже).
@OptIn(ExperimentalObjCName::class)
@ObjCName("BaseDi", exact = true)
abstract class BaseDi<T> : KoinComponent {
protected lateinit var kitScopeComponent: KitScopeComponent
lateinit var featureScopeComponent: FeatureScopeComponent
lateinit var featureFlowScopeComponent: FeatureFlowScopeComponent
fun init(
kitScopeComponent: KitScopeComponent,
featureConfig: T,
platform: FeaturesKitPlatformContract
) {
this.baseKitScopeComponent = baseKitScopeComponent
initFeatureComponents()
featureScopeComponent.getKoin()
.loadModules(listOf(initFeatureScopeModule(featureConfig, platform)))
}
abstract fun initFeatureComponents()
abstract fun initFeatureScopeModule(
featureConfig: T,
platform: FeaturesKitPlatformContract
): Module
inline fun <reified R> bindNavigation(
navController: NavHostController,
router: (NavHostController) -> R
) {
featureFlowScopeComponent.bind(navController)
featureFlowScopeComponent.bind(router.invoke(navController))
}
override fun getKoin(): Koin = kitScopeComponent.getKoin()
open fun release() {
featureScopeComponent.closeScope()
}
fun releaseFeatureFlowScope() {
featureFlowScopeComponent.closeScope()
}
}
core-feature
Модуль core-feature
содержит базовые классы для фича-модулей, позволяющие быстро строить новые модули по определенной архитектуре, а также там хранится часть дизайн-системы, связанная с работой с темами (мы поддерживаем светлую и темную тему), цветовыми палитрами (у каждого Kit-а своя), строками. Да, этот модуль по смыслу частично перекликается с core-design
, однако мы хотели оставить core-design
максимально независимым и универсальным, тогда как этот модуль имеет автогенерируемый код и меняет свое содержимое в зависимости от Kit-а, под который собирают SDK. Подробнее про эти изменения и наш подход при работе с ресурсами будет описано в отдельной статье, а здесь мы разберем базовые классы:
-
BaseKitThemeSettings
— это интерфейс для работы с цветовой палитрой Kit-a. Мы договорились с дизайнерами об ограничениях в цветах (т.е. во всех продуктах мы должны иметь одинаковые названия цветов в дизайн-системе, например,BackgroundMain
). Этот интерфейс имплементируется внутри каждого из Kit-ов, что позволяет нам менять цвета в UI фичей в зависимости от приложения.interface BaseKitThemeSettings { val lightThemeColors: Colors val darkThemeColors: Colors }
-
BaseKitRes
— это интерфейс для работы с ресурсами из разных Kit-ов, адаптированный под ресурсы Compose Multiplatform. Все строки в xml-формате (как и у Android) лежат в папкеcommonMain/composeResources/values
внутри конкретных Kit-ов (так как у разных приложений могут быть разные строки), поэтому доступ к ним есть только там, и для фичей строки становятся недоступными. Подробнее про то, как мы работаем со строками и ассетами в фичах, будет описано в одной из следующих статей, а здесь ниже приведет контракт для каждого из Kit-ов:interface BaseKitRes { fun getStringRes(key: String): StringResource fun getDrawableRes(key: String): DrawableResource fun getUri(path: String): String suspend fun readBytes(path: String): ByteArray }
-
Базовый класс
BaseKit
— класс для всех Kit-ов в нашем SDK. На вход ему передаются реализации контрактовPlatformContract
иNavigationContract
, а также в методе инициализации происходит инициализация цветовой темы и ресурсов конкретного Kit-а черезBaseKitThemeSettings
иBaseKitRes
.abstract class BaseKit { companion object { var Platform: PlatformContract? = null get() = field ?: throw Exception("Call initialize to init Kit first!") private set var Navigator: NavigationContract? = null get() = field ?: throw Exception("Call initialize to init Kit first!") private set var ThemeSettings: BaseKitThemeSettings? = null get() = field ?: throw Exception("Call initialize to init Kit first!") private set var Res: BaseKitRes? = null get() = field ?: throw Exception("Call initialize to init Kit first!") private set var KoinApplication: KoinApplication? = null get() = field ?: throw Exception("Call initialize to init Kit first!") private set } fun initialize( platformContract: FeaturesKitPlatformContract, navigationContract: FeaturesKitNavigationContract, ) { BaseKit.Platform = platformContract BaseKit.Navigator = navigationContract BaseKit.ThemeSettings = getThemeSettings() BaseKit.Res = getRes() BaseKit.KoinApplication = KitScopeComponent.koinApp } abstract fun getThemeSettings(): BaseKitThemeSettings abstract fun getRes(): BaseKitRes
-
Базовый класс BaseFeature — класс для всех фичей в нашем SDK. На вход ему передается:
Feature
— enum с типом фичи.FeatureInput
— generic контракт фичи, который должен быть реализован на стороне платформы (может быть пустым).BaseDi<FeatureInput>
— реализация DI‑объекта фичи для управления скоупами и грамотной работы с памятью.FeatureUiTheme
— какую UI‑тему нужно использовать в приложении (для нас сейчас это светлая или темная). Цвета в теме зависят от собираемого Kit-а.
@OptIn(ExperimentalObjCName::class) @ObjCName("BaseFeature", exact = true) abstract class BaseFeature<FeatureInput>( val feature: Feature, protected val featureContract: FeatureInput, protected val di: BaseDi<FeatureInput>, private val featureTheme: FeatureUiTheme = FeatureUiTheme.Dark, ) { val koinApp: KoinApplication = BaseKit.KoinApplication!! init { AppTheme.themes[feature] = featureTheme di.init(KitScopeComponent, featureContract, BaseKit.Platform!!) Logger.log("Init feature: ${feature}") } open fun release() { Logger.log("Release feature") Logger.loggerImplementation = null di.release() } }
Здесь мы можем безопасно обращаться к статическим полям из BaseKit, так как в противном случае сразу увидим краш о том, что не проинициализировали SDK.
Kits-модули
Пакет с возможными модулями Kit-ов и семплами к ним вынесен у нас отдельно от других модулей, так как идеологически это абсолютно разные вещи. Типичный модуль Kit-а зависит от всех core-модулей и всех фича-модулей, которые он должен содержать, имеет свою имплементацию цветовой палитры для светлой и темной темы, а также свои строки и ассеты. Мы не рассматривали вариант использования нескольких Kit-ов в одном приложении, потому что это абсолютно бессмысленная затея: если нужна фича из другого Kit-а, можно просто ее подключить к текущему (опять же, спасибо многомодульной архитектуре с минимум зависимостей).
Сейчас у нас есть три Kit-а: два под два разных продукта компании и третий — тестовый, в котором есть две тестовые фичи с примерами взаимодействия. По сути тестовый Kit — это наш плацдарм для тестирования архитектурных решений и всевозможных технических обновлений. Плюс в нем всегда можно подсмотреть грамотные способы решения той или иной задачи (стараемся по возможности пополнять этот Kit нетривиальными кейсами, которые нам встречаются).
Выглядит это примерно так:
-
kits
— пакет с всеми возможными вариантами SDK и простыми семплами к ним, чтобы можно было легко и быстро проверять разрабатываемый функционал. Семплы обычно — простые одностраничные приложения с кучей кнопок для входа в ту или иную фичу.-
app1
— пакет для SDK под приложение 1android-sample
— нативный семпл под Androidkit
— модуль для SDK под приложение 1. Зависит от всех core‑модулей и всех небходимых нам feature‑модулей
-
app2
— пакет для SDK под приложение 2android-sample
— нативный семпл под Androidkit
— модуль для SDK под приложение 2. Зависит от всех core‑модулей и всех небходимых нам feature‑модулей
-
ios-sample-app1
— нативный семпл под iOS для SDK под приложение 1ios-sample-app2
— нативный семпл под iOS для SDK под приложение 2
По сути, любой модуль Kit‑а состоит всего из трех классов: класса наследника BaseKit
и двух классов под цвета для светлой и темной темы.
Технические инсайты
Снова считаем достаточно важным поделиться различными техническими инсайтами, которые были обнаружены по теме.
Про Web
KMP и Compose Multiplatform имеют поддержку Web-а. Да, что-то все еще в alpha или beta релизах, но тем не менее, стратегия ясна. У продукта Instories также есть продукт для Web-а, поэтому стало интересно немного посмотреть в эту сторону. Конечно, мы не закладывали все это в проект изначально, так как не было таких задач, но ресерч провели, и он позволил сделать нам несколько выводов на будущее, например, стараться по умолчанию использовать зависимости, которое поддерживают Web. Однако, когда это не представляется возможным (например, koin для DI), заранее продумывать абстрации для работы с такими зависимостями, инкапсулированные в модули. Таким образом, вместо подключения зависимостей напрямую, можно подключать нашу обертку с разной реализацией под капотом и быть более гибкими.
Про абстрактные классы в качестве контрактов
В процессе развития и использования SDK, мы поняли, что не всегда для разных Kit-ов будут одинаковые контракты. В первый раз, когда мы столкнулись с тем, что нам нужны новые методы для определенного Kit-а, мы попытались изначально решить вопрос с помощью дополнительного интерфейса, в котором не нужные нам методы из исходного контрактного интерфейса имели бы предустановленные пустые реализации (благо, Kotlin такое позволяет). Однако, при компиляции под iOS выяснилось, что методы интерфейсов с реализацией полностью игнорируются Xcode и он начинает требовать реализацию.
Тогда появилась идея попробовать решить вопрос, сделав новый контракт абстрактным классом, добавив нужные новые абстракные методы и реализовав старые методы из исходного контрактного интерфейса. Действительно сработало, Xcode больше не ругался на нереализованные методы, но теперь получилось даже хуже: он не стал ругаться даже на отстуствие имплементаций абстрактных методов. При этом после запуска происходила классика: краш в рантайме из-за отсутствия методов. Так что такой метод в итоге тоже не рекомендуем.
В итоге выводы были сделаны: лучше делать полностью отдельные контракты для Kit-а и делать все контракты через интерфейсы.
Спасибо за прочтение статьи! Дальше будет еще интереснее: расскажем про архитектуру конкретной фичи, про организацию DI и роутинга, а также дадим еще больше инсайтов о своем опыте и набитых шишках. Ну и, конечно, скачивайте Instories приложение и пробуйте найти фичи, которые сделаны на KMP! ?
Следующая статья: Построение KMP SDK: проектирование архитектуры для feature-модулей
Предыдущая статья: Построение KMP SDK: наш опыт, плюсы и минусы, и как это изменило разработку