expect/actual — mechanizm Kotlin Multiplatform, który pozwala deklarować API zależne od platformy w kodzie wspólnym. Słowo kluczowe expect tworzy kontrakt funkcji, klasy lub właściwości w commonMain, a słowo kluczowe actual dostarcza konkretną implementację dla każdej platformy. Kompilator sprawdza, czy dla każdej deklaracji expect istnieje implementacja actual na wszystkich docelowych platformach. Według danych JetBrains, 2025, mechanizm jest używany w 80% projektów KMM do implementacji platformowej logiki biznesowej.
Najważniejsze
expect/actual — to deklaratywny mechanizm Kotlin Multiplatform do implementacji programowania zorientowanego na platformę. Pozwala opisać API raz w module wspólnym (expect) i zaimplementować je osobno dla każdej platformy (actual). W przeciwieństwie do interfejsów, expect/actual nie tworzy wywołań wirtualnych — kompilator wiąże deklaracje expect i actual na etapie kompilacji, co eliminuje narzuty na dynamiczną dyspozycję.
Historia expect/actual rozpoczęła się wraz z pojawieniem się Kotlin Multiplatform w 2017 roku. Pierwotnie mechanizm nazywał się expect/actual declarations i był eksperymentalny. W Kotlin 1.2 dodano adnotacje expect, a w Kotlin 1.3 expect/actual stał się stabilny dla klas i funkcji. Stopniowo mechanizm był rozszerzany: w Kotlin 1.6 dodano obsługę expect/actual dla obiektów companion, w Kotlin 1.7 — dla klas enum, a w Kotlin 2.0 — dla typealias.
Kluczowa cecha expect/actual — bezpieczeństwo na poziomie kompilacji. Jeśli programista dodał deklarację expect w commonMain, ale zapomniał dostarczyć implementacji actual dla iOS, kompilator zgłosi błąd. Zapobiega to błędom runtime, charakterystycznym dla podejść z refleksją lub dynamicznym ładowaniem kodu platformowego.
Mechanizm expect/actual działa na poziomie source set — systemu modułów Kotlin Multiplatform. Kod wspólny, dostępny dla wszystkich platform, znajduje się w source set commonMain. Kod zależny od platformy — w iosMain, androidMain, macosMain i tak dalej. Słowo kluczowe expect w commonMain deklaruje API, a słowo kluczowe actual w platformowym source set dostarcza implementację. Kompilator wiąże je na etapie generowania kodu, zastępując wywołanie funkcji expect odpowiednią implementacją actual dla docelowej platformy.
Hierarchia source set w typowym projekcie KMM wygląda następująco: commonMain zawiera deklaracje expect, iosMain i androidMain zawierają implementacje actual. Podczas kompilacji dla iOS używane jest actual z iosMain, podczas kompilacji dla Android — z androidMain. Source set mogą być pośrednie (na przykład iosArm64Main dla konkretnej architektury), co pozwala doprecyzować implementacje dla różnych urządzeń.
// commonMain — deklaracja expect
expect fun getPlatformName(): String
// androidMain — actual dla Android
actual fun getPlatformName(): String = "Android"
// iosMain — actual dla iOS
actual fun getPlatformName(): String = "iOS"
Kompilator Kotlin sprawdza kilka warunków podczas pracy z expect/actual. Każda deklaracja expect musi mieć implementację actual dla każdej aktywnej platformy. Sygnatura deklaracji actual musi zgadzać się z sygnaturą expect (adnotacja @OptionalExpectation może łagodzić to wymaganie). Modyfikatory dostępu, typ zwracany i parametry muszą być identyczne. Kompilator sprawdza również brak cyklicznych zależności między deklaracjami expect i actual.
expect/actual obsługuje kilka typów deklaracji. Najczęściej używane są funkcje expect/actual do operacji platformowych, klasy expect/actual dla obiektów wymagających natywnej implementacji oraz właściwości expect/actual dla stałych i ustawień. Każdy typ ma swoje zasady użycia i ograniczenia.
Funkcje expect/actual — najprostszy i najczęstszy typ. Są używane do wywoływania platformowych API, takich jak pobieranie czasu, odczytywanie plików czy wysyłanie zapytań HTTP. Klasy expect/actual są stosowane do tworzenia obiektów, które bezpośrednio współdziałają z kodem natywnym (na przykład do dostępu do kamery, geolokalizacji lub magazynu kluczy). Właściwości expect/actual (val) nadają się do stałych platformowych — nazwy systemu operacyjnego, wersji SDK lub ścieżki do katalogu systemowego.
| Typ deklaracji | Słowa kluczowe | Przykład użycia |
|---|---|---|
| Funkcja | expect fun / actual fun | Pobieranie unikalnego identyfikatora urządzenia |
| Klasa | expect class / actual class | Dostęp do SecureStorage (Keychain / EncryptedSharedPreferences) |
| Właściwość | expect val / actual val | Bieżąca platforma (iOS / Android) |
| Klasa enum | expect enum / actual enum | Lista dostępnych uprawnień aplikacji |
| Typealias | expect typealias / actual typealias | Typ odpowiedzi sieciowej specyficzny dla platformy |
Nie wszystkie konstrukcje Kotlin mogą być użyte z expect/actual. Deklaracja expect nie może zawierać ciała — tylko sygnaturę. Klasa expect nie może mieć konstruktora z parametrami (musi mieć pusty konstruktor podstawowy). Dla enum expect/actual wszystkie stałe muszą być identyczne w expect i actual. Właściwości expect muszą być val (nie var), ponieważ przechowywanie stanu w module wspólnym dla właściwości platformowych nie ma sensu.
Rozważmy praktyczne przykłady expect/actual od prostych funkcji po pełnoprawne klasy. Podstawowy przypadek — pobieranie nazwy platformy do użycia w interfejsie. Bardziej złożone przykłady obejmują dostęp do natywnego magazynu i pracę z platformowymi wątkami.
// commonMain — klasa expect dla bezpiecznego przechowywania
expect class PlatformStorage {
fun save(key: String, value: String)
fun get(key: String): String?
fun remove(key: String)
}
// androidMain — actual na Androidzie
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() }
}
W tym przykładzie klasa expect PlatformStorage określa kontrakt prostego magazynu klucz-wartość. Na Androidzie implementacja używa SharedPreferences, a na iOS — Keychain lub NSUserDefaults. Dzięki expect/actual logika biznesowa w commonMain wywołuje save/get/remove, nie znając platformowej implementacji.
// iosMain — actual na iOS z 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)
}
}
Projektując API expect/actual, należy przestrzegać kilku zasad. Minimalizuj liczbę deklaracji expect — im więcej wspólnego kodu, tym łatwiejsze utrzymanie. Używaj expect/actual tylko dla API, które rzeczywiście różnią się na platformach. Dla pozostałego kodu stosuj interfejsy z fabrykami lub wstrzykiwaniem zależności, co upraszcza testowanie.
Zaleca się grupowanie deklaracji expect według modułów tematycznych, a nie mieszanie ich w jednym pliku. Na przykład Storage.kt dla deklaracji expect magazynu, Platform.kt dla funkcji expect pracy z systemem operacyjnym i Analytics.kt dla klas expect analityki. Upraszcza to nawigację i zrozumienie platformowej powierzchni projektu KMM. Każdy plik actual powinien znajdować się w odpowiednim source set: androidMain, iosMain, desktopMain i tak dalej.
Implementacje domyślne przez expect fun z actual fun, gdzie actual używa kodu wspólnego — to częsty antywzorzec. Jeśli platformowa implementacja nie różni się od domyślnej, expect/actual nie jest potrzebny. W takich przypadkach używaj prostej funkcji w commonMain. Unikaj również expect/actual dla trywialnych getterów — używaj expect val ze stałymi.
Prawidłowa struktura kodu expect/actual jest krytyczna dla czytelności projektu. Każdy moduł expect/actual powinien mieć jeden punkt wejścia. Przykład organizacji: commonMain/kotlin/com/project/platform zawiera deklaracje expect, androidMain/kotlin/com/project/platform — actual dla Androida, iosMain/kotlin/com/project/platform — actual dla iOS. Nazwy plików i pakietów powinny być zgodne dla expect i actual, aby programista mógł szybko znaleźć odpowiednią implementację.
Interfejsy z platformową fabryką — główna alternatywa dla expect/actual. Zamiast klasy expect można zadeklarować interfejs w commonMain, a konkretne klasy utworzyć w modułach platformowych. Fabryka lub kontener wstrzykiwania zależności dostarcza odpowiednią implementację w runtime. To podejście lepiej nadaje się do testowania, ponieważ interfejs można zamockować.
Wstrzykiwanie zależności (Koin, Kodein) — bardziej elastyczne, ale mniej wydajne podejście. Kontener DI jest konfigurowany oddzielnie dla każdej platformy i dostarcza platformowe zależności do wspólnego kodu. W przeciwieństwie do expect/actual, wstrzyknięcie następuje w runtime, co umożliwia podmianę implementacji do testowania. Z drugiej strony, błędy konfiguracji DI są wykrywane dopiero przy uruchomieniu, a nie na etapie kompilacji.
| Podejście | Sprawdzenie na etapie kompilacji | Elastyczność testowania | Narzut runtime |
|---|---|---|---|
| expect/actual | Pełne | Niska (actual nie można zamockować) | Zerowy (wiązanie kompilacyjne) |
| Interfejsy + fabryka | częściowe | Wysoka (można zamockować) | Minimalne (wywołanie wirtualne) |
| Wstrzykiwanie zależności | Nie (runtime) | Wysoka | Średnie (proxy DI) |
Wybór między expect/actual a alternatywami zależy od kontekstu. Dla krytycznej wydajności (silniki gier, przetwarzanie w czasie rzeczywistym) expect/actual jest preferowany ze względu na zerowy narzut runtime. Dla logiki biznesowej (repozytoria, przypadki użycia) lepiej stosować interfejsy z DI, aby uprościć testowanie. Podejście łączone — expect/actual dla niskopoziomowych operacji platformowych i interfejsy dla warstwy logiki biznesowej — jest stosowane w większości produkcyjnych projektów KMM.
Często zadawane pytania
expect/actual wiąże implementację na etapie kompilacji bez wywołań wirtualnych, a interfejsy — w runtime. expect/actual gwarantuje istnienie implementacji dla wszystkich platform, interfejsy wymagają sprawdzeń w runtime.
Tak, expect enum jest obsługiwany od Kotlin 1.7. Wszystkie stałe w expect i actual enum muszą być zgodne. Różne wartości stałych na różnych platformach — błąd kompilacji.
Kompilator zgłosi błąd dla każdej platformy, dla której brakuje implementacji actual. Projekt nie zostanie zbudowany, dopóki dla wszystkich deklaracji expect nie zostaną dodane odpowiednie implementacje actual.
Nie, expect i actual muszą znajdować się w różnych source set. expect — w commonMain lub pośrednim source set, actual — w platformowym source set. Umieszczenie expect i actual w jednym source set to błąd kompilacji.
Do testowania expect/actual używaj commonTest z platformowymi testowymi source set. Napisz testy expect w commonTest i testy actual dla każdej platformy. Testy integracyjne uruchamiane są osobno na każdej docelowej platformie.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również