> Как работает компиляция в Kotlin Multiplatform (Kotlin, Android)

Уровень: senior · Роль: mobile · Категория: Технические вопросы

Компании: IBS

Стек: Kotlin, Android

> Пример ответа

Короткий ответ

Kotlin Multiplatform компилирует общий код в промежуточное представление (IR), а затем - в нативные бинарники или байткод для каждой целевой платформы. Для Android и iOS используется один и тот же frontend (Kotlin compiler), но разные backend'и: JVM-байткод для Android и Kotlin/Native (LLVM) для iOS. Платформенный код остаётся на Kotlin/JVM или Swift/Objective-C, связь обеспечивается через expect/actual и сгенерированные фреймворки.

Подробное объяснение

Компиляция в Kotlin Multiplatform - это многоступенчатый процесс, который зависит от выбранных таргетов. Основные этапы:

  1. Frontend (общий для всех таргетов): исходный код на Kotlin парсится, проверяется типизация, строится семантическая модель. Здесь работает expect/actual резолвинг: компилятор сопоставляет ожидаемые объявления с их фактическими реализациями для конкретной платформы.

  2. Backend для JVM (Android): генерируется байткод JVM, который затем упаковывается в DEX (через d8/r8) и интегрируется в APK. Здесь нет ничего специфичного для KMP - это стандартный Kotlin/JVM, но с учётом иерархии промежуточных исходных наборов (source sets).

  3. Backend для Kotlin/Native (iOS): используется LLVM. Код компилируется в нативный машинный код для конкретной архитектуры (arm64 для устройств, x86_64 для симулятора). На выходе получается статическая библиотека или фреймворк, который затем линкуется с Swift/Objective-C кодом.

  4. Иерархия source sets: компилятор собирает код из общего source set (commonMain), платформенных (androidMain, iosMain) и промежуточных (например, iosArm64Main). Для каждого таргета строится своя комбинация.

  5. Kotlin/Native компиляция в два прохода: сначала компилируется сам Kotlin-код в LLVM IR, затем этот IR оптимизируется и линкуется с рантаймом Kotlin/Native и зависимостями (например, с cinterop-библиотеками).

  6. Генерация Objective-C header: для iOS Kotlin/Native автоматически генерирует заголовочный файл и экспортирует Kotlin-классы как Objective-C классы, что позволяет вызывать их из Swift.

Важный нюанс: компиляция в KMP не является "компиляцией один раз - запуск везде". Это скорее "написание один раз - компиляция для каждой платформы отдельно". Общий код компилируется повторно для каждого таргета, что увеличивает время сборки, но даёт нативную производительность.

На практике

На практике процесс выглядит так:

  • Gradle-плагин (kotlin-multiplatform) создаёт для каждого таргета отдельную компиляционную задачу. Например, compileKotlinIosArm64, compileKotlinAndroid.
  • Android-таргет использует стандартный Kotlin/JVM компилятор, поэтому интеграция с Android Gradle Plugin происходит через KotlinCompile task.
  • iOS-таргет требует настройки binaries.framework, который генерирует .framework для последующего использования в Xcode. Обычно создаются два варианта: debug и release, а также отдельно для симулятора и устройства.
  • CocoaPods или Swift Package Manager используются для подключения сгенерированного фреймворка к iOS-проекту.
  • Рантайм Kotlin/Native включается в итоговый бинарник, поэтому размер приложения увеличивается (обычно на 1-3 МБ на архитектуру).
  • Для iOS симулятора и устройства компиляция происходит отдельно, поэтому часто используют iosX64 (симулятор на Intel), iosArm64 (устройство) и iosSimulatorArm64 (симулятор на Apple Silicon). Это три разные компиляции одного и того же кода.

Пример кода

// commonMain/kotlin/Platform.kt
expect fun platformName(): String

// androidMain/kotlin/Platform.android.kt
actual fun platformName(): String = "Android"

// iosMain/kotlin/Platform.ios.kt
actual fun platformName(): String = "iOS"
// build.gradle.kts
kotlin {
    androidTarget {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }

    listOf(
        iosArm64(),
        iosSimulatorArm64(),
        iosX64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
        }
        androidMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
        }
    }
}

Как отвечать на собеседовании

Начните с общего описания двух backend'ов и того, что общий код компилируется отдельно для каждой платформы. Затем перейдите к деталям: упомяните expect/actual, иерархию source sets, генерацию Objective-C header для iOS. Если спросят про производительность - подчеркните, что нативный код не требует рантайма JVM на iOS, но рантайм Kotlin/Native всё равно присутствует. Если спросят про время сборки - честно скажите, что KMP увеличивает время компиляции, особенно для iOS, и что это решается кэшированием и параллельными тасками.

Хорошо показать понимание разницы между iosArm64, iosX64 и iosSimulatorArm64 - это демонстрирует практический опыт. Также можно упомянуть, что для Android используется стандартный Kotlin/JVM, поэтому никаких сюрпризов там нет.

Что проверяет интервьюер

Интервьюер проверяет:

  • понимание того, что KMP - это не "write once, run anywhere", а компиляция под каждую платформу;
  • знание роли expect/actual в процессе компиляции;
  • понимание иерархии source sets и как Gradle это моделирует;
  • осведомлённость о генерации Objective-C фреймворков для iOS;
  • способность объяснить, почему для iOS нужно несколько таргетов (симулятор/устройство, Intel/ARM);
  • понимание trade-off'ов: время сборки, размер бинарника, сложность настройки.

Типичные ошибки

  • Утверждение, что Kotlin/Native компилирует код в байткод, который выполняется в виртуальной машине - это неверно, там LLVM и нативный машинный код.
  • Игнорирование того, что Android-таргет использует обычный Kotlin/JVM, а не Kotlin/Native.
  • Путаница между iosArm64 и iosSimulatorArm64 - это разные архитектуры, и код компилируется отдельно.
  • Заявление, что общий код компилируется один раз и переиспользуется - на самом деле он компилируется для каждого таргета.
  • Непонимание, что expect/actual резолвится на этапе компиляции, а не в рантайме.
  • Забывание про рантайм Kotlin/Native, который линкуется в итоговый бинарник и увеличивает его размер.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью