Kotlin Multiplatform: архитектура общего кода на интервью
Процент общего кода плохо описывает качество Kotlin Multiplatform проекта. Можно вынести почти всё в commonMain и получить интерфейс, который игнорирует lifecycle, навигацию и привычки платформы. Можно оставить много platform code, но разделить бизнес-правила, сеть и хранение так, что две команды перестанут чинить один дефект дважды. На интервью важно объяснить границу, а не похвастаться числом reuse.
Рассмотрим приложение с каталогом, избранным, offline cache и оплатой. Android и iOS используют разные UI-стеки, платежные SDK и background policies. Общими могут стать модели, use cases, синхронизация и часть data layer. Кейс раскрывает зависимости, expect/actual, concurrency, тесты и выбор platform UX.
Граница проходит по стабильности смысла
В commonMain помещайте правила, одинаковые по смыслу и меняющиеся совместно: расчёт доступности действия, merge локальных и серверных данных, формат domain error, state machine синхронизации. API, который лишь случайно похож сегодня, не обязан становиться общей абстракцией.
Platform layer владеет тем, что зависит от OS: разрешениями, lifecycle, navigation, push token, secure storage, purchase flow, accessibility и визуальными паттернами. Общий use case может запросить capability через интерфейс, но решение о показе системного диалога остаётся у платформы.
Dependency direction важнее расположения папки. Domain не импортирует Android Context или UIKit. Адаптеры реализуют порты на каждой платформе, composition root собирает граф. Если общий модуль знает детали SDK, portability стала фикцией.
Если common API требует
if (isIOS)у каждого consumer, платформенная разница уже просочилась наружу. Пересмотрите порт или оставьте поведение на platform layer.
expect/actual используют для узкой платформенной разницы
expect/actual удобен для небольших типов и функций, чья семантика совпадает: clock, platform identifier, crypto primitive с одинаковым контрактом. Большой expect class DatabaseManager с десятками методов прячет две разные реализации за обещанием равенства, которое сложно проверить.
Интерфейс и dependency injection часто яснее: common code видит capability, platform code передаёт реализацию, тест использует fake. expect/actual остаётся там, где тип нужен на уровне компиляции или platform API невозможно удобно завернуть. Выбор объясняется формой зависимости, а не личным стилем.
Следите за source sets. iosMain может объединять iOS targets, а специализированный source set — редкую разницу архитектуры. Копирование actual в каждом target сигнализирует, что иерархия построена плохо или контракт слишком широк.
| Слой | Общее | Платформенное | Проверка границы |
|---|---|---|---|
| Domain | правила и state | нет | common unit tests |
| Data | repository и merge | engine/storage | contract tests |
| Presentation | state при равном смысле | navigation и lifecycle | UI integration |
| Capabilities | порт | SDK adapter | device test |
Concurrency и ошибки входят в общий контракт
Suspend-функция не обещает конкретный dispatcher. Документируйте thread-safety, cancellation и ownership scope. UI создаёт и отменяет scope по lifecycle, use case кооперативно реагирует на cancel, repository защищает shared state. Глобальный scope в common code переживает экран и затрудняет тест.
Ошибки platform SDK переводятся в domain taxonomy без потери диагностического контекста. Пользовательский слой получает PaymentDeclined или NetworkUnavailable, логирование сохраняет platform cause безопасно. Нельзя протащить NSError в Android consumer или стереть все причины до Unknown.
Потоки состояния должны иметь ясный sharing policy. Горячий flow, который продолжает сеть без подписчиков, расходует батарею. Холодный flow может повторно запускать дорогую операцию на каждом collector. Выбор зависит от lifecycle данных, а не от модного оператора.
Тестовая пирамида ловит расхождение платформ
Common unit tests покрывают domain rules, state transitions, сериализацию и merge. Contract tests запускают один набор требований к Android и iOS реализациям порта: secure storage сохраняет и удаляет значение, clock соблюдает timezone, HTTP engine одинаково мапит timeout. Это защищает смысл абстракции.
Интеграционные тесты нужны на границах framework: lifecycle, background, push, database migration, deep link. Общий fake не воспроизведёт поведение Keychain после переустановки или ограничения Android background. Небольшой набор device tests закрывает дорогие platform failures.
Версии схем и API проверяют upgrade path. Новая сериализация должна читать старые cache-записи либо мигрировать их. Два приложения обновляются с разной скоростью, поэтому backend и shared library поддерживают совместимое окно.
Схема показывает опорные решения кейса «Kotlin Multiplatform: архитектура общего кода на интервью».
Платформенный UX не считается дублированием
Общий presentation state полезен, если экран имеет одинаковую модель решений. Но навигация, gestures, системные sheets, клавиатура и accessibility могут требовать разных компонентов. Дублирование нескольких строк UI дешевле общей абстракции с флагами isIOS в каждом методе.
Сверьте кейс с вакансиями Kotlin Multiplatform разработчика и посмотрите зарплатный ориентир. На интервью покажите эволюцию границы: что сначала вынесли, какая platform-разница сломала допущение и как архитектура изменилась без переписывания продукта.
Сборка общего модуля для iOS добавляет цену межъязыкового API. Nullability, generics, default arguments и sealed hierarchy могут выглядеть иначе в Swift. Публичную поверхность проверяйте глазами iOS-разработчика, а не только успешной компиляцией Kotlin.
Binary framework и source integration дают разные trade-offs по времени сборки, отладке и выпуску. Версионированный артефакт стабилизирует зависимость, но замедляет совместное изменение. Монорепозиторий облегчает refactor, зато требует дисциплины CI и ownership.
SQLDelight или другая общая база не отменяет platform storage policy. iOS может завершить приложение во время background sync, Android — ограничить worker. Транзакция и checkpoint проектируются с повтором, а не надеются на непрерывное выполнение.
Дата и время особенно опасны для общей логики. Храните instant отдельно от timezone и calendar rules, передавайте clock в тестах, проверяйте DST и смену locale. Строка, отформатированная в common code, может нарушить ожидания платформенного accessibility.
При выборе Compose Multiplatform для UI не смешивайте решение с KMP в целом. Оцените зрелость компонентов, доступность дизайнерской системы, native integrations и навыки команд. Общий UI может быть оправдан для одного продукта и лишним для другого.
Кейс выигрывает от честного удаления абстракции. Расскажите, как общий camera wrapper разросся platform-флагами и был разделён на два адаптера, сохранив общий результат use case. Это показывает способность снижать связанность, а не защищать первоначальный дизайн.
Мини-кейс: синхронизация переживает закрытие экрана
Первую версию избранного команда вынесла в commonMain вместе с глобальным CoroutineScope. Запрос продолжался после ухода с экрана, второй collector запускал ещё одну сеть, а iOS иногда завершала приложение между записью данных и checkpoint. Процент общего кода вырос, но lifecycle-гарантии не были определены.
Границу пересобрали. Platform presentation layer создаёт scope экрана и отменяет его при завершении lifecycle; общий use case кооперативно реагирует на cancellation. Долговечная синхронизация стала отдельной capability с platform scheduler: WorkManager на Android и допустимое background-окно на iOS. Repository выполняет идемпотентные порции, сохраняет checkpoint и не обещает непрерывный процесс.
Один набор contract tests проверяет save, read, delete, повтор после частичного сбоя и mapping ошибок у обеих реализаций storage. Device tests добавляют то, чего не умеет common fake: переустановку, entitlement Keychain, background termination и миграцию старого cache. Публичный Swift API отдельно проверили на nullability и читаемость sealed результатов.
Во время работы выяснилось, что общий camera wrapper требует isIOS почти в каждом методе. Его удалили: два узких адаптера сохранили общий результат use case без ложного единства SDK. Это уменьшило reuse в отчёте, зато упростило ownership и отладку.
Для оплаты граница получилась другой. Общий use case хранит намерение и domain state, но StoreKit и Google Play Billing остаются в platform adapters со своими lifecycle и типами подтверждения. Contract test проверяет общий результат покупки и повтор, device test — восстановление незавершённой транзакции. Попытка спрятать оба SDK за большим expect class дала бы одинаковые методы без одинаковых гарантий, поэтому её отвергли.
Граница оставалась проверяемой: common test не имитировал системный sheet, а platform test не повторял расчёт domain state. Каждый уровень отвечал за свою гарантию.
Кейс стоит заканчивать этой эволюцией. Хорошая KMP-граница обещает одинаковый смысл там, где его можно проверить, и явно оставляет платформе lifecycle, системный UX и ограничения фоновой работы.