Это цикл статей, посвященных построению и архитектуре KMP SDK. Содержание для удобства навигации:

  1. Построение KMP SDK: наш опыт, плюсы и минусы, и как это изменило разработку

  2. Построение KMP SDK: базовая архитектура для общей библиотеки

  3. Построение KMP SDK: проектирование архитектуры для feature-модулей

  4. Построение KMP SDK: единая дизайн-система и управление ресурсами

  5. Построение KMP SDK: инсайты и подводные камни из нашего опыта

В предыдущих статьях (раз и два) мы рассмотрели, почему выбрали Kotlin Multiplatform для нашего SDK, а также подробно разобрали базовую архитектуру проекта, включая core-модули и организацию Kit-ов. Теперь настало время погрузиться в самое интересное - архитектуру отдельной фичи и то, как мы организовали взаимодействие между модулями.

Эта статья будет особенно полезна тем, кто хочет понять, как на практике реализовать feature-first подход в KMP проекте, как правильно организовать DI, навигацию и работу с ресурсами в многомодульной архитектуре.

Кратко напомним контекст и продукты: Instories — мобильный видеоредактор для маркетологов, SMM-специалистов и блогеров. Контекст проекта: желание получить ряд SDK (мы называем их Kit-ами, по сути это разные сборки SDK для разных продуктов, со своими ресурсами, фичами и дизайн системой) для наших уже существующих приложений, которые содержали бы в себе коробочные фичи (и бизнес-логику, и UI), готовые к подключению, а также были бы легко расширяемыми и переиспользуемыми для разных приложений компании.

Feature-first подход к архитектуре

При проектировании архитектуры фичей мы руководствовались несколькими ключевыми принципами, которые были выработаны на основе нашего опыта разработки Android-приложений и адаптированы под специфику KMP:

  1. Изоляция фичи Каждая фича в нашем SDK представляет собой полностью изолированный модуль с собственной зоной ответственности. Это означает, что фича имеет свой собственный DI-граф, свою навигацию и работает как автономный мини-модуль внутри большого приложения. Такая изоляция позволяет нам избежать неявных зависимостей между фичами и упростить поддержку кода в долгосрочной перспективе.

  2. Переиспользуемость Мы спроектировали архитектуру так, чтобы каждая фича могла легко подключаться к любому Kit-у без необходимости модификации кода. Это достигается за счет четкого разделения на API и Implementation модули. API модуль содержит только контракты и модели данных, в то время как вся реализация скрыта в Implementation модуле. Такой подход позволяет нам иметь разные реализации одной фичи для разных продуктов. При этом они используют один и тот же API, но могут иметь различную реализацию в зависимости от требований конкретного продукта.

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

  4. Тестируемость Архитектура фичи спроектирована с учетом необходимости написания тестов. Благодаря четкому разделению на слои и использованию DI, мы можем легко мокать зависимости и тестировать каждый компонент в изоляции. Это особенно важно для тестирования бизнес-логики в use case-ах и работы с данными в репозиториях.

  5. Платформенная независимость Одно из ключевых требований при работе с KMP - это правильное разделение кода на платформенно-независимый и платформенно-специфичный. Мы придерживаемся принципа "максимум в commonMain, минимум в платформенных модулях". Вся бизнес-логика, работа с данными и базовая структура UI находятся в общем коде. В платформенные модули выносится только то, что действительно не может быть реализовано платформенно-независимым способом. Работа с платформенно-специфичным функционалом реализована через expect/actual классы, в то время как вся логика обработки данных находится в общем коде. Это позволяет нам максимально переиспользовать код между платформами и минимизировать дублирование.

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

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

Для реализации принципов выше мы последовали принципам CLEAN архитектуры и разделений по слоям, что позволило построить следующую структуру фича-модуля:

feature/
├── feature1/
│   ├── api/
│   │   ├── src/commonMain/kotlin/
│   │   │   ├── contract/
│   │   │   │   └── Feature1Contract.kt  #Интерфейс контракта фичи
│   │   │   ├── domain/
│   │   │   │   ├── usecase/  #Интерфейсы внешних юзкейсов, если есть           
│   │   │   │   ├── scenario/ #Интерфейсы внешних сценариев, если есть
│   │   │   │   └── model/    #Внешние модели, если есть
│   │   │   └── entrypoints/
│   │   │   │   ├── Feature1Screen1EntryPoint.kt #Модель для точки входа Screen1 в фичу
│   │   │       └── Feature1Screen2EntryPoint.kt #Модель для точки входа Screen2 в фичу
│   │   └── build.gradle.kts
│   ├── impl/
│   │   ├── src/commonMain/kotlin/
│   │   │   ├── data/
│   │   │   │   ├── repository/
│   │   │   │   ├── database/
│   │   │   │   │   ├── model/
│   │   │   │   │   └── datasource/
│   │   │   │   └── network/
│   │   │   │   │   ├── model/
│   │   │   │   │   └── datasource/
│   │   │   ├── domain/
│   │   │   │   ├── usecase/
│   │   │   │   ├── scenario/
│   │   │   │   └── model/
│   │   │   ├── di/
│   │   │   │   └── Feature1Di.kt
│   │   │   ├── presentation/
│   │   │   │   ├── screen1/
│   │   │   │   │   ├── Screen1ViewModel.kt
│   │   │   │   │   ├── Screen1Content.kt
│   │   │   │   │   ├── Screen1Holder.kt
│   │   │   │   │   └── Screen1State.kt
│   │   │   │   └── screen2/
│   │   │   │   │   ├── Screen2ViewModel.kt
│   │   │   │   │   ├── Screen2Content.kt
│   │   │   │   │   ├── Screen2Holder.kt
│   │   │   │   │   └── Screen2State.kt
│   │   │   ├── router/
│   │   │   │   └── Feature1Router.kt
│   │   │   ├── analytics/
│   │   │   │   └── Feature1Analytics.kt
│   │   │   ├── Feature1Flow.kt
│   │   │   └── Feature1Feature.kt
│   │   └── build.gradle.kts
│   └── build.gradle.kts

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

Api модуль

Api модуль — это публичное лицо фичи. Он содержит только те интерфейсы и модели данных, которые могут быть использованы другими модулями. Это единственная часть фичи, которая может быть импортирована извне. Также в этом модуле содержатся классы для точек входа в фичу. У каждой фичи их может не быть вовсе (когда фича используется, например, только для поллинга данных), а может быть одна или несколько точек входа (когда для разных флоу нужно показывать разные экраны, однако логически они оба принадлежать одной фиче).

Если мы хотим использовать какие-то данные одной фичи в другой, то для этого используется зависимость на модуль api фичи.

Impl модуль

Impl модуль содержит всю реализацию фичи, следуя принципам Clean Architecture. Мы используем трехслойную архитектуру:

  1. Data layer — этот слой отвечает за получение, сохранение и маппинг данных. Также в этом слое могут быть классы и методы по работы с файловой системой. Он включает в себя такие сущности, как:

    1. Network — пакет с моделями, классами и методами для работы с сетью. Предоставляет свой DataSource для данных, полученных по сети.

    2. Database — пакет с моделями, классами и методами для работы с базой данных. Предоставляет свой DataSource для данных, полученных от БД приложения.

    3. Repository — основной класс для работы с данными, который собирает и обрабатывает данные от всех DataSource‑ов.

  2. Domain layer — этот слой содержит модели, с которыми работает SDK, а также бизнес‑логику приложения в виде usecases и scenarios.

    1. UseCase — это класс с бизнес‑логикой, который идеалогически отвечает за одно конкретное действие. Обычно содержит один метод. Не стоит делать юзкейсы зависимыми друг от друга, для этого есть scenario.

    2. Scenario — это класс с бизнес‑логикой, который собирает бизнес‑логику из юзкейсов. У сценария, в отличии от юзкейсов, может быть несколько методов. Не стоит делать сценарии зависимыми друг от друга, если так получается, то надо перепродумать архитектуру доменного слоя.

  3. Presentation layer — это слой, который содержит ViewModels и Compose UI. В основе архитектуры каждой фичи лежит паттерн MVI (Model‑View‑Intent), который реализован через следующие ключевые сущности:

    1. State — это обычно data‑класс, который является неизменяемым (immutable) классом, описывающим все состояние экрана. Он содержит все необходимые поля для отображения UI. Каждый экран определяет свой собственный класс состояния (например, ChatScreenState, PreviewScreenState и так далее)

    2. Intent — это sealed интерфейс, описывающий все возможные действия пользователя и системы. Включает в себя как UI‑события (клики пользователя), так и системные события (открытие экрана, обновить данные и тд). ViewModel обрабатывает каждый intent и меняет state необходимым образом.

    3. SingleEvent — ****это sealed интерфейс для событий, которые не меняют state, например, показ системного уведомления. Такие события обрабатываются с помощью StateFlow.

    4. Holder — это компонент, который служит мостом между DI, VM и UI‑слоем фичи. Holder отвечает за инициализацию ViewModel через DI, подписку на состояние через collectAsStateLifecycleAware, обработку аппаратной навигации «назад» для андроида.

    5. ViewModel — это классический объект для управления экраном. Управляет потоками данных через Kotlin Flow: MutableStateFlow для состояний State, MutableSharedFlow для Intent‑ов, ChannelFlow для SingleEvent. У нас для всех VM есть свой абстрактный класс, который содержит три ключевых метода:

      1. fun initialState(): State — метод, в котором определяется исходное состояние экрана при открытии.

      2. fun reduce(intent: Intent, prevState: State): State — метод, в котором обрабатываются интенты, влияющие на состояние экрана. Возвращает обновленное состояние экрана.

      3. suspend fun performSideEffects(intent: Intent, , state: State) — метод, в котором обрабатываются интенты, влияющие на бизнес логику.

    6. Screen — это файл с Composable функциями, которые отрисовывают UI экрана по текущему состоянию State.

Также в impl‑модуле находятся классы, для работы с di, аналитикой, роутингом внутри фичи и сам класс Feature, с которым работает платформа при подключении фичи. Давайте рассмотрим и эти разделы подробнее.

DI

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

На самом верхнем уровне находится KitScope — корневой скоуп всего SDK, который создается при инициализации Kit‑а и живет на протяжении всего времени его работы. Этот скоуп отвечает за предоставление глобальных сервисов и платформенных зависимостей, которые могут потребоваться в любой части приложения. Здесь хранятся такие важные компоненты как конфигурация, сетевые клиенты и другие общие сервисы, которые должны быть доступны во всех фичах.

Следующий уровень — FeatureScope, который создается для каждой фичи отдельно. Этот скоуп содержит все зависимости, специфичные для конкретной фичи: репозитории, use case‑ы и другие компоненты бизнес‑логики. Важно отметить, что FeatureScope имеет доступ к зависимостям из KitScope, но при этом изолирует свои собственные зависимости от других фич.

Для управления навигацией и состоянием внутри фичи мы используем FeatureFlowScope. Этот промежуточный уровень отвечает за сохранение состояния между различными экранами одного flow‑а и содержит компоненты, связанные с навигацией. Например, если пользователь проходит через несколько последовательных экранов в рамках одного процесса (как при онбординге или создании контента), FeatureFlowScope позволяет сохранять и передавать необходимые данные между этими экранами, при этом гарантируя их очистку после завершения flow‑а.

На самом нижнем уровне находится FeatureScreenScope — скоуп, создаваемый для каждого отдельного экрана в фиче. Здесь размещаются ViewModel-и и другие UI-компоненты, необходимые для работы конкретного экрана. FeatureScreenScope автоматически очищается при закрытии экрана, что предотвращает утечки памяти и обеспечивает правильное освобождение ресурсов.

Роутинг

В нашем SDK есть 2 вида роутинга. Первый — на стороне платформы, использующей библиотеку: платформа сама решает, какой экран фичи из доступных EntryPoint‑ов открыть. После открытия любого экрана фичи, управление переходит к фиче, у которой есть свой внутренний роутинг со своим графом навигации, реализованный через Compose Navigation. Таким образом, при открытии фичи, мы можем пользоваться классическими подходами к навигации, используя NavHostController. Жизненный цикл NavHostController соответствует FeatureFlowScope, так что когда юзер закрывает фичу и уходит на другие экраны, компоненты, связанные с этим скоупом, очищаются из памяти.

Feature

В основе архитектуры лежит концепция Feature — изолированного модуля, который представляет собой законченную функциональную единицу с собственным пользовательским интерфейсом, бизнес‑логикой и зависимостями. Каждая фича наследуется от базового класса для всех фичей и реализует свой уникальный контракт, который определяет её взаимодействие с внешним миром.

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

Каждая фича является полностью самодостаточной и может быть подключена к любому приложению, реализующему необходимый контракт. Это достигается благодаря строгой инкапсуляции — вся реализация фичи скрыта за API‑модулем, который содержит только контракты и модели данных. Такой подход позволяет нам легко переиспользовать фичи между разными приложениями без необходимости изменения кода приложения. При этом каждая фича имеет свой собственный DI‑граф, свою навигацию и может работать как полностью автономный мини‑модуль внутри большого приложения.

Пример типового контракта фичи представлен ниже.

@OptIn(ExperimentalObjCName::class)
@ObjCName("Feature1Contract", exact = true)
interface Feature1Contract {
    
    fun method1(): String
    fun method2(): Boolean
    ...
}

Также представим конструктор для абстрактного класса для всех фичей: при создании экземпляра фичи, приложение предоставляет реализацию контракта, а также Ui-тему для этой фичи.

@OptIn(ExperimentalObjCName::class)
@ObjCName("BaseFeature", exact = true)
abstract class BaseFeature<FeatureContract>(
    protected val featureContract: FeatureContract,
    private val featureTheme: FeatureUiTheme
)

Пример использования фичи:

val feature1 = Feature1(
   featureContract = Feature1ContractImpl(),
   featureTheme = FeatureUiThemeDark()
)

//платформа также может получать от фичи какие-то 
//данные или просить сделать каку-то работу
feature1.doSomeWork()

Технические инсайты

По традиции делимся различными техническими нюансами, которые мы для себя зафиксировали.

Навигация

Мы не сразу пришли к compose navigation, так как изначально при создании нашего SDK этой кроссплатформенной библиотеки еще не было (была только для Android). Поэтому первоначальный выбор пал на библиотеку Voyager. Это довольно мощная библиотека, которая имеет обширный функционал, однако к ней было много вопросов, особенно в связке с правильным жизненным циклом экранов и непривычными для android-разработки подходами. Так что несмотря на то, что библиотека неплохая, лучше используйте классический стек. В данный момент compose navigation в beta, но все необходимо работает хорошо, так что рекомендуем.

Второй нюанс был в том, что мы в рамках андроид приложения хотели использовать подход Single Activity с единым NavHostController, однако тот факт, что каждая фича имеет свой собственный, не очень туда ложилась. Пробовали инжектить родительский NavHostController в SDK, но в итоге также наткнулись на сложности работы с DI и жизненным циклом (наш подход с FlowScopeComponent переставал работать верно), поэтому оставили эту затею и сделали на каждую входную точку фичи свой RouteEntry в NavHostController приложения.

Про BottomSheets

Некоторые EntryPoint-ы по дизайну должны были иметь вид bottom sheets, что вызывало некоторые сложности. Во-первых, в коробке к compose navigation не идет возможность навигации bottomSheet (а аккомпанист подключить нельзя, так как эта библиотека не кроссплатформенная). Во-вторых, на iOS свои особенности: нельзя просто так взять и сделать экран из KMP прозрачным без танцев с бубном.

Первую проблему мы решили тем, что скопировали NavController с поддержкой bottomSheet из accompanist, все встроилось хорошо, так что не будем подробно описывать детали.

Со второй проблемой пришлось помучиться и привлечь ребят из iOS, и в итоге получился универсальный метод для создания прозрачного UiViewController (весь секрет в методе forceTransparentBackground() и во флаге opaque = false):

   fun getViewController(
        feature: Feature,
        content: @Composable () -> Unit
    ): UIViewController {
        return ComposeUIViewController(
            configure = {
                onFocusBehavior = OnFocusBehavior.DoNothing
                enforceStrictPlistSanityCheck = false
                opaque = false 
            },
            content = content
        ).apply {
            this.forceTransparentBackground()
        }
    }
    
    
    private fun UIViewController.forceTransparentBackground() {
        val view = this.view
        view.backgroundColor = UIColor.clearColor
        view.opaque = false

        view.subviews
            .filterIsInstance<UIView>()
            .firstOrNull()?.let {
                it.backgroundColor = UIColor.clearColor
                it.opaque = false
                it.layer.opaque = false
            }
    }

Вот и все про фичи! Надеемся, было много полезного и интересного. Ждем вас в следующих статьях про дизайн системы для разных Kit-ов, про работу с ресурсами, про CI/CD и про тестирование. А пока скачивайте Instories приложения и пробуйте самостоятельно найти фичи, которые сделаны на KMP! ? Ждем предположения в комментариях.

Следующая статья: Построение KMP SDK: единая дизайн-система и управление ресурсами

Предыдущая статья: Построение KMP SDK: базовая архитектура для общей библиотеки

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