Tworzenie niestandardowych wtyczek Gradle na potrzeby Androida KMP

Ten dokument zawiera wskazówki dla autorów wtyczek dotyczące prawidłowego wykrywania, obsługi i konfigurowania platformy Kotlin Multiplatform (KMP), ze szczególnym uwzględnieniem integracji z platformami Androida w projekcie KMP. W miarę rozwoju KMP zrozumienie odpowiednich punktów zaczepienia i interfejsów API, takich jak typy KotlinMultiplatformExtensionKotlinTarget oraz interfejsy integracji specyficzne dla Androida, jest niezbędne do tworzenia solidnych 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 obecny KMP, musisz sprawdzić, czy projekt korzysta z wtyczki KMP. Sprawdzoną metodą jest używanie funkcji plugins.withId() do reagowania na zastosowanie wtyczki KMP zamiast natychmiastowego sprawdzania, czy została ona zastosowana. Takie podejście zapobiega podatności wtyczki na kolejność, w jakiej wtyczki są stosowane 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 dla 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 platformy Kotlin Multiplatform

Użyj kontenera targets, aby reaktywnie konfigurować wtyczkę dla każdego miejsca 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 dotyczącej konkretnego celu

Jeśli wtyczka ma 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 miejsce docelowe.

Użyj tej opcji, jeśli wtyczka musi tylko ogólnie rozróżniać platformy (np. działać tylko na platformach 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

Wszystkie platformy docelowe Androida mają wskaźnik platformType.androidJvm, ale KMP ma 2 różne punkty integracji w zależności od używanego wtyczki Gradle do Androida: 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 KotlinMultiplatformAndroidLibraryTarget API został dodany w AGP 8.8.0, więc jeśli użytkownicy wtyczki korzystają ze starszej wersji AGP, sprawdzenie target is KotlinMultiplatformAndroidLibraryTarget może spowodować błąd ClassNotFoundException. Aby zapewnić bezpieczeństwo, przed sprawdzeniem typu miejsca docelowego sprawdź, czy AndroidPluginVersion.getCurrent(). Pamiętaj, że AndroidPluginVersion.getCurrent() wymaga wtyczki Androida do obsługi Gradle w wersji 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 KMP na Androida i jego właściwości

Wtyczka będzie wchodzić w interakcje głównie z rozszerzeniem Kotlin udostępnianym przez wtyczkę Kotlin Multiplatform oraz z rozszerzeniem Androida udostępnianym przez AGP na potrzeby platformy docelowej KMP Android. Blok android {} w rozszerzeniu Kotlin w projekcie KMP jest reprezentowany przez interfejs KotlinMultiplatformAndroidLibraryTarget, który rozszerza też KotlinMultiplatformAndroidLibraryExtension. Oznacza to, że za pomocą tego jednego obiektu możesz uzyskać dostęp do właściwości DSL dotyczących zarówno konkretnych platform docelowych, jak 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 odróżnieniu od innych wtyczek Androida (np. com.android.library lub com.android.application) wtyczka KMP Androida nie rejestruje głównego rozszerzenia DSL na poziomie projektu. Znajduje się w hierarchii docelowej KMP, aby mieć pewność, że dotyczy tylko konkretnego celu Androida zdefiniowanego w konfiguracji wieloplatformowej.

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

Wtyczki często muszą działać na bardziej szczegółowym poziomie niż tylko kompilacja. Element 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 z instrumentacją) w pluginie konwencji unikaj używania metod włączających, takich jak withDeviceTest { } czy withHostTest { }. Wywoływanie 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 nie można wywołać tych metod po raz drugi w określonym module, aby doprecyzować ustawienia, ponieważ spowoduje to błąd informujący o tym, że kompilacja została już utworzona.

Zamiast tego zalecamy użycie reaktywnego configureEach bloku w kontenerze kompilacji. Dzięki temu możesz podać konfiguracje domyślne, które będą 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 i umożliwia poszczególnym modułom wywoływanie funkcji withDeviceTest { } w celu włączenia i dalszego dostosowywania testów bez kolizji z wartościami domyślnymi.

Interakcja z interfejsem Variant API

W przypadku zadań, które wymagają konfiguracji na późnym etapie, dostępu do artefaktów (np. manifestów lub kodu bajtowego) albo możliwości włączania lub wyłączania określonych komponentów, musisz użyć interfejsu Android Variant API. W projektach KMP rozszerzenie jest typu KotlinMultiplatformAndroidComponentsExtension.

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

Użyj beforeVariants, aby kontrolować tworzenie wariantów lub ich zagnieżdżonych komponentów testowych (hostTestsdeviceTests). 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 obiektu końcowej wersji (KotlinMultiplatformAndroidVariant). W tym miejscu możesz sprawdzić rozwiązane właściwości lub zarejestrować przekształcenia artefaktów, takich jak scalony plik manifestu 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)
            }
        }
    }
}