Créer des plug-ins Gradle personnalisés pour Android KMP

Ce document fournit aux auteurs de plug-ins un guide sur la façon de détecter, d'utiliser et de configurer correctement la configuration Kotlin Multiplatform (KMP), en mettant l'accent sur l'intégration aux cibles Android dans un projet KMP. Comme KMP continue d'évoluer, il est essentiel de comprendre les hooks et les API appropriés, tels que les types KotlinMultiplatformExtension et KotlinTarget, ainsi que les interfaces d'intégration spécifiques à Android, pour créer des outils robustes et évolutifs qui fonctionnent de manière transparente sur toutes les plates-formes définies dans un projet multiplate-forme.

Vérifier si un projet utilise le plug-in Kotlin Multiplatform

Pour éviter les erreurs et vous assurer que votre plug-in ne s'exécute que lorsque KMP est présent, vous devez vérifier si le projet utilise le plug-in KMP. Il est recommandé d'utiliser plugins.withId() pour réagir à l'application du plug-in KMP, plutôt que de le vérifier immédiatement. Cette approche réactive empêche votre plug-in d'être fragile à l'ordre dans lequel les plug-ins sont appliqués dans les scripts de compilation de l'utilisateur.

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.
        }
    }
}

Accéder au modèle

Le point d'entrée de toutes les configurations Kotlin Multiplatform est l'extension 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)
        }
    }
}

Réagir aux cibles Kotlin Multiplatform

Utilisez le conteneur targets pour configurer de manière réactive votre plug-in pour chaque cible ajoutée par l'utilisateur.

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
            }
        }
    }
}

Appliquer une logique spécifique à la cible

Si votre plug-in doit appliquer une logique uniquement à certains types de plates-formes, une approche courante consiste à vérifier la propriété platformType. Il s'agit d'un enum qui catégorise globalement la cible.

Par exemple, utilisez cette option si votre plug-in n'a besoin que d'une différenciation générale (par exemple, s'exécuter uniquement sur des cibles de type 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) */ }
                }
            }
        }
    }
}

Informations spécifiques à Android

Alors que toutes les cibles Android comportent l'indicateur platformType.androidJvm, KMP dispose de deux points d'intégration distincts en fonction du plug-in Android Gradle utilisé : KotlinAndroidTarget pour les projets utilisant com.android.library ou com.android.application, et KotlinMultiplatformAndroidLibraryTarget pour les projets utilisant com.android.kotlin.multiplatform.library.

L'API KotlinMultiplatformAndroidLibraryTarget a été ajoutée dans AGP 8.8.0. Par conséquent, si les consommateurs de votre plug-in exécutent une version antérieure d'AGP, la vérification de target is KotlinMultiplatformAndroidLibraryTarget peut entraîner une ClassNotFoundException. Pour que cela soit sûr, vérifiez AndroidPluginVersion.getCurrent() avant de vérifier le type de cible. Notez que AndroidPluginVersion.getCurrent() nécessite AGP 7.1 ou version ultérieure.

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
                }
            }
        }
    }
}

Accéder à l'extension Android KMP et à ses propriétés

Votre plug-in interagira principalement avec l'extension Kotlin fournie par le plug-in Kotlin Multiplatform et l'extension Android fournie par AGP pour la cible Android KMP. Le bloc android {} de l'extension Kotlin dans un projet KMP est représenté par l'interface KotlinMultiplatformAndroidLibraryTarget, qui étend également KotlinMultiplatformAndroidLibraryExtension. Cela signifie que vous pouvez accéder aux propriétés DSL spécifiques à la cible et à Android via cet objet unique.

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
            }
        }
    }
}

Contrairement aux autres plug-ins Android (tels que com.android.library ou com.android.application), le plug-in Android KMP n'enregistre pas son extension DSL principale au niveau du projet. Il se trouve dans la hiérarchie cible KMP pour s'assurer qu'il ne s'applique qu'à la cible Android spécifique définie dans votre configuration multiplate-forme.

Gérer les compilations et les ensembles de sources

Souvent, les plug-ins doivent fonctionner à un niveau plus précis que la simple cible, en particulier au niveau de la compilation. KotlinMultiplatformAndroidLibraryTarget contient des instances KotlinMultiplatformAndroidCompilation (par exemple, main, hostTest, deviceTest). Chaque compilation est associée à des ensembles de sources Kotlin. Les plug-ins peuvent interagir avec ces éléments pour ajouter des sources ou des dépendances, ou configurer des tâches de compilation.

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 ->

                    }
                }
            }
        }
    }
}

Configurer les compilations de test dans les plug-ins de convention

Lorsque vous configurez des valeurs par défaut pour les compilations de test (telles que targetSdk pour les tests instrumentés) dans un plug-in de convention, vous devez éviter d'utiliser des méthodes d'activation telles que withDeviceTest { } ou withHostTest { }. L'appel de ces méthodes déclenche la création des variantes de test et des compilations Android correspondantes pour chaque module qui applique le plug-in de convention, ce qui peut ne pas être approprié. De plus, ces méthodes ne peuvent pas être appelées une deuxième fois dans un module spécifique pour affiner les paramètres, car cela générera une erreur indiquant que la compilation a déjà été créée.

Nous vous recommandons plutôt d'utiliser un bloc configureEach réactif sur le conteneur de compilations. Cela vous permet de fournir des configurations par défaut qui ne s'appliquent que si et quand un module active explicitement la compilation de test :

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) }
                    }
                }
        }
    }
}

Ce modèle garantit que votre plug-in de convention reste différé et permet aux modules individuels d'appeler withDeviceTest { } pour activer et personnaliser davantage leurs tests sans entrer en conflit avec les valeurs par défaut.

Interagir avec l'API Variant

Pour les tâches qui nécessitent une configuration en phase finale, un accès aux artefacts (comme les fichiers manifestes ou le bytecode) ou la possibilité d'activer ou de désactiver des composants spécifiques, vous devez utiliser l'API Android Variant. Dans les projets KMP, l'extension est de type KotlinMultiplatformAndroidComponentsExtension.

L'extension est enregistrée au niveau du projet lorsque le plug-in KMP Android est appliqué.

Utilisez beforeVariants pour contrôler la création de variantes ou de leurs composants de test imbriqués (hostTests et deviceTests). C'est l'endroit idéal pour désactiver les tests par programmation ou modifier les valeurs des propriétés 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 }
            }
        }
    }
}

Utilisez onVariants pour accéder à l'objet de variante final (KotlinMultiplatformAndroidVariant). C'est là que vous pouvez inspecter les propriétés résolues ou enregistrer des transformations sur des artefacts tels que le fichier manifeste fusionné ou les classes de bibliothèque.

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)
            }
        }
    }
}