
В первой части мы разобрали философию KMP, обсудили лестницу развития проектов и выяснили, почему не стоит шарить ViewModel. Теперь давайте заглянем в код и поговорим про то, с чем столкнутся разработчики при выполнении ежедневных задач.
Это вторая, практическая, часть из двух. Здесь про то, как организованы слои, какие контракты уходят в Swift, как готовить SKIE, как устроены обработка ошибок и DI, что с модульностью, скоростью сборки и тестами.
Я — Павел Присталов, Staff Engineer с почти 20-летним опытом разработки, из них 15 — в мобильной.
TL;DR: репозиторий-шаблон описанной архитектуры: github.com/pristalovpavel/kmp-clean-template
Слои: что где лежит
Data-слой
В data-слое у нас лежат API и БД для offline-first, а потоками данных управляет Repository — ровно так, как советует Google вот тут и вот тут. Переключение между источниками реализовано в двух вариантах. Для каких-то экранов хватает упрощённого one-shot-паттернаNetworkBoundResource (классический исходник в GithubBrowserSample): сначала сеть, а при ошибке - кеш с пометкой isOldData. А там, где кеш должен обновляться сам, — полноценный реактивный Single Source of Truth: UI подписан на Flow из БД, сеть только обновляет её. В шаблоне так устроен профиль.

Domain-слой
В domain-слое имеем интерфейсы репозиториев и UseCase'ы. Реализации репозиториев, *RepositoryImpl, принадлежат data-слою и подключаются через DI: domain при этом ничего не знает о Ktor и SQLDelight, он оперирует контрактами. Именно UseCase и RepositoryImpl мы обязательно покрываем unit-тестами и подробно документируем. К тестам и документации мы вернёмся немного позже.
В Clean Architecture UseCase передаётся в Presentation-слой, поэтому в нашем подходе он находится на стыке миров Kotlin и Swift. И этот интерфейс критично важно продумать, чтобы избежать проблем.
Какие данные передавать
К сожалению, полный интероп между Kotlin и Swift если и возможен, то не сегодня: текущие проблемы и ограничения хорошо систематизированы в Kotlin-Swift Interopedia. JetBrains делает очень много по развитию Swift Export, прямого экспорта в Swift без Objective-C-прослойки, но он всё ещё в Alpha, несмотря на значительные успехи за прошлые годы.
Значит, мы должны подумать о том, как не усложнить жизнь iOS-разработчикам. Наши UseCase'ы будут возвращать только Domain Entities, в обёртке DomainResult, или Flow из них. Так мы будем уверены, что объекты на той стороне поведут себя ожидаемо, потому что интероп примитивных типов работает отлично.
Почему это важно? Например, даже привычные sealed-классы Kotlin не заработают в iOS «из коробки» так, как в Android. Без дополнительных инструментов Kotlin-компилятор экспортирует sealed class и его наследников в Objective-C как обычную иерархию классов. Swift о «запечатанности» ничего не знает, и exhaustive switch по такому типу невозможен — остаются цепочки кастов:
// Без SKIE: DomainResult — просто класс с наследниками if let success = result as? DomainResultSuccess<AuthResponse> { handle(success.value) } else if let failure = result as? DomainResultFailure<AuthResponse> { show(failure.error) } else { // Swift не знает, что других вариантов нет: ветка else обязана быть. // Добавите третий кейс в sealed class — этот код молча продолжит собираться, // и о необработанном варианте вы узнаете в рантайме. }
В Kotlin такой код не скомпилировался бы после добавления нового кейса, и эту гарантию мы вернём себе чуть ниже с помощью SKIE.
Наш универсальный возвращаемый тип — это sealed-класс DomainResult, аннотированный @ObjCName для дружелюбного имени в Swift:
sealed class DomainResult<out T> { @OptIn(ExperimentalObjCName::class) @ObjCName("success", swiftName = "success") data class Success<T>( val value: T, // true, если значение пришло из кеша и может быть устаревшим val isOldData: Boolean = false, ) : DomainResult<T>() @OptIn(ExperimentalObjCName::class) @ObjCName("failure", swiftName = "failure") data class Failure<T>(val error: AppError) : DomainResult<T>() }
Возвращаем его как привычный Kotlin- и Swift-разработчикам Result:
class LoginUseCase internal constructor( private val authRepository: AuthRepository, private val profileRepository: ProfileRepository, ) { suspend operator fun invoke(email: String, password: String): DomainResult<AuthResponse> = when (val loginResult = authRepository.login(email, password)) { is DomainResult.Failure -> loginResult is DomainResult.Success -> when (val profile = profileRepository.getProfile()) { is DomainResult.Failure -> { // Прогрев профиля не удался — откатываем частичный логин, // иначе сохранённая сессия при следующем старте уведёт на Main authRepository.logout() DomainResult.Failure(profile.error) } is DomainResult.Success -> loginResult } } }
Может возникнуть резонный вопрос: а почему не стандартный kotlin.Result? Потому что это value-класс, а value-классы не экспортируются в Objective-C: на стороне Swift вместо него окажется бесполезный Any? (KT-32352). Решить проблему поможет самописный sealed DomainResult, в который мы добавим ещё и флаг isOldData для offline-first-сценариев.
SKIE
Подружить два разных мира помогает ещё и SKIE — очень полезная библиотека для проектов, которые используют KMP для Android и iOS. Она кодогенерирует решения для некоторых проблем интеропа: мапит Kotlin Flow в Swift AsyncSequence, превращает suspend-функции в async/await, делает sealed-классы Swift-дружелюбными enum'ами и много чего ещё. Настоятельно рекомендую.
Отмечу, что Swift Export уже умеет преобразовывать suspend в async/await, а Flow — в AsyncSequence из коробки. Но SKIE, по моему мнению, пока остаётся более зрелым решением для большого проекта.
Как это выглядит вживую. Suspend-функция UseCase'а на стороне Swift становится обычным async-вызовом:
// Kotlin: suspend operator fun invoke(email: String, password: String): DomainResult<AuthResponse> let result = try await loginUseCase.login(email: email, password: password)
Kotlin Flow — родным для Swift AsyncSequence, который можно использовать в for await:
// Kotlin: fun observeUserProfile(): Flow<DomainResult> for try await result in observeUserProfileUseCase.observeUserProfile() { render(result) }
А sealed-класс — Swift-enum'ом с exhaustive switch:
switch onEnum(of: result) { case .success(let loginResult): if let value = loginResult.value { sessionToken = value.accessToken } return true case .failure(let error): if error.error is AppError.UnauthorizedError { showInvalidCredentials = true return false } print("Failed with error: \(error.error)") }
Да, придётся для каждого вызова UseCase использовать onEnum(of: result). Но, кажется, пока не слишком много бойлерплейта. Зато получаем бонус, который легко недооценить: отмена корутин работает сквозь границу языков. Если SwiftUI убил Task вместе с View, SKIE отменит и котлиновскую корутину, не требуется никакого ручного менеджмента подписок.
Да, SKIE увеличивает время сборки iOS-проекта из-за кодогенерации — но это оправданная цена за строгую типизацию и отсутствие рефлексии в рантайме. Вот несколько практических советов, как «правильно приготовить» SKIE:
Время сборки растёт в первую очередь от числа экспортируемых деклараций, а не от количества включённых фич SKIE. Целесообразно держать публичной только поверхность UseCase'ов и моделей, и это ещё один аргумент в пользу нашей границы shared-слоя.
Если iOS-команда живёт на Combine, включите preview-фичи конвертации:
enableFlowCombineConvertorPreviewиenableFutureCombineExtensionPreviewв блокеskie {features { … }}.При изменении Kotlin-API проверяйте сгенерированный Swift (
shared/build/skie/...) и собирайте iOS-проект до мержа. Лучше всего это делать CI-джобой сxcodebuild.Версии SKIE привязаны к версиям Kotlin-компилятора, поэтому планируйте их обновление вместе.
Не выпускайте исключения из Flow, уходящего в Swift: такое исключение не превратится в Swift-ошибку, а уронит приложение (ограничения SKIE Flows). Возвращайте
DomainResult, а сбои источника ловите оператором catch.
Обработка ошибок
Помимо Domain Entities, UseCase, очевидно, может возвращать и ошибки. И тут мы уже обсудили несколько проблем: kotlin.Result превращается в Any?, дженерики Kotlin-интерфейсов стираются при экспорте, а extension-функции приезжают в Swift глобальными функциями. Но вдобавок Throwable в Kotlin и Error в Swift работают по-разному и не мапятся полностью один в другой.
Поэтому неплохим вариантом архитектурного решения для пробрасывания исключений в наш DomainResult являются это кастомные ошибки AppError. В этом подходе, помимо его мультиплатформенности, меня привлекает возможность задать фиксированный набор ошибок приложения. И HTTP, и технические (таймауты, недоступность хоста), и внутренние ошибки самого приложения использут не «джавовые» типы, а наши собственные:
// Строки ошибок здесь — для демонстрации: это технический fallback. // Тексты для UI кладём в KMP-ресурсы и мапим по кейсам на стороне Presentation. sealed class AppError(message: String) : Throwable(message) { data object NetworkUnavailableError : AppError("Сеть недоступна. Проверьте соединение.") data object NetworkError : AppError("Ошибка сети. Повторите попытку.") data object TimeoutError : AppError("Время ожидания ответа истекло.") data object UnauthorizedError : AppError("Неверный логин или пароль.") // 401 data object AccessDeniedError : AppError("Доступ запрещён.") // 403 data object TooManyRequestsError : AppError("Слишком много запросов.") // 429 data object ServerError : AppError("Ошибка на сервере.") // 500 data class UnknownError(val details: String) : AppError(details) // ...полный список кодов }
Очевидно, что на каждую из этих ошибок приложение должно реагировать по-разному. Например, мы хотим перейти с главного экрана на экран профиля пользователя. Загружаем данные и ловим ошибку. Если это ошибка сети и кеш пустой, показываем Snackbar «Ошибка сети» и остаёмся на текущем экране. Если в кеше есть данные — переходим на экран профиля и показываем их. Если пришла 500 — показываем полноэкранную заглушку «Проблемы на сервере». Если 401 — инициируем разлогин, чистим навигационный стек и показываем экран логина.
Причём поведение приложения меняется в зависимости от важности запроса. Некоторые ошибки можно игнорировать: если фоновый рефреш кеша упал с ошибкой сети, а на экране уже есть данные, незачем говорить пользователю об этой проблеме. Просто молча попробуем ещё раз в следующем цикле. Ошибки отправки аналитики или синхронизации push-токена логируем, но на UI не показываем. А вот с отправкой сообщения в чате наоборот: нужны и явный retry, и статус «не доставлено». И так — на каждом экране.
Ошибка | Кеш | Реакция UI |
Сеть недоступна / таймаут | пустой | Snackbar «Ошибка сети», остаёмся на текущем экране |
Сеть недоступна / таймаут | есть данные | показываем экран с данными из кеша (можно с пометкой «данные могли устареть») |
| любой | полноэкранная заглушка «Проблемы на сервере» с кнопкой «Повторить» |
| любой | разлогин: чистим сессию и навигационный стек, переходим на экран логина |
| любой | Snackbar «Действие недоступно», остаёмся на экране |
Фоновое обновление упало | есть данные | игнорируем: пользователь работает со свежим кешем |
Чтобы не переписывать одну и ту же логику на всех экранах, мы можем использовать механизм ErrorHandler. Контракт и дефолтную реализацию держим в shared — они пишутся один раз; а вот исполняет решение нативный Presentation каждой платформы. Класс-наследник DefaultErrorHandler реализует обработку так, как предписывают ваши корпоративные UX-гайдлайны, а для особых случаев создаём кастомный ErrorHandler и инжектируем его в конкретную ViewModel.
// Контракт: превращает AppError в понятный UI side-эффект interface ErrorHandler { suspend fun resolveError(error: AppError): ErrorSideEffect } // Фиксированный набор реакций UI. Тоже sealed, чтобы UI разобрал все случаи. // Домен говорит, что случилось; как показать — решает нативный Presentation. sealed interface ErrorSideEffect { /** Восстановимая проблема, которая не убивает сессию: пользователь может остаться на экране. */ data class TransientError(val message: String) : ErrorSideEffect /** Сессия мертва: платформа чистит стек и уходит на логин. */ data object SessionExpired : ErrorSideEffect /** Фоновый вызов упал, а данные на экране есть — ничего не делаем */ data object Ignore : ErrorSideEffect } // Поведение «по корпоративным UX-гайдлайнам» — покрывает большинство экранов class DefaultErrorHandler( private val logoutUseCase: LogoutUseCase, ) : ErrorHandler { override suspend fun resolveError(error: AppError): ErrorSideEffect = when (error) { AppError.UnauthorizedError -> { logoutUseCase() ErrorSideEffect.SessionExpired } AppError.NetworkUnavailableError, AppError.NetworkError, AppError.TimeoutError, AppError.AccessDeniedError, AppError.TooManyRequestsError -> ErrorSideEffect.TransientError(error.message.orEmpty()) else -> ErrorSideEffect.TransientError(error.message ?: "Something went wrong") } }
Во ViewModel остаётся маленький when по ErrorSideEffect: превратить семантическое решение в конкретный снекбар, полноэкранное состояние или навигацию. Главное, что when(error) по всем типам AppError на каждом экране больше не нужен: классификация случилась один раз в shared.
Но откуда берутся AppError? Все сетевые запросы проходят через функцию safeApiCall: она мапит и HTTP-статусы, и сетевые исключения, отдавая наружу единый DomainResult:
internal suspend inline fun <reified T> HttpClient.safeApiCall( request: HttpClient.() -> HttpResponse, ): DomainResult<T> = try { val response = request(this) when { response.status.value in 200..299 -> DomainResult.Success(response.body()) response.status == HttpStatusCode.Unauthorized -> DomainResult.Failure(AppError.UnauthorizedError) response.status == HttpStatusCode.Forbidden -> DomainResult.Failure(AppError.AccessDeniedError) response.status == HttpStatusCode.NotFound -> DomainResult.Failure(AppError.NotFoundError) response.status.value in 500..599 -> DomainResult.Failure(AppError.ServerError) // ...остальные коды else -> DomainResult.Failure(AppError.UnknownError(response.errorDetails())) } } catch (e: CancellationException) { // Отмену корутины пробрасываем, чтобы работала structured concurrency throw e } catch (e: Exception) { when (e) { is HttpRequestTimeoutException, is SocketTimeoutException, is ConnectTimeoutException -> DomainResult.Failure(AppError.TimeoutError) is UnresolvedAddressException -> DomainResult.Failure(AppError.NetworkUnavailableError) // 2xx с нечитаемым телом — баг контракта, а не сети is JsonConvertException, is SerializationException -> DomainResult.Failure(AppError.UnknownError("Malformed response: ${e.message}")) else -> { Napier.w("safeApiCall: unexpected ${e::class.simpleName}", e, tag = "Network") DomainResult.Failure(AppError.UnknownError("Unexpected client error: ${e.message}")) } } }

Кроме транспортных, в AppError живут и доменные ошибки — те, что рождаются в бизнес-логике, а не в сети. Например, валидация полей формы перед отправкой: data class FormValidationError(val errors: List) : AppError(…). Для UI это принципиально ничего не меняет: доменная ошибка проходит через тот же DomainResult и тот же ErrorHandler, что и HTTP-шные. Экрану не важно, где именно что-то пошло не так, важно лишь то, как на это реагировать.
DI без боли
Ещё одна важная фича shared-слоя: нормальный DI. Мне нравится Koin. Да, это скорее сервис-локатор, он не такой мощный, как Hilt, но предсказуемый и кроссплатформенный. Благодаря ему мы собираем всё дерево зависимостей в shared-модуле привычным для Android-разработчиков способом. Важно это ещё и потому, что на стороне iOS нам не приходится создавать и инжектить все объекты в дерево UseCase’а руками. Разработчики вряд ли любили бы нас за это, да и пришлось бы помечать все сущности в shared-модуле как public. А с нашим подходом мы просто провайдим UseCase в iOS-часть — и всё работает.
Шаг 1: регистрируем UseCase один раз в общем Koin-модуле:
factoryOf(::LoginUseCase) // лёгкий одноразовый объект — новый на каждый запрос
Шаг 2: отдаём наружу для iOS через объект-инжектор; весь граф уже собран в shared. Kotlin-only точки инициализации помечаем @HiddenFromObjC: иначе Koin DSL всё равно окажется в Shared.h, даже если сам KoinInjector не наследует KoinComponent.
// Сознательно НЕ KoinComponent: иначе Koin-типы попадут в сигнатуру самого инжектора object KoinInjector { private val koin get() = KoinPlatform.getKoin() val loginUseCase: LoginUseCase get() = koin.get() val getUserProfileUseCase: GetUserProfileUseCase get() = koin.get() // ...остальные UseCase }
Шаг 3: на стороне Swift оборачиваем Kotlin-UseCase в протокол ради тестируемости и берём готовый инстанс из KoinInjector:
public protocol LoginUseCaseProtocol { func login(email: String, password: String) async throws -> DomainResult<AuthResponse> } extension LoginUseCase: LoginUseCaseProtocol { public func login(email: String, password: String) async throws -> DomainResult<AuthResponse> { try await self.invoke(email: email, password: password) } } // инстанс берём так: KoinInjector.shared.loginUseCase
Но раз мы ограничиваем DI предоставлением UseCase’ов, подменять зависимости для тестирования в iOS-приложении приходится руками. Можно было бы использовать Koin и на стороне iOS или создать интерфейс для UseCase прямо внутри domain-слоя. В тесте подсовываем мок с той же сигнатурой:
// Заглушка, чья сигнатура точно совпадает с реальным LoginUseCaseProtocol final class MockLoginUseCase: LoginUseCaseProtocol { enum Outcome { case success(AuthResponse); case failure(Error) } let outcome: Outcome init(outcome: Outcome) { self.outcome = outcome } func login(email: String, password: String) async throws -> DomainResult<AuthResponse> { switch outcome { case .success(let response): return DomainResultSuccess(value: response, isOldData: false) case .failure: return DomainResultFailure(error: AppError.NetworkError()) } } } // в тесте: LoginViewModel(loginUseCase: MockLoginUseCase(outcome: .success(...)))..)))
Presentation-слой
А что в Presentation-слое? Ровно то, что решат команды Android, iOS, Desktop и Web. На iOS может быть MVVM или Redux, а на Android — MVI. Аргументы, почему мы сознательно не шарим ViewModel и не строим архитектуру вокруг presentation-фреймворков, можно найти в первой, стратегической части.
Модульность
Модуляризация предлагается вполне обычная: внутри shared-слоя выделяются core-модули (сеть, БД, аналитика, общие утилиты) и feature-модули. И точно так же делятся нативные реализации. Каждый feature-модуль объявляет собственный Koin-модуль с фабриками своих UseCase'ов, а корневой модуль их агрегирует — дерево зависимостей собирается из независимых кусочков.
Замечу также, что написанием и поддержкой логики core-библиотек в больших компаниях обычно занимается platform team. И эта команда может писать код и покрывать его тестами всего один раз.

Трейд-оффы
Отдельно стоит сказать про итоговый Shared framework. KMP экспортирует для iOS один «зонтичный» (umbrella) фреймворк: iOS-приложение получает весь shared-код единым бинарником. Да, это «многовато»: подключать feature-модули на iOS по отдельности и версионировать их независимо из коробки не выйдет.
Но Kotlin/Native компилируется по принципу closed world, поэтому каждый фреймворк тащит свой рантайм и свои копии общих зависимостей, а одинаковые Kotlin-типы из разных фреймворков бинарно несовместимы между собой. Для приложения, где модули делят общие типы, umbrella поэтому фактически безальтернативен. Подход с несколькими фреймворками жизнеспособен, когда это действительно независимые SDK, не обменивающиеся Kotlin-объектами.

Ещё один компромисс: для некоторых случаев всё же нужно писать не совсем обычный связующий код. Расскажу про два примера: пуши и трекеры аналитики (этих фич нет в демо-шаблоне, примеры взяты из реального проекта). В каждом из них есть несколько строк, обеспечивающих правильные вызовы между Kotlin и Swift. Подчеркну: это не мосты и не рефлексия, такой нативный код не создаёт проблем с безопасностью или производительностью.
Пуши: платформенные реализации общих контрактов подставляются в Koin из iosMain:
// iosMain: нативная реализация контракта PushPlatformProvider class IosPushPlatformProvider : PushPlatformProvider { override val platform: String = "ios" } // iosMain Koin: подставляем iOS-реализации val iosModule = module { single<PushTokenSyncStore> { IosPushTokenSyncStore() } single<PushPlatformProvider> { IosPushPlatformProvider() }
Аналитика: та же идея, но в обратную сторону: SDK трекеров (Firebase, Amplitude, Mixpanel и т. д.) представляют собой нативные iOS-библиотеки, а вызывает их общий Kotlin-код. Поэтому shared объявляет фабрику-контракт, а iOS-приложение при старте отдаёт в Koin её Swift-реализацию:
// iosMain: контракт на зависимости, которые реализованы на Swift interface SwiftLibDependencyFactory { fun provideAnalyticsManager(): AnalyticsManager } fun KoinApplication.provideSwiftLibDependencyFactory(factory: SwiftLibDependencyFactory) = modules(module { single { factory.provideAnalyticsManager() } bind AnalyticsManager::class })
// iOS: реализация фабрики нативными трекерами class SwiftLibDependencyFactoryImpl: Shared.SwiftLibDependencyFactory { static var shared = SwiftLibDependencyFactoryImpl() func provideAnalyticsManager() -> AnalyticsManager { AnalyticsManager( trackers: [MixpanelTracker(), AmplitudeTracker(), FirebaseTracker()], // ... ) } } // и её передача при инициализации Koin в iOSApp.swift KoinInitializer.shared.doInitKoin { koinApp in koinApp.provideSwiftLibDependencyFactory(factory: SwiftLibDependencyFactoryImpl.shared) }
А ещё есть общие проблемы: локальные сложности с отладкой на iOS, ограничения SKIE, необходимость в той или иной степени знать все платформы и языки, которые вы используете на проекте.
Тестирование и документирование
Как показано в первой статье, у нас есть три основные сущности с бизнес-логикой:
RepositoryImpl — разбор и маппинг данных, выбор их источника и представления;
UseCase — основная бизнес-логика, которая решает важные для пользователя задачи;
ViewModel — логика подготовки данных к отображению.
Первые две мы покрываем тестами один раз, когда пишем shared-модуль. Покрытие, кстати, удобно измерять при помощи Kover, он отлично работает с KMP. Правда, есть нюанс: покрытие считается по JVM-таргету (./gradlew :shared:jvmTest), Native- и JS-таргеты Kover не инструментирует. Но для нашей схемы это ровно то, что нужно. На проекте целесообразно настроить CI-гейт: смержить ветку нельзя, если покрытие этих сущностей опускается ниже, например, 80%.
Покрытие ViewModel остаётся на совести каждой из платформ. И каждая платформа свободна в выборе других инструментов: snapshot-тесты, автотесты, e2e — всё, что угодно.
// shared/src/jvmTest: обычный JVM-тест — mockk, coroutines-test, никакого симулятора class LoginUseCaseTest { private val authRepository = mockk<AuthRepository>() private val profileRepository = mockk<ProfileRepository>() private val useCase = LoginUseCase(authRepository, profileRepository) @Test fun `wrong credentials short-circuit without touching the profile`() = runTest { coEvery { authRepository.login(any(), any()) } returns DomainResult.Failure(AppError.UnauthorizedError) val result = useCase("demo@digest.app", "wrong") val failure = assertIs<DomainResult.Failure<AuthResponse>>(result) assertEquals(AppError.UnauthorizedError, failure.error) coVerify(exactly = 0) { profileRepository.getProfile() } } @Test fun `profile failure after login rolls back the session`() = runTest { coEvery { authRepository.login(any(), any()) } returns DomainResult.Success(auth) coEvery { profileRepository.getProfile() } returns DomainResult.Failure(AppError.ServerError) coEvery { authRepository.logout() } returns Unit val result = useCase("demo@digest.app", "digest") assertIs<DomainResult.Failure<AuthResponse>>(result) // Частичный логин откачен: сохранённой сессии остаться не должно coVerify(exactly = 1) { authRepository.logout() } } }
Из-за ограничений mockk эти тесты также гоняются только на JVM-таргете. Зато прогон занимает секунды, отлично себя чувствует на CI и тестирует ровно ту логику, которая уедет на обе платформы.
Документацию пишем на те же три сущности и собираем автоматически: плагин Dokka на каждой сборке shared-модуля генерирует актуальный справочник всех бизнес-сценариев проекта. Можно создать и настроить у вашего ИИ-агента скилл написания документации, и он будет обновлять KDoc прямо перед отправкой кода на ревью. И ваша документация всегда будет актуальной.
Что ещё?
В shared-слое также можно реализовать постраничную загрузку контента (например, при помощи AndroidX Paging), инвалидацию кеша (Store5) и логирование (Napier).
А вот фоновая загрузка данных остаётся полуплатформенной: единой KMP-библиотеки здесь пока нет, планировщики нативные: WorkManager на Android, BGTaskScheduler на iOS. Но сами задачи, которые они запускают, — это всё те же общие UseCase.
Итоги
Что мы получаем на выходе?
Комфорт команд и меньше интеграционных ошибок. Платформенные разработчики работают привычными инструментами, а узкая и строго типизированная граница
sharedснижает риск ошибок на стыке технологий.Готовность к автоматизации. Стандартизированные Data- и Domain-слои позволяют генерировать по контрактам бэкенда типовой каркас репозиториев, мапперов, UseCase’ов и тестов с помощью файловых шаблонов или ИИ-агента.
Одна реализация бизнес-логики для всех платформ. Общие сценарии разрабатываются, документируются и тестируются один раз, поэтому платформы ведут себя одинаково, а развитие продукта требует меньше времени и ресурсов.
И главное: это не ставка на будущие обещания. Подход работает уже сегодня без ручных мостов, рефлексии и сомнительной обвязки, а развитие Swift Export будет только сокращать количество инфраструктурного кода.
И ещё одна ссылка на репозиторий проекта: 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/
Пример Clean Architecture на Kotlin (Fernando Cejas): https://github.com/android10/Android-CleanArchitecture-Kotlin
Google, Guide to app architecture: https://developer.android.com/topic/architecture
Google, рекомендации по архитектуре: https://developer.android.com/topic/architecture/recommendations
Google, Build an offline-first app: https://developer.android.com/topic/architecture/data-layer/offline-first
NetworkBoundResource (исходник в семпле GithubBrowserSample): https://github.com/android/architecture-components-samples/blob/master/GithubBrowserSample/app/src/main/java/com/android/example/github/repository/NetworkBoundResource.kt
Библиотеки и инструменты
SKIE (Touchlab) — мост Kotlin↔Swift: https://skie.touchlab.co/ (репозиторий: https://github.com/touchlab/SKIE)
Koin — DI: https://insert-koin.io/
Dokka — документация: https://kotlinlang.org/docs/dokka-introduction.html
Kover — покрытие Kotlin-кода: https://github.com/Kotlin/kotlinx-kover
Store5 (MobileNativeFoundation) — offline-first / кэш, KMP: https://github.com/MobileNativeFoundation/Store
KMMBridge (Touchlab) — публикация готовых XCFramework для iOS-команды через SPM/CocoaPods: https://kmmbridge.touchlab.co/
Интероп и сборка iOS-фреймворков:
Kotlin Swift Export (прямой экспорт Kotlin → Swift, Alpha): https://kotlinlang.org/docs/native-swift-export.html
Touchlab, «Multiple Kotlin Frameworks in an Application» — почему umbrella: https://touchlab.co/multiple-kotlin-frameworks-in-application
KT-32352 — value/inline-классы экспортируются в ObjC как id (поэтому kotlin.Result не работает через границу): https://youtrack.jetbrains.com/issue/KT-32352
