Tworzenie niestandardowych wtyczek Gradle na potrzeby Androida KMP

Ten dokument zawiera wskazówki dla autorów wtyczek dotyczące prawidłowego wykrywania i konfigurowania konfiguracji Kotlin Multiplatform (KMP) oraz interakcji z nią, ze szczególnym naciskiem na integrację z elementami docelowymi Androida w projekcie KMP. Te zalecenia dotyczą zarówno tworzenia wtyczek konwencji, które mają na celu standaryzację konfiguracji w modułach projektu, jak i tworzenia wtyczek do użytku przez szerszą społeczność. W miarę rozwoju KMP zrozumienie odpowiednich punktów zaczepienia i interfejsów API, takich jak KotlinMultiplatformExtension, typy KotlinTarget i interfejsy integracji specyficzne dla Androida, jest niezbędne do tworzenia niezawodnych i przyszłościowych narzędzi, które działają bezproblemowo na wszystkich platformach zdefiniowanych w projekcie wieloplatformowym.

Sprawdzanie, czy projekt korzysta z wtyczki Kotlin Multiplatform

Aby uniknąć błędów i mieć pewność, że wtyczka działa tylko wtedy, gdy jest dostępna KMP, musisz sprawdzić, czy projekt korzysta z wtyczki KMP. Najlepiej jest użyć plugins.withId(), aby reagować na zastosowanie wtyczki KMP, zamiast sprawdzać ją od razu. To podejście reaktywne zapobiega podatności wtyczki na kolejność stosowania wtyczek w skryptach kompilacji użytkownika.

import org.gradle.api.Plugin
import org.gradle.api.Project

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
            // The KMP plugin is applied, you can now configure your KMP integration.
        }
    }
}

Dostęp do modelu

Punktem wejścia do wszystkich konfiguracji Kotlin Multiplatform jest rozszerzenie KotlinMultiplatformExtension.

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
            val kmpExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
        }
    }
}

Reagowanie na elementy docelowe Kotlin Multiplatform

Użyj kontenera targets, aby reaktywnie skonfigurować wtyczkę dla każdego elementu docelowego dodanego przez użytkownika.

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
            val kmpExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
            kmpExtension.targets.configureEach { target ->
                // 'target' is an instance of KotlinTarget
                val targetName = target.name // for example, "android", "iosX64", "jvm"
                val platformType = target.platformType // for example, androidJvm, jvm, native, js
            }
        }
    }
}

Stosowanie logiki specyficznej dla elementu docelowego

Jeśli wtyczka musi stosować logikę tylko do określonych typów platform, powszechnym rozwiązaniem jest sprawdzenie właściwości platformType. Jest to wyliczenie, które ogólnie kategoryzuje element docelowy.

Użyj go na przykład, jeśli wtyczka musi rozróżniać tylko ogólnie (np. działać tylko na elementach docelowych podobnych do JVM):

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
            val kmpExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
            kmpExtension.targets.configureEach { target ->
                when (target.platformType) {
                    KotlinPlatformType.jvm -> { /* Standard JVM or Android */ }
                    KotlinPlatformType.androidJvm -> { /* Android */ }
                    KotlinPlatformType.js -> { /* JavaScript */ }
                    KotlinPlatformType.native -> { /* Any Native (iOS, Linux, Windows, etc.) */ }
                    KotlinPlatformType.wasm -> { /* WebAssembly */ }
                    KotlinPlatformType.common -> { /* Metadata target (rarely needs direct plugin interaction) */ }
                }
            }
        }
    }
}

Szczegóły dotyczące Androida

Chociaż wszystkie elementy docelowe Androida mają wskaźnik platformType.androidJvm, KMP ma 2 różne punkty integracji w zależności od używanej wtyczki Androida do obsługi Gradle: KotlinAndroidTarget w przypadku projektów korzystających z com.android.library lub com.android.application oraz KotlinMultiplatformAndroidLibraryTarget w przypadku projektów korzystających z com.android.kotlin.multiplatform.library.

Interfejs API KotlinMultiplatformAndroidLibraryTarget został dodany w AGP 8.8.0, więc jeśli użytkownicy wtyczki korzystają z niższej wersji AGP, sprawdzenie target is KotlinMultiplatformAndroidLibraryTarget może spowodować wyjątek ClassNotFoundException. Aby to zabezpieczyć, przed sprawdzeniem typu elementu docelowego sprawdź AndroidPluginVersion.getCurrent(). Pamiętaj, że AndroidPluginVersion.getCurrent() wymaga AGP 7.1 lub nowszej.

import com.android.build.api.AndroidPluginVersion
import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("com.android.kotlin.multiplatform.library") {
            val kmpExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
            kmpExtension.targets.configureEach { target ->
                if (target is KotlinAndroidTarget) {
                    // Old kmp android integration using com.android.library or com.android.application
                }
                if (AndroidPluginVersion.getCurrent() >= AndroidPluginVersion(8, 8) &&
                    target is KotlinMultiplatformAndroidLibraryTarget
                ) {
                    // New kmp android integration using com.android.kotlin.multiplatform.library
                }
            }
        }
    }
}

Dostęp do rozszerzenia Android KMP i jego właściwości

Wtyczka będzie przede wszystkim współpracować z rozszerzeniem Kotlin udostępnianym przez wtyczkę Kotlin Multiplatform oraz z rozszerzeniem Androida udostępnianym przez AGP dla elementu docelowego Android KMP. Blok android {} w rozszerzeniu Kotlin w projekcie KMP jest reprezentowany przez interfejs KotlinMultiplatformAndroidLibraryTarget, który rozszerza też KotlinMultiplatformAndroidLibraryExtension. Oznacza to, że za pomocą tego pojedynczego obiektu możesz uzyskać dostęp do właściwości DSL specyficznych dla elementu docelowego i Androida.

import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("com.android.kotlin.multiplatform.library") {
            val kmpExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)

            // Access the Android target, which also serves as the Android-specific DSL extension
            kmpExtension.targets.withType(KotlinMultiplatformAndroidLibraryTarget::class.java).configureEach { androidTarget ->

                // You can now access properties and methods from both
                // KotlinMultiplatformAndroidLibraryTarget and KotlinMultiplatformAndroidLibraryExtension
                androidTarget.compileSdk = 34
                androidTarget.namespace = "com.example.myplugin.library"
                androidTarget.withJava() // enable Java sources
            }
        }
    }
}

W przeciwieństwie do innych wtyczek Androida (takich jak com.android.library czy com.android.application) wtyczka KMP na Androida nie rejestruje swojego głównego rozszerzenia DSL na poziomie projektu. Znajduje się ono w hierarchii elementów docelowych KMP, aby mieć pewność, że będzie stosowane tylko do konkretnego elementu docelowego Androida zdefiniowanego w konfiguracji wieloplatformowej.

Obsługa kompilacji i zestawów źródeł

Często wtyczki muszą działać na bardziej szczegółowym poziomie niż tylko element docelowy – konkretnie na poziomie kompilacji. KotlinMultiplatformAndroidLibraryTarget zawiera instancje KotlinMultiplatformAndroidCompilation (np. main, hostTest, deviceTest). Każda kompilacja jest powiązana z zestawami źródeł Kotlin. Wtyczki mogą wchodzić z nimi w interakcje, aby dodawać źródła, zależności lub konfigurować zadania kompilacji.

import com.android.build.api.dsl.KotlinMultiplatformAndroidCompilation
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("com.android.kotlin.multiplatform.library") {
            val kmpExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
            kmpExtension.targets.configureEach { target ->
                target.compilations.configureEach { compilation ->
                    // standard compilations are usually 'main' and 'test'
                    // android target has 'main', 'hostTest', 'deviceTest'
                    val compilationName = compilation.name

                    // Access the default source set for this compilation
                    val defaultSourceSet = compilation.defaultSourceSet

                    // Access the Android-specific compilation DSL
                    if (compilation is KotlinMultiplatformAndroidCompilation) {

                    }

                    // Access and configure the Kotlin compilation task
                    compilation.compileTaskProvider.configure { compileTask ->

                    }
                }
            }
        }
    }
}

Konfigurowanie kompilacji testowych we wtyczkach konwencji

Podczas konfigurowania wartości domyślnych dla kompilacji testowych (np. targetSdk w przypadku testów instrumentowanych) we wtyczce konwencji należy unikać używania metod włączających, takich jak withDeviceTest { } czy withHostTest { }. Wywołanie tych metod powoduje natychmiastowe utworzenie odpowiednich wariantów testów na Androida i kompilacji dla każdego modułu, który stosuje wtyczkę konwencji, co może być nieodpowiednie. Ponadto tych metod nie można wywołać po raz drugi w konkretnym module, aby doprecyzować ustawienia, ponieważ spowoduje to zgłoszenie błędu informującego o tym, że kompilacja została już utworzona.

Zamiast tego zalecamy używanie reaktywnego bloku configureEach w kontenerze kompilacji. Pozwala to na podanie konfiguracji domyślnych, które są stosowane tylko wtedy, gdy moduł wyraźnie włączy kompilację testową:

import com.android.build.api.dsl.KotlinMultiplatformAndroidDeviceTestCompilation
import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("com.android.kotlin.multiplatform.library") {
            val kmpExtension =
                project.extensions.getByType(KotlinMultiplatformExtension::class.java)
            kmpExtension.targets.withType(KotlinMultiplatformAndroidLibraryTarget::class.java)
                .configureEach { androidTarget ->
                    androidTarget.compilations.withType(
                        KotlinMultiplatformAndroidDeviceTestCompilation::class.java
                    ).configureEach {
                        targetSdk { version = release(34) }
                    }
                }
        }
    }
}

Ten wzorzec zapewnia, że wtyczka konwencji pozostaje leniwa, a poszczególne moduły mogą wywoływać withDeviceTest { }, aby włączyć i dodatkowo dostosować testy bez konfliktu z ustawieniami domyślnymi.

Interakcja z interfejsem Variant API

W przypadku zadań, które wymagają konfiguracji na późnym etapie, dostępu do artefaktów (takich jak manifesty lub kod bajtowy) albo możliwości włączania lub wyłączania określonych komponentów, musisz użyć interfejsu Android Variant API. W projektach KMP rozszerzenie ma typ KotlinMultiplatformAndroidComponentsExtension.

Rozszerzenie jest rejestrowane na poziomie projektu po zastosowaniu wtyczki KMP na Androida.

Użyj beforeVariants, aby kontrolować tworzenie wariantów lub ich zagnieżdżonych komponentów testowych (hostTests i deviceTests). Jest to odpowiednie miejsce, aby programowo wyłączyć testy lub zmienić wartości właściwości DSL.

import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("com.android.kotlin.multiplatform.library") {
            val androidComponents = project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)
            androidComponents?.beforeVariants { variantBuilder ->
                // Disable all tests for this module
                variantBuilder.hostTests.values.forEach { it.enable = false }
                variantBuilder.deviceTests.values.forEach { it.enable = false }
            }
        }
    }
}

Użyj onVariants, aby uzyskać dostęp do końcowego obiektu wariantu (KotlinMultiplatformAndroidVariant). Możesz tu sprawdzić rozwiązane właściwości lub zarejestrować przekształcenia artefaktów, takich jak manifest łączony lub klasy biblioteki.

import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withId("com.android.kotlin.multiplatform.library") {
            val androidComponents = project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)
            androidComponents?.onVariants { variant ->
                // 'variant' is a KotlinMultiplatformAndroidVariant
                val variantName = variant.name

                // Access the artifacts API
                val manifest = variant.artifacts.get(com.android.build.api.variant.SingleArtifact.MERGED_MANIFEST)
            }
        }
    }
}