Compose Multiplatform на практике: один интерфейс на Kotlin для Android и iOS
Два моих Android-приложения, тренажёр чешской грамматики и тренажёр музыкального слуха, теперь работают и на iPhone. Это не порт и не переписывание: один и тот же код на Kotlin работает на обеих платформах, включая экраны. Версии для iOS уже в TestFlight и готовятся к выходу в App Store. Рассказываю, как прошёл переезд.
Почему Compose Multiplatform
Оба приложения и так были на Kotlin: интерфейс на Jetpack Compose, база Room и несколько сервисов Google. Для iOS было три реальных варианта:
- Нативное приложение на Swift. Лучше всего подходит платформе, но это второе приложение, которое придётся вечно держать вровень с первым. Для студии из одного человека это и есть главная статья расходов: каждая функция дважды, каждая ошибка дважды.
- Flutter или React Native. Тогда пришлось бы переписать и Android-приложение, причём на другом языке.
- Kotlin Multiplatform и Compose Multiplatform. Логика, данные и экраны переезжают в общий код на Kotlin, а небольшая нативная оболочка запускает их на каждой платформе.
Третий вариант сохраняет то, что уже работает. Compose Multiplatform рисует те же composable-функции на iOS, поэтому экраны не пришлось писать заново: им нужно было лишь перестать зависеть от Android.
Три слоя
В обоих приложениях получилась одна и та же структура:
- Общий код (
commonMain): правила, данные, вьюмодели, все экраны, строки на всех семи языках. Правило для этого слоя короткое: никакихandroid.*,java.*и Hilt. Время берётся изkotlin.timeиkotlinx-datetime, JSON изkotlinx.serialization, многопоточность из корутин. - Оболочка Android: класс приложения, activity, внедрение зависимостей и Android-сторона каждого платформенного сервиса.
- Оболочка iOS: тонкое приложение на SwiftUI, которое показывает общий интерфейс на весь экран, плюс Swift только для SDK Apple или Google, до которых из Kotlin не добраться.
На iOS общий интерфейс запускается одной функцией, которая заворачивает корневой composable во view controller; SwiftUI показывает этот контроллер и больше ничего не делает.
Что переехало как есть
Больше, чем я ожидал:
- Правила и вьюмодели. Подсчёт очков, серии дней, дневные цели, порядок вопросов, движок упражнений. Это и раньше был обычный Kotlin, кроме нескольких вызовов
java.time. - Экраны. Большинство composable-функций переехали с одной лишь заменой импортов. Material 3 есть, навигация есть, анимации есть.
- База данных. Room теперь поддерживает Kotlin Multiplatform. Схему и её хеш я оставил в точности прежними, поэтому Android-приложение открывает существующий файл базы и никто не теряет прогресс. На iOS та же база работает на SQLite, встроенном в приложение. Миграции переписаны со старого API
SupportSQLiteDatabaseна новый connection API и проверены на обеих платформах. - Строки. Строковые ресурсы Android стали ресурсами Compose с тем же XML. Ловушка: ресурсы Compose показывают текст как есть, и Android-экранирование вроде
\'появляется на экране с обратной косой чертой; в тренажёре слуха теперь на это падает юнит-тест. И ещё одна: общие тексты не должны упоминать магазин другой платформы. App Review не принимает «Google Play» в приложении для iPhone, поэтому тексты о покупке стали нейтральными, а на iOS к тому же нужна видимая кнопка «Восстановить покупки».
Что ушло за интерфейсы
Всё, что общается с платформой, получило интерфейс в общем коде и две реализации. В тренажёре грамматики список такой:
- настройки ключ-значение (SharedPreferences / NSUserDefaults);
- разовая покупка «без рекламы» (Play Billing / StoreKit 2);
- реклама и форма согласия (AdMob с SDK согласия Google на обеих платформах и запрос Apple на отслеживание на iOS);
- аналитика и отчёты о сбоях (Firebase на обеих);
- ежедневные напоминания (WorkManager / локальные уведомления, запланированные заранее);
- просьба оценить приложение, страница в магазине, вибрация, язык интерфейса.
Упрощённый пример этого приёма:
// commonMain: что нужно общему коду
interface Purchases {
val adFree: StateFlow<Boolean>
suspend fun buyAdFree(screen: ScreenHandle): PurchaseResult
suspend fun restore()
}
// Один объект отдаёт общему интерфейсу всё платформенное.
interface AppServices {
val settings: Settings
val purchases: Purchases
val analytics: Analytics
val reminders: ReminderScheduler
}
Android собирает AppServices из уже существующих классов, iOS собирает его вручную в небольшом объекте на Kotlin в iosMain. Общие тесты используют in-memory-фейки. Если на iOS функции пока быть не может, она получает явную пустую реализацию с комментарием: ни падения, ни тихого расхождения. Например, в iOS-версии тренажёра слуха пока нет рекламы и аналитики: эти реализации просто ничего не делают.
expect/actual используется только для мелких фактов: текущий экран, названия дней недели, язык системы. Всё, у чего есть поведение, — интерфейс, потому что интерфейс можно подменить в тесте, а expect-функцию нельзя.
Что осталось на Swift
Kotlin/Native вызывает фреймворки Apple напрямую, поэтому большинство сервисов iOS тоже на Kotlin: настройки, напоминания, вся iOS-часть тренажёра слуха. В тренажёре грамматики на Swift остались только SDK, до которых из Kotlin не добраться: StoreKit 2, Google Mobile Ads и Firebase. Каждый живёт в одном Swift-файле, который реализует интерфейс из Kotlin, а сам SDK из Kotlin не виден. Мост между языками работает хорошо, но несколько деталей стоит знать:
Intиз Kotlin приходит в Swift какInt32;- параметр функционального типа из Kotlin становится обычным замыканием Swift;
- Swift-API с пометкой
@MainActor, вызванное из метода Kotlin-интерфейса, требует явного перехода на главный актор; NSLog("%@", text)из Kotlin со строкой Kotlin роняет приложение: аргумент неNSString.
Нативные view могут жить внутри Compose. Рекламный баннер — это UIKit-view внутри UIKitView, высота которого зарезервирована заранее, чтобы экран не прыгал, когда приходит реклама.
Звук
Тренажёр слуха играет на фортепиано, и звук — единственное место, где платформы различаются полностью. На Android это MIDI-синтезатор, на iOS — аудиодвижок и сэмплер Apple с небольшим фортепианным SoundFont в составе приложения, и управляет ими Kotlin, а не Swift. Общий код видит один интерфейс, примерно «сыграй эту ноту сейчас, останови ту», а тайминг упражнений остаётся в Kotlin. Тест на iOS рендерит каждую ноту в буфер, не воспроизводя её, и проверяет, что это не тишина: машина без колонок слушать не умеет.
Тесты обеих платформ из одного кода
- Общие тесты идут дважды: на JVM (быстро, на любой машине) и на симуляторе iOS, где участвуют Kotlin/Native и настоящий SQLite iOS. В тренажёре грамматики CI ещё и требует заданного покрытия тестами общих правил.
- Тестовые теги — контракт между наборами UI-тестов.
Modifier.testTagна iOS становится accessibility identifier, поэтому тесты Compose на Android и XCUITest на iOS находят одни и те же элементы по одним и тем же именам. Теги не меняются, и каждый новый интерактивный элемент получает свой. - Проверка на маленьком экране. UI-тесты тренажёра грамматики на iOS дополнительно прогоняются на самом маленьком iPhone, который ещё остался в симуляторе: там длинные чешские и украинские слова ломают вёрстку вероятнее всего.
Чего это стоило и что дало
Большую часть кода писали мои ИИ-агенты, и единственным их окном в iOS был CI. Переезд шёл этапами: сначала общая логика, потом база, потом платформенные сервисы, потом экраны. После каждого этапа Android-приложение можно было выпускать, и обновления в это время продолжали выходить.
Выигрыш тот, ради которого всё затевалось: функция пишется один раз. В инструкциях для моих агентов есть правило «реализуй обе стороны в одном изменении»: новый платформенный сервис не готов, пока его нет на Android, на iOS и в тестовых фейках. Цена — более строгая дисциплина в общем коде и сборка, которой для iOS-половины нужен Mac. Подходящего у меня нет. Как я обхожусь без него — отдельная история.