expect/actual — механизм Kotlin Multiplatform, который позволяет объявлять платформенно-зависимые API в общем коде. Ключевое слово expect создаёт контракт функции, класса или свойства в commonMain, а ключевое слово actual предоставляет конкретную реализацию для каждой платформы. Компилятор проверяет, что каждой expect-декларации соответствует actual-реализация на всех целевых платформах. По данным JetBrains, 2025, механизм используется в 80% KMM-проектов для реализации платформенной бизнес-логики.
Основни моменти
expect/actual — это декларативный механизм Kotlin Multiplatform для реализации платформенно-ориентированного программирования. Он позволяет описать API один раз в общем модуле (expect) и реализовать его отдельно для каждой платформы (actual). В отличие от интерфейсов, expect/actual не создаёт виртуальных вызовов — компилятор связывает expect- и actual-декларации на этапе компиляции, что исключает накладные расходы на динамическую диспетчеризацию.
История expect/actual началась с появлением Kotlin Multiplatform в 2017 году. Первоначально механизм назывался expect/actual declarations и был экспериментальным. В Kotlin 1.2 были добавлены expect-аннотации, а в Kotlin 1.3 expect/actual стал стабильным для классов и функций. Постепенно механизм расширялся: в Kotlin 1.6 добавили поддержку expect/actual для companion-объектов, в Kotlin 1.7 — для enum-классов, а в Kotlin 2.0 — для typealias.
Ключевая особенность expect/actual — безопасность на уровне компиляции. Если разработчик добавил expect-объявление в commonMain, но забыл предоставить actual-реализацию для iOS, компилятор выдаст ошибку. Это предотвращает runtime-сбои, характерные для подходов с рефлексией или динамической загрузкой платформенного кода.
Механизм expect/actual работает на уровне source set — системы модулей Kotlin Multiplatform. Общий код, доступный всем платформам, располагается в source set'е commonMain. Платформенно-зависимый код — в iosMain, androidMain, macosMain и так далее. Ключевое слово expect в commonMain объявляет API, а keyчевое слово actual в платформенном source set'е предоставляет реализацию. Компилятор связывает их на этапе кодогенерации, заменяя вызов expect-функции на соответствующую actual-реализацию для целевой платформы.
Source set иерархия в типичном KMM-проекте выглядит следующим образом: commonMain содержит expect-декларации, iosMain и androidMain содержат actual-реализации. При компиляции для iOS используется actual из iosMain, при компиляции для Android — из androidMain. Source set'ы могут быть промежуточными (например, iosArm64Main для конкретной архитектуры), что позволяет уточнять реализации для разных устройств.
// commonMain — expect declaration
expect fun getPlatformName(): String
// androidMain — actual for Android
actual fun getPlatformName(): String = "Android"
// iosMain — actual for iOS
actual fun getPlatformName(): String = "iOS"
Kotlin-компилятор проверяет несколько условий при работе с expect/actual. Каждая expect-декларация должна иметь actual-реализацию для каждой активной платформы. Сигнатура actual-декларации должна совпадать с expect-сигнатурой (аннотация @OptionalExpectation может смягчать это требование). Модификаторы доступа, return type и параметры должны быть идентичны. Компилятор также проверяет отсутствие циклических зависимостей между expect- и actual-декларациями.
expect/actual поддерживает несколько типов деклараций. Наиболее часто используются expect/actual-функции для платформенных операций, expect/actual-классы для объектов, требующих нативной реализации, и expect/actual-свойства для констант и настроек. Каждый тип имеет свои правила использования и ограничения.
Expect/actual-функции — самый простой и распространённый тип. Они используются для вызова платформенных API, таких как получение времени, чтение файлов или отправка HTTP-запросов. Expect/actual-классы применяются для создания объектов, которые напрямую взаимодействуют с нативным кодом (например, для доступа к камере, геолокации или хранилищу ключей). Expect/actual-свойства (val) подходят для платформенных констант — имени ОС, версии SDK или пути к системной директории.
| Тип декларации | Ключевые слова | Пример использования |
|---|---|---|
| Функция | expect fun / actual fun | Получение уникального идентификатора устройства |
| Класс | expect class / actual class | Доступ к SecureStorage (Keychain / EncryptedSharedPreferences) |
| Свойство | expect val / actual val | Текущая платформа (iOS / Android) |
| Enum-класс | expect enum / actual enum | Список доступных разрешений приложения |
| Typealias | expect typealias / actual typealias | Тип сетевого ответа, специфичный для платформы |
Не все конструкции Kotlin могут быть использованы с expect/actual. Expect-декларация не может содержать тело — только сигнатуру. Expect-класс не может иметь конструктор с параметрами (должен быть пустой primary constructor). Для enum expect/actual все константы должны быть одинаковыми в expect и actual. Expect-свойства должны быть val (не var), так как хранение состояния в общем модуле для платформенных свойств не имеет смысла.
Рассмотрим практические примеры expect/actual от простых функций до полноценных классов. Базовый случай — получение имени платформы для использования в UI. Более сложные примеры включают доступ к нативному хранилищу и работу с платформенными потоками.
// commonMain — expect class for secure storage
expect class PlatformStorage {
fun save(key: String, value: String)
fun get(key: String): String?
fun remove(key: String)
}
// androidMain — actual on Android
actual class PlatformStorage {
private val prefs = AppContext.getSharedPreferences("secure", 0)
actual fun save(key: String, value: String) { prefs.edit().putString(key, value).apply() }
actual fun get(key: String): String? = prefs.getString(key, null)
actual fun remove(key: String) { prefs.edit().remove(key).apply() }
}
В этом примере expect-класс PlatformStorage определяет контракт простого хранилища ключ-значение. На Android реализация использует SharedPreferences, а на iOS — Keychain или NSUserDefaults. Благодаря expect/actual бизнес-логика в commonMain вызывает save/get/remove, не зная о платформенной реализации.
// iosMain — actual on iOS with Keychain
actual class PlatformStorage {
actual fun save(key: String, value: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecValueData to value.encodeToByteArray()
)
SecItemAdd(query, null)
}
actual fun get(key: String): String? {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecReturnData to true
)
val result = mutableMapOf<String, Any>()
return if (SecItemCopyMatching(query, result) == errSecSuccess)
result[kSecValueData]?.toString()
else null
}
actual fun remove(key: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key
)
SecItemDelete(query)
}
}
При проектировании expect/actual API следует придерживаться нескольких принципов. Минимизируйте количество expect-деклараций — чем больше общего кода, тем проще поддержка. Используйте expect/actual только для тех API, которые действительно различаются на платформах. Для остального кода применяйте интерфейсы с фабриками или dependency injection, что упрощает тестирование.
Рекомендуется группировать expect-декларации по тематическим модулям, а не смешивать их в одном файле. Например, Storage.kt для expect-деклараций хранилища, Platform.kt для expect-функций работы с ОС и Analytics.kt для expect-классов аналитики. Это упрощает навигацию и понимание платформенной поверхности KMM-проекта. Каждый actual-файл должен находиться в соответствующем source set'е: androidMain, iosMain, desktopMain и так далее.
Default-реализации через expect fun с actual fun, где actual использует common-код — распространённый антипаттерн. Если платформенная реализация не отличается от дефолтной, expect/actual не нужен. В таких случаях используйте простую функцию в commonMain. Также избегайте expect/actual для тривиальных геттеров — используйте expect val с константами.
Правильная структура expect/actual кода критична для читаемости проекта. Каждый expect/actual модуль должен иметь единую точку входа. Пример организации: commonMain/kotlin/com/project/platform содержит expect-декларации, androidMain/kotlin/com/project/platform — actual для Android, iosMain/kotlin/com/project/platform — actual для iOS. Имена файлов и пакетов должны совпадать для expect и actual, чтобы разработчик мог быстро найти соответствующую реализацию.
Интерфейсы с платформенной фабрикой — основная альтернатива expect/actual. Вместо expect-класса можно объявить интерфейс в commonMain, а concrete классы создать в платформенных модулях. Фабрика или dependency injection контейнер предоставляют правильную реализацию в runtime. Этот подход лучше подходит для тестирования, так как интерфейс можно замокать.
Dependency Injection (Koin, Kodein) — более гибкий, но менее производительный подход. DI-контейнер настраивается отдельно для каждой платформы и предоставляет платформенные зависимости в common-код. В отличие от expect/actual, инъекция происходит в runtime, что даёт возможность подменять реализации для тестирования. С другой стороны, ошибки конфигурации DI обнаруживаются только при запуске, а не на этапе компиляции.
| Подход | Проверка на этапе компиляции | Гибкость тестирования | Runtime-накладные расходы |
|---|---|---|---|
| expect/actual | Полная | Низкая (actual нельзя замокать) | Нулевые (компиляционная связь) |
| Интерфейсы + фабрика | Частичная | Высокая (можно замокать) | Минимальные (виртуальный вызов) |
| Dependency Injection | Нет (runtime) | Высокая | Средние (DI-прокси) |
Выбор между expect/actual и альтернативами зависит от контекста. Для критичной производительности (игровые движки, real-time обработка) expect/actual предпочтительнее из-за нулевых накладных расходов. Для бизнес-логики (репозитории, use-case'ы) лучше использовать интерфейсы с DI, чтобы упростить тестирование. Комбинированный подход — expect/actual для низкоуровневых платформенных операций и интерфейсы для слоя бизнес-логики — применяется в большинстве production KMM-проектов.
Често задавани въпроси
expect/actual связывает реализацию на этапе компиляции без виртуальных вызовов, а интерфейсы — в runtime. expect/actual гарантирует наличие реализации для всех платформ, интерфейсы требуют runtime-проверок.
Да, expect enum поддерживается начиная с Kotlin 1.7. Все константы в expect и actual enum должны совпадать. Разные значения констант на разных платформах — ошибка компиляции.
Компилятор выдаст ошибку для каждой платформы, где отсутствует actual-реализация. Проект не соберётся, пока для всех expect-деклараций не будут добавлены соответствующие actual-реализации.
Нет, expect и actual должны находиться в разных source set'ах. expect — в commonMain или промежуточном source set'е, actual — в платформенном source set'е. Размещение expect и actual в одном source set'е — ошибка компиляции.
Для тестирования expect/actual используйте commonTest с платформенными тестовыми source set'ами. Напишите expect-тесты в commonTest и actual-тесты для каждой платформы. Интеграционные тесты запускаются отдельно на каждой целевой платформе.
Резюме
Ще разработим мобилно приложение под ключ
IT Sectr създава iOS и Android приложения за стартъпи и бизнеси от 2017 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също