usharik.dev

← Все статьи

Compose Multiplatform на практике: один интерфейс на Kotlin для Android и iOS

Опубликовано · · 6 мин чтения

Два моих Android-приложения, тренажёр чешской грамматики и тренажёр музыкального слуха, теперь работают и на iPhone. Это не порт и не переписывание: один и тот же код на Kotlin работает на обеих платформах, включая экраны. Версии для iOS уже в TestFlight и готовятся к выходу в App Store. Рассказываю, как прошёл переезд.

Почему Compose Multiplatform

Оба приложения и так были на Kotlin: интерфейс на Jetpack Compose, база Room и несколько сервисов Google. Для iOS было три реальных варианта:

Третий вариант сохраняет то, что уже работает. Compose Multiplatform рисует те же composable-функции на iOS, поэтому экраны не пришлось писать заново: им нужно было лишь перестать зависеть от Android.

Три слоя

В обоих приложениях получилась одна и та же структура:

На iOS общий интерфейс запускается одной функцией, которая заворачивает корневой composable во view controller; SwiftUI показывает этот контроллер и больше ничего не делает.

Что переехало как есть

Больше, чем я ожидал:

Что ушло за интерфейсы

Всё, что общается с платформой, получило интерфейс в общем коде и две реализации. В тренажёре грамматики список такой:

Упрощённый пример этого приёма:

// 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 не виден. Мост между языками работает хорошо, но несколько деталей стоит знать:

Нативные view могут жить внутри Compose. Рекламный баннер — это UIKit-view внутри UIKitView, высота которого зарезервирована заранее, чтобы экран не прыгал, когда приходит реклама.

Звук

Тренажёр слуха играет на фортепиано, и звук — единственное место, где платформы различаются полностью. На Android это MIDI-синтезатор, на iOS — аудиодвижок и сэмплер Apple с небольшим фортепианным SoundFont в составе приложения, и управляет ими Kotlin, а не Swift. Общий код видит один интерфейс, примерно «сыграй эту ноту сейчас, останови ту», а тайминг упражнений остаётся в Kotlin. Тест на iOS рендерит каждую ноту в буфер, не воспроизводя её, и проверяет, что это не тишина: машина без колонок слушать не умеет.

Тесты обеих платформ из одного кода

Чего это стоило и что дало

Большую часть кода писали мои ИИ-агенты, и единственным их окном в iOS был CI. Переезд шёл этапами: сначала общая логика, потом база, потом платформенные сервисы, потом экраны. После каждого этапа Android-приложение можно было выпускать, и обновления в это время продолжали выходить.

Выигрыш тот, ради которого всё затевалось: функция пишется один раз. В инструкциях для моих агентов есть правило «реализуй обе стороны в одном изменении»: новый платформенный сервис не готов, пока его нет на Android, на iOS и в тестовых фейках. Цена — более строгая дисциплина в общем коде и сборка, которой для iOS-половины нужен Mac. Подходящего у меня нет. Как я обхожусь без него — отдельная история.

Приложения из статьи