В архитектуре KMP+CMP-приложений главный вопрос не в том, сколько кода можно пошарить. Он в том, где нужно остановиться.

Я покажу два рабочих подхода. Первый — архитектура для большого мобильного продукта: общий Kotlin-слой для бизнес-логики и нативный UI на каждой платформе. Второй — стартаповый путь, где Compose Multiplatform помогает быстро запустить мобильные продукты, web- и desktop-приложения. И даже backend на общих моделях данных.

Поговорим о том, когда нужно и не нужно применять «лучшую» архитектуру.


Это первая статья из двух, теоретическая. Во второй статье мы перейдём к практике, разберём реальные проблемы реализации архитектуры и увидим бонусы, которые мы от неё получаем.

Я — Павел Присталов, Staff Engineer с опытом в IT длинною в 20 лет. Проектировал и разрабатывал мобильные сервисы для небольших стартапов и транснациональных компаний.

TL;DR: репозиторий-шаблон описанной архитектуры: github.com/pristalovpavel/kmp-clean-template.

Введение

Согласен, заголовок статьи довольно кликбейтный. Но архитектура, о которой я расскажу сегодня, действительно лучшая из всех, что я встречал за 15 лет в мобильной разработке. Она даёт скелет большого приложения, на который просто и предсказуемо добавлять фичу за фичей.

Её основные преимущества:

  • Простая. В её основе — KISS и YAGNI. Так мы уменьшаем время онбординга новых сотрудников, время код-ревью и возможные ошибки и недопонимания внутри команды.

  • Мощная. Позволяет реализовать все нативные возможности каждой из платформ. Без мостов, рефлексии, сомнительных обходных путей и без компромиссов по производительности.

  • Экономная. Переиспользует максимум кода, но компилируется при этом под каждую платформу её средствами: JVM-байткод для Android, нативный бинарный файл для iOS, JS/Wasm для веба. Это кратно сокращает время разработки и тестирования и позволяет быстрее локализовать и исправлять проблемы.

  • Расширяемая. Мы можем довольно просто добавить поддержку совершенно новой для нас платформы.

  • Утилитарная. Решает базовые прикладные задачи любого приложения: унифицированную обработку ошибок, постраничную загрузку контента, офлайн и обновление кеша, DI, авторизацию и обновление токена, общую логику аналитики, фоновую загрузку данных, повтор сетевых запросов, логирование и удалённое управление фичами приложения.

Предлагаемая архитектура: обзор и принципы

Итак, что же выбрать в качестве основы нашей архитектуры? Конечно, Clean! Довольно простой, понятный и хорошо обкатанный подход к построению даже сложных приложений.

Привычный подход к Clean, без усложнений
Привычный подход к Clean, без усложнений

Я не буду описывать суть подхода Clean, его принципы и преимущества. В этой и этой классических статьях они разобраны очень подробно. Те же слои — data, domain, UI — рекомендует и официальный гайд Google по архитектуре. Мы же поговорим про практические аспекты применения архитектуры для KMP-проектов.

Shared-слой и ViewModel

Начнём с главного: shared-модуль KMP нужно ограничить слоями data и domain — и не выносить в него ViewModel, а тем более UI. Да-да, я советую не использовать CMP как основу кроссплатформенных приложений, которые вам нужно будет развивать и поддерживать. CMP — это отличное решение для Android. Его можно и нужно использовать для Web- и Desktop-частей, если вам нужно переиспользовать на этих платформах уже имеющийся UI. А вот iOS отличается довольно значительно, и в большом проекте попытка пошарить ViewModel и UI почти наверняка обернётся ворохом проблем.

Вот некоторые из них:

  • Jetpack ViewModel создавался под Android-лайфсайкл. На iOS приходится вручную заводить ViewModelStoreOwner, что для iOS-разработчика довольно инородно. В мире iOS ViewModel — это «снимок» состояния под вью, без управления лайфсайклом.

  • StateFlow при прохождении через Objective-C interop теряет дженерик-тип, и наблюдать его из SwiftUI напрямую нельзя. Нужны обёртки (CommonStateFlow) и адаптер (KotlinViewAdapter) плюс ручная привязка CoroutineScope к жизни вью. Получается, что «пошарить ViewModel» = написать слой обвязки, которого в нативном подходе просто нет.

  • Nested ViewModels в Jetpack-библиотеке не поддерживаются.

Вот минимум обвязки, без которого шаринг ViewModel с нативным SwiftUI не работает (код — из статьи Tristan Ferré). Сначала обёртка в общем коде, потому что через Objective-C StateFlow приезжает в Swift без дженерика:

class CommonStateFlow<T>(private val flow: StateFlow<T>) : StateFlow<T> by flow {
    fun startCollect(
        coroutineScope: CoroutineScope,
        onEach: (T) -> Unit,
    ): CommonCancellable {
        val job = coroutineScope.launch { flow.collect { onEach(it) } }
        return CommonCancellable { job.cancel() }
    }
}

Затем — generic-адаптер на стороне Swift, который живёт по лайфсайклу вью и транслирует состояние в @Published:

final class KotlinViewAdapter<State: AnyObject, VM>: ObservableObject {
    @Published private(set) var state: State
    let viewModel: VM
    private var subscription: CommonCancellable?
    private let scope = CoroutineScopeFactory().create()

    init(viewModel: VM, stateFlow: CommonStateFlow<State>) {
        self.viewModel = viewModel
        self.state = stateFlow.value as! State
        self.subscription = stateFlow.startCollect(coroutineScope: scope) { [weak self] newState in
            self?.state = newState as! State
        }
    }

    deinit {
        subscription?.cancel()   // руками гасим и подписку,
        scope.cancel()           // и котлиновский scope, иначе потечёт
    }
}

// и только после этого в SwiftUI:
// @StateObject var adapter = KotlinViewAdapter(viewModel: ..., stateFlow: ...)

И это лишь скелет: реальная обвязка обрастает фабриками скоупов, прокидыванием событий в обратную сторону и обработкой одноразовых side-эффектов.

В этой статье Touchlab дана правильная, на мой взгляд, рекомендация: для Compose Multiplatform — шарьте ViewModel; для нативного iOS UI или команды с сильной iOS-культурой — не шарьте, держите Presentation-слой платформо-специфичным.

Из этого правила, конечно, есть исключения. Например, концепция BDUI (backend-driven UI) отлично ложится на CMP, как и любая другая достаточно изолированная фича вашего проекта, не требующая максимально нативного UI и UX.

Почему ещё не шарим ViewModel

Я сам долго ждал возможности переиспользовать ViewModel между платформами. Такой подход выглядит вполне логичным: ViewModel содержит бизнес-логику Presentation-слоя. А если это бизнес-логика, её нужно написать один раз для всех платформ, по одному из наших же критериев хорошей архитектуры. Но дьявол в деталях.

Шаринг ViewModel — это, по сути, искусственное разделение Presentation-слоя на две части просто потому, что «так удобно». Кроме того, такое разделение требует унифицировать архитектуру на самом верхнем слое, что не всегда желательно. Да и сами платформы Android и iOS отличаются по возможностям и UX. И чтобы написать действительно сложное приложение с использованием максимума нативных фичей платформы, приходится постоянно бороться с обходом этих различий: оставлять пустые actual'ы, реализации интерфейсов, втягивать платформо-специфичную реализацию в общий код. Так что просто оставьте Presentation-слой нативным!

Меньше "обвязки" - меньше потенциальных проблем
Меньше "обвязки" - меньше потенциальных проблем

Навигация тоже остаётся нативной — NavigationStack на iOS, Compose Navigation на Android (или что угодно ещё). А согласованность между платформами обеспечивает не общий навигатор, а общий shared-слой: экраны везде ходят за одними и теми же UseCase и получают одинаковые состояния.

А что на iOS?

Выбранный нами подход, кстати, подспудно снимает главную проблему внедрения KMP-кода: сопротивление iOS-разработчиков. Они могут писать UI-слой как им угодно, даже не привязываясь к VM-архитектурам. Просто за данными они пойдут не в условный Alamofire, а в UseCase из shared-слоя. Заодно получая всю ту мощь по работе с данными, которую он даёт. По моему опыту, это сильно сокращает и время разработки, и время внедрения KMP.

Оптимизируем и на iOS
Оптимизируем и на iOS

Более того, раз в shared-слое нет ничего из Android-фреймворка, iOS-разработчики могут сами писать его код! Давайте посмотрим, насколько это сложно. Общий код у нас содержит модели данных (которые, очевидно, не так уж сильно отличаются между Kotlin и Swift), интерфейсы API, БД на SQL, *Repository, *RepositoryImpl, *UseCase. В простых случаях это довольно незамысловатые сущности, их легко написать самому или сгенерировать по готовому примеру. Да, бывают и сложные случаи, но и в них iOS-разработчику несложно разобраться и поддержать код: он завязан на общеязыковых принципах, без привязки к особенностям Android.

Поэтому на проекте бывает достаточно всего трёх документов по технической части для онбординга новых сотрудников: описание архитектуры, описание структуры файлов и гайд «Как создать новую фичу/экран». Проще — лучше.

Тестируемость

А ещё этот код отлично тестируется. Так как в нём нет ни Android-фреймворка, ни UIKit, ни привязки к лайфсайклу — только чистый Kotlin, вся бизнес-логика приложения покрывается обычными юнит-тестами в commonTest, которые бегают на JVM за секунды. Без эмулятора, без симулятора, без instrumented-тестов.

В тестировании тоже получаем экономию ресурсов + расширяемость
В тестировании тоже получаем экономию ресурсов + расширяемость

В нативной реализации самая ценная логика — работа с данными, обработка ошибок, кеш, авторизация — тестируется по-разному на каждой платформе. У нас она собрана в одном месте и покрывается один раз сразу для всех: написали тест на UseCase — он работает и для Android, и для iOS, и для web.

А то, что нужно проверить на UI-слое, проверяется родными (а значит, максимально оптимизированными) инструментами платформы: XCTest, Compose UI-тесты, shapshot-тестирование и т.д. Как это выглядит в коде, я покажу во второй, практической, части.

Бонусы, которые вы получаете

Раз данные и логика с первого дня живут в мультиплатформенном shared-модуле, добавление почти любого нового таргета стоит недорого. Вот три бонуса, которые мы получаем:

Используем возможности KMP по полной
Используем возможности KMP по полной

Desktop-версия. Бонусом к Android-приложению на Compose мы получаем его Desktop-версию почти даром. Да, придётся немного потрудиться: собрать её с нужными библиотеками и адаптировать разметку под большие экраны. Впрочем, современный Android и так не привязывается к ориентации и размеру экрана — дизайнеры с разработчиками уже думают про foldable-девайсы и планшеты.

Ещё немного времени уйдёт на инсталляторы под macOS, Windows и Linux. Но согласитесь: это несравнимо дешевле отдельной desktop-разработки — а ваше приложение при этом будет доступно на всех этих платформах.

Backend на общих моделях. Выносим DTO в отдельный лёгкий модуль и подключаем его в Ktor-сервер обычной зависимостью — это просто ещё один JVM-таргет. В репозитории проекта живёт демо-бэкенд, который отвечает ровно теми же типами, что парсит приложение. Контракт API используется один на всех и не может «разъехаться» молча: изменил модель — клиент и сервер пересобираются вместе, расхождение ловится на этапе компиляции, а не в проде.

Ещё один очень практичный кейс написания KMP-сервисов из моего опыта: mock-backend. Он проксирует все запросы к "настоящим" эндпоинтам вашего тестового окружения, но возвращает моковые модели данных для новых или изменённых эндпоинтов. Такой бэкенд разблокирует мобильную разработку и тестирование, пока настоящий сервер ещё пилится: команде нужен лишь согласованный контракт API.

Веб. Тут есть два пути, и они сильно разные по зрелости.

Первый — Kotlin/JS: shared-логика как «фреймворк» для веб-команды. Тот же shared-модуль компилируется в JS-библиотеку с TypeScript-декларациями: помечаем публичный API аннотацией @JsExport — и веб-команда подключает наши UseCase к своему React/Vue/Angular как обычный npm-пакет. Это ровно тот же паттерн, что и с iOS: нативный UI + общая бизнес-логика.

JetBrains позиционирует такой сценарий как основной для Kotlin/JS и предоставляет официальный пример — kmp-logic-sharing-simple-example (Android + iOS + React). Работает это и в проде: Quizlet с 2019 года использует общий движок проверки ответов в вебе и мобильных приложениях с суммарной сотней миллионов установок (кейс).

Но, честности ради, опыт разработчика здесь пока хуже, чем на границе с iOS. Экспортный интероп ещё не полностью стабилизирован: часть возможностей — за experimental-флагами, идиоматика теряется: List и Flow наружу без ручных фасадов не отдать, а экспорт suspend-функций и Long появился только недавно и пока за experimental-флагами. Сама библиотека получается в разы тяжелее рукописного JS (опыт Doist: ~41 КБ gzip против ~10 КБ).

Второй — Kotlin/Wasm + Compose Multiplatform: общее приложение целиком в браузере, Compose рендерится в canvas через WebAssembly. Для админок и внутренних инструментов это вполне рабочий вариант уже сегодня. Для публичного продукта пока мешают canvas-рендеринг (SEO, доступность) и вес первой загрузки.

Несмотря на все успехи Kotlin for Web, бесплатного подарка тут ждать не стоит. К сожалению, ещё нет «SKIE for Web», который бы снимал основные описанные проблемы. Но если у вас есть небольшая веб-команда со своим стеком, вы можете отдать ей shared-логику через Kotlin/JS. А если вам нужен быстрый внутренний инструмент - собрать его на CMP/Wasm, используя Ktor, SQLDelight, Koin и kotlinx-библиотеки под js и wasmJs.

В репозитории-шаблоне реализован первый путь: веб-приложение на Kotlin/JS поверх общих UseCase’ов плюс @JsExport-фасад с TypeScript-декларациями для внешней веб-команды.

А почему вообще не Flutter или React Native?

Для всех случаев, когда вы хотите в будущем сами активно развивать и поддерживать сложный проект, не используйте PWA, Flutter и React Native, только KMP+CMP! Потому что если ваш проект всё же разрастётся и вам нужно будет перейти к «серьёзной» архитектуре, вы без труда это сделаете.

Дописать в KMP-приложение новую фичу нативно почти не больно: создаём SwiftUI-экран, заводим под него кусок shared-логики — и всё, никаких мостов. Добавить нативную фичу во Flutter-приложение — это platform channels (MethodChannel/Pigeon), двойная реализация на Android и iOS и работа по встраиванию нативных вью (PlatformView) в поток Flutter.

По моему опыту, через полтора-два года многие из активно развивающихся Flutter/RN-проектов приходят с просьбой «перепишите нативно». И переписывать приходится почти с нуля, дорого и больно. KMP же растёт эволюционно и постепенным рефакторингом легаси. Никакой смены стека, никакой боли с производительностью и поддержкой двух стеков — только управляемый и прогнозируемый рефакторинг.

Или противоположная ситуация: у нас уже есть два нативных приложения, как пошарить логику? Ответ тот же, наш подход работает и отсюда: ничего не переписывайте. Возьмите одну новую фичу, вынесите её data+domain в shared-модуль, подключите его к вашим приложениям. И посмотрите на скорость разработки и количество багов. Если метрики вас устроят, добавляйте в shared фичу за фичей, и рефакторите легаси по необходимости. А если решите откатиться, в любой момент сможете это сделать.

Когда НЕ использовать эту архитектуру

Итак, у нас есть классная архитектура! Когда же не нужно её использовать?

Кейс 1: стартап без чётких требований

Если у вас нет подробного ТЗ по реализации проекта, очень маленькая команда и очень ограниченный бюджет, не тратьте ресурсы на тесты, документацию и бойлерплейт. Пишите код быстро — и для всех платформ сразу (на KMP+CMP)!

Кейс 2: нечего выносить в shared

Второй кейс — это приложение, в котором почти нечего выносить в shared-слой. Например, виджеты для Android и iOS с минимальным сетевым трафиком и небольшие утилиты. Также к этой категории следует отнести online-only приложения без какого-либо кеша. В таком случае весь data и domain-слои превращаются просто в ненужный бойлерплейт.

Кейс 3: приложение не будут развивать

Третий кейс — это приложения, которые почти не нужно будет развивать и поддерживать в дальнейшем. Например, мобильные приложения для небольшой сети ресторанов с понятным функционалом: забронировать столик, посмотреть меню, оформить карту лояльности и показать её при оплате счёта. В таком приложении может быть и offline-режим, и нетривиальная обработка данных, но если новых фичей в будущем не планируется, то нет смысла в переусложнённой архитектуре.

Тип проекта

Шарим логику?

Шарим UI?

Рекомендация

Большой продукт с долгой поддержкой

да, data + domain

нет

Clean-в-KMP: shared-слой + нативный UI («та самая» архитектура)

Стартап-гипотеза, маленькая команда,

да

да, целиком

KMP + CMP без слоёв: скорость важнее структуры

Изолированная фича / BDUI внутри большого продукта

да

да, точечно

CMP-экраны внутри нативного приложения

Виджеты, мелкие утилиты, online-only без кеша

почти нечего

нет

чистый натив на языке платформы

Приложение «сделал и забыл» (меню, лояльность)

да

зависит от выбора команды

KMP + CMP: дёшево сейчас + путь роста, если планы изменятся

Итоги и выводы

Если выжать всё в одну мысль, KMP — это не «написал один раз, запустил везде». Это про то, чтобы шарить ровно то, что имеет смысл шарить.

  • в большом продукте — Clean-в-KMP: общий data+domain, нативный UI.

  • в маленьком проекте или стартап-гипотезе — CMP целиком, чтобы взлететь быстро и сразу для всех платформ.

  • если у вас «только UI», без сложных данных, offline и сети — чистый натив на языке платформы.

И между этими подходами не пропасть, а лестница. Проект вырос — достроили слои и убрали CMP с iOS, не переписывая всё с нуля. Поэтому вопрос не в том, «использовать KMP или нет», а «где сегодня на этой лестнице ваш проект». Посмотрите на свой. И либо начинайте переезжать, либо осознанно оставайтесь там, где вы есть. Оба ответа правильные, если они честные.

Использовать KMP и CMP вы можете на любой стадии мобильного проекта
Использовать KMP и CMP вы можете на любой стадии мобильного проекта

Как устроено ядро архитектуры — контракты UseCase, SKIE, обработка ошибок, DI, модульность и тесты — поговорим во второй, практической части.

Репозиторий-шаблон: github.com/pristalovpavel/kmp-clean-template.

Ссылки и материалы

Архитектура и подход:

Clean Architecture, первоисточник (Robert «Uncle Bob» Martin): https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Clean Architecture для Android, каноничная статья (Fernando Cejas): https://fernandocejas.com/2014/09/03/architecting-android-the-clean-way/

Google, Guide to app architecture: https://developer.android.com/topic/architecture Почему презентацию держим нативной (не шарим ViewModel):

- Touchlab, «Is AndroidX ViewModel the best choice for KMP projects?»: https://touchlab.co/kmp-view-models - Tristan Ferré, «Using a Kotlin view-model in an iOS view»: https://medium.com/@tristanferre/using-a-kotlin-view-model-in-an-ios-view-643f60497f42 - KaMPKit, «Why is the ViewModel shared code here?»: https://github.com/touchlab/KaMPKit/discussions/170 - Google, «Set up ViewModel for KMP»: https://developer.android.com/kotlin/multiplatform/viewmodel Веб-таргет и шаринг логики с браузером:

- Kotlin для веба, JS vs Wasm: https://kotlinlang.org/docs/web-overview.html - JetBrains, «Present and Future of Kotlin for Web»: https://blog.jetbrains.com/kotlin/2025/05/present-and-future-kotlin-for-web/ - Официальный пример шаринга логики Android + iOS + React: https://github.com/Kotlin/kmp-logic-sharing-simple-example - Кейс Quizlet: https://quizlet.com/blog/shared-code-kotlin-multiplatform - Опыт Doist: https://www.doist.dev/filterist-kotlin-multiplatform-javascript-exploration/