יצירת פלאגינים מותאמים אישית של Gradle ל-Android KMP

במסמך הזה מוסבר למפתחי פלאגינים איך לזהות, להגדיר ולהשתמש בצורה נכונה ב-Kotlin Multiplatform‏ (KMP), תוך התמקדות בשילוב עם יעדי Android בפרויקט KMP. ההמלצות האלה רלוונטיות גם אם אתם יוצרים פלאגינים של קונבנציות כדי להגדיר סטנדרטים במודולים של הפרויקט, וגם אם אתם מפתחים פלאגינים לשימוש רחב יותר בקהילה. ככל ש-KMP ממשיך להתפתח, חשוב להבין את ה-hooks ואת ממשקי ה-API המתאימים – כמו KotlinMultiplatformExtension, סוגי KotlinTarget וממשקי השילוב הספציפיים ל-Android – כדי ליצור כלים חזקים ומוכנים לעתיד שפועלים בצורה חלקה בכל הפלטפורמות שמוגדרות בפרויקט מרובה פלטפורמות.

איך בודקים אם בפרויקט נעשה שימוש בפלאגין של Kotlin Multiplatform

כדי למנוע שגיאות ולוודא שהפלאגין פועל רק כש-KMP קיים, צריך לבדוק אם הפרויקט משתמש בפלאגין של KMP. השיטה המומלצת היא להשתמש ב-plugins.withId() כדי להגיב להחלת הפלאגין של KMP, במקום לבדוק אותו מיד. הגישה הריאקטיבית הזו מונעת מהפלאגין להיות רגיש לסדר שבו הפלאגינים מופעלים בסקריפטים של ה-build של המשתמש.

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

גישה למודל

נקודת הכניסה לכל ההגדרות של Kotlin Multiplatform היא התוסף 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)
        }
    }
}

תגובה ליעדים של Kotlin Multiplatform

משתמשים בקונטיינר targets כדי להגדיר את הפלאגין באופן ריאקטיבי לכל יעד שהמשתמש מוסיף.

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

החלת לוגיקה ספציפית ליעד

אם הפלאגין צריך להחיל לוגיקה רק על סוגים מסוימים של פלטפורמות, גישה נפוצה היא לבדוק את המאפיין platformType. זהו טיפוס enum שמסווג את היעד באופן כללי.

לדוגמה, אפשר להשתמש בזה אם הפלאגין צריך רק להבדיל באופן כללי (למשל, להפעיל רק ביעדים דמויי 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 */ }
                    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) */ }
                }
            }
        }
    }
}

פרטים ספציפיים ל-Android

לכל יעדי Android יש את האינדיקטור platformType.androidJvm, אבל ל-KMP יש שתי נקודות שילוב נפרדות, בהתאם לפלאגין של Android Gradle שבו משתמשים: KotlinAndroidTarget לפרויקטים שמשתמשים ב-com.android.library או ב-com.android.application, ו-KotlinMultiplatformAndroidLibraryTarget לפרויקטים שמשתמשים ב-com.android.kotlin.multiplatform.library.

ה-API של KotlinMultiplatformAndroidLibraryTarget נוסף ב-AGP 8.8.0, אז אם משתמשי הפלאגין שלכם מריצים גרסה ישנה יותר של AGP, בדיקה של target is KotlinMultiplatformAndroidLibraryTarget עלולה לגרום ל-ClassNotFoundException. כדי לעשות את זה בצורה בטוחה, צריך לבדוק את AndroidPluginVersion.getCurrent() לפני שבודקים את סוג היעד. חשוב לזכור ש-AndroidPluginVersion.getCurrent() דורש AGP בגרסה 8.3.1 ואילך.

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.KotlinPlatformType
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget

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 ->
                if (target is KotlinAndroidTarget) {
                    // Old kmp android integration using com.android.library or com.android.application
                } else if (
                    // Check platformType first, so AGP classes load only for Android targets
                    target.platformType == KotlinPlatformType.androidJvm &&
                    AndroidPluginVersion.getCurrent() >= AndroidPluginVersion(8, 8) &&
                    target is KotlinMultiplatformAndroidLibraryTarget
                ) {
                    // New kmp android integration using com.android.kotlin.multiplatform.library
                }
            }
        }
    }
}

גישה לתוסף Android KMP ולמאפיינים שלו

הפלאגין שלכם יקיים אינטראקציה בעיקר עם התוסף של Kotlin שסופק על ידי הפלאגין של Kotlin Multiplatform, ועם התוסף של Android שסופק על ידי AGP ליעד של KMP Android. הבלוק android {} בתוך התוסף של Kotlin בפרויקט KMP מיוצג על ידי הממשק KotlinMultiplatformAndroidLibraryTarget, שגם מרחיב את KotlinMultiplatformAndroidLibraryExtension. כלומר, אתם יכולים לגשת למאפייני DSL ספציפיים ליעד וספציפיים ל-Android דרך האובייקט היחיד הזה.

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

בשונה מפלאגינים אחרים של Android (כמו com.android.library או com.android.application), הפלאגין KMP ל-Android לא רושם את תוסף ה-DSL הראשי שלו ברמת הפרויקט. הוא נמצא בהיררכיית היעדים של KMP כדי לוודא שהוא חל רק על יעד Android הספציפי שמוגדר בהגדרות של ריבוי הפלטפורמות.

טיפול בקומפילציות ובקבוצות מקורות

בדרך כלל, פלאגינים צריכים לפעול ברמה פרטנית יותר מאשר רק ברמת היעד – באופן ספציפי, הם צריכים לפעול ברמת ההידור. המאפיין KotlinMultiplatformAndroidLibraryTarget מכיל מופעים של KotlinMultiplatformAndroidCompilation (לדוגמה, main,‏ hostTest, ‏ deviceTest). כל קומפילציה משויכת לקבוצות מקורות של Kotlin. פלאגינים יכולים לקיים אינטראקציה עם הממשקים האלה כדי להוסיף מקורות, יחסי תלות או להגדיר משימות קומפילציה.

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

                    }
                }
            }
        }
    }
}

הגדרת הידורים של בדיקות בפלאגינים של קונבנציות

כשמגדירים ערכי ברירת מחדל לקומפילציות של בדיקות (כמו targetSdk לבדיקות אינסטרומנטציה) בפלאגין של מוסכמות, כדאי להימנע משימוש בשיטות הפעלה כמו withDeviceTest { } או withHostTest { }. קריאה לשיטות האלה מפעילה באופן מיידי את היצירה של וריאנטים תואמים לבדיקה ב-Android ואת ההידור של כל מודול שמחיל את הפלאגין של המוסכמה, וזה לא תמיד מתאים. בנוסף, אי אפשר לקרוא לשיטות האלה פעם שנייה במודול ספציפי כדי לשפר את ההגדרות, כי פעולה כזו תגרום לשגיאה שלפיה ה-compilation כבר נוצר.

במקום זאת, מומלץ להשתמש בבלוק configureEach תגובתי בקונטיינר של האוספים. כך אתם יכולים לספק הגדרות ברירת מחדל שחלות רק אם וכאשר מודול מפעיל באופן מפורש את הידור הבדיקה:

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 {
                        it.targetSdk { version = release(34) }
                    }
                }
        }
    }
}

התבנית הזו מבטיחה שפלאגין המוסכמה יישאר עצל, ומאפשרת למודולים ספציפיים לקרוא ל-withDeviceTest { } כדי להפעיל את הבדיקות שלהם ולהתאים אותן אישית בלי להתנגש עם ברירות המחדל.

אינטראקציה עם Variant API

למשימות שדורשות הגדרה בשלב מאוחר, גישה לארטיפקטים (כמו מניפסטים או קוד בייטים) או יכולת להפעיל או להשבית רכיבים ספציפיים, צריך להשתמש ב-Android Variant API. בפרויקטים של KMP, התוסף הוא מסוג KotlinMultiplatformAndroidComponentsExtension.

התוסף רשום ברמת הפרויקט כשמחילים את הפלאגין של KMP ל-Android.

משתמשים ב-beforeVariants כדי לשלוט ביצירת וריאנטים או ברכיבי הבדיקה המקוננים שלהם (hostTests ו-deviceTests). זה המקום הנכון להשבית בדיקות באופן פרוגרמטי או לשנות את הערכים של מאפייני 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 }
            }
        }
    }
}

משתמשים ב-onVariants כדי לגשת לאובייקט הווריאנט הסופי (KotlinMultiplatformAndroidVariant). כאן אפשר לבדוק מאפיינים שנקבעו או לרשום טרנספורמציות על פריטי מידע שנוצרו בתהליך הפיתוח (artifacts), כמו המניפסט שעבר מיזוג או מחלקות הספרייה.

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.artifact.SingleArtifact.MERGED_MANIFEST)
            }
        }
    }
}