ก่อนจะเริ่มเขียน Gradle Plugin ด้วย Kotlin เราควรจะเข้าใจโครงสร้างของ Android Gradle Plugin ที่เราใช้งานใน Gradle กันอยู่ทุกวันนี้เสียก่อน

บทความในชุดเดียวกัน

ที่มาของคำสั่ง Gradle สำหรับ App Module และ Library Module

เมื่อลองดูโค้ดใน build.gradle.kts ของ App Module ก็จะเห็นว่าในนั้นมี Block ต่าง ๆ ที่ประกาศไว้ ซึ่งเป็น Extension Function ที่ Gradle Plugin แต่ละตัวได้เตรียมไว้ให้นั่นเอง

// build.gradle.kts (App Module)
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.akexorcist.sleepingforless"
    compileSdk = 33
    /* ... */ 
}
/* ... */

ยกตัวอย่างเช่น คำสั่ง android {..} จะมาจาก Plugin ที่ชื่อว่า com.android.application ที่ประกาศไว้ใน plugins {..} ดังนั้นถ้าไม่ใส่ Plugin ตัวนี้ก็จะเรียกใช้งานคำสั่งนั้นไม่ได้

และเมื่อลองกดเข้าไปดูข้างในคำสั่ง android {..} ดูก็จะพบว่าข้างในนั้นเป็นแค่ Extension Function ที่ com.android.application ได้เตรียมไว้ให้

fun org.gradle.api.Project.`android`(configure: Action<com.android.build.gradle.internal.dsl.BaseAppModuleExtension>): Unit =
    (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("android", configure)
การกดไล่โค้ดใน Gradle ที่ใช้ Kotlin DSL แบบนี้คือข้อดีอย่างนึงที่ทำใน Groovy DSL ไม่ค่อยได้ เป็นอีกเหตุผลที่เจ้าของบล็อกเลือกที่จะใช้ Kotlin DSL มากกว่า

และภายใน Block ของ android {..} ก็จะเป็น BaseAppModuleExtension เพื่อให้เราเรียกใช้คำสั่งต่าง ๆ อย่าง namespace, compileSdk หรือ defaultConfig เพื่อกำหนดค่าให้กับโปรเจคนั่นเอง

ในขณะเดียวกัน com.android.library สำหรับ Library Module ก็ทำงานในลักษณะเดียวกัน แต่จะได้เป็น LibraryExtension แทน ไม่ใช่ BaseAppModuleExtension เนื่องจากตั้งค่าบางตัวแบบ App Module ไม่ได้ (เช่น applicationId)

เมื่อลองดู BaseAppModuleExtension กับ LibraryExtension ก็จะพบว่าทั้งคู่เป็น Sub Class ของ CommonExtension เหมือนกัน

CommonExtension จะเป็น Interface ที่ประกอบไปด้วย Generic ที่เป็น Interface อีก 5 ตัวคือ BuildFeature, BuildType, DefaultConfig, ProductFlavor, และ AndroidResources

interface CommonExtension<
        BuildFeaturesT : BuildFeatures,
        BuildTypeT : BuildType,
        DefaultConfigT : DefaultConfig,
        ProductFlavorT : ProductFlavor,
        AndroidResourcesT : AndroidResources> { /* ... */ }

โดยที่ BaseAppModuleExtension กับ LibraryExtension จะกำหนด Generic ที่เป็น Interface ทั้ง 5 ตัวแตกต่างกันแบบนี้

CommonExtension BaseAppModuleExtension LibraryExtension
BuildFeatures ApplicationBuildFeatures LibraryBuildFeatures
BuildType ApplicationBuildType LibraryBuildType
DefaultConfig ApplicationDefaultConfig LibraryDefaultConfig
ProductFlavor ApplicationProductFlavor LibraryProductFlavor
AndroidResources ApplicationAndroidResources LibraryAndroidResources

ซึ่ง Interface เหล่านี้ก็คือที่มาของค่าบางส่วนที่นักพัฒนาสามารถกำหนดใน build.gradle.kts ของ App Module และ Library Module ได้นั่นเอง

// build.gradle.kts (App Module)
android {
    buildFeatures { /* ... */ }
    buildType { /* ... */ }
    defaultConfig { /* ... */ }
    productFlavor { /* ... */ }
    androidResources { /* ... */ }
}

// build.gradle.kts (Library Module)
android {
    buildFeatures { /* ... */ }
    buildType { /* ... */ }
    defaultConfig { /* ... */ }
    productFlavor { /* ... */ }
    androidResources { /* ... */ }
}

ทำให้เวลากำหนดค่าใน android {..} ของ App Module กับ Library Module จะมีค่าให้กำหนดไม่เท่ากัน เพราะค่าบางอย่างใน App Module ก็ไม่จำเป็นต้องกำหนดใน Library Module

และถ้าลองดูโค้ดข้างใน CommonExtension ก็จะพบว่ามีค่าอื่น ๆ อยู่ด้วย จึงเป็นที่มาว่าค่าบางอย่างก็กำหนดได้ทั้งใน App Module และ Library Module

// CommonExtension
interface CommonExtension</* ... */> {
    var compileSdk: Int?
    var namespace: String?
    
    val compileOptions: CompileOptions
    fun compileOptions(action: CompileOptions.() -> Unit)
    
    val jacoco: JacocoOptions
    fun jacoco(action: JacocoOptions.() -> Unit)
    
    val testCoverage: TestCoverage
    fun testCoverage(action: TestCoverage.() -> Unit)
    
    val packaging: Packaging
    fun packaging(action: Packaging.() -> Unit)
    
    val signingConfigs: NamedDomainObjectContainer<out ApkSigningConfig>
    fun signingConfigs(action: NamedDomainObjectContainer<out ApkSigningConfig>.() -> Unit)
    
    val composeOptions: ComposeOptionsfun composeOptions(action: ComposeOptions.() -> Unit)
    /* ... */
}

ดังนั้นการเข้าใจความสัมพันธ์ของคลาสต่าง ๆ ที่เกี่ยวข้องกับ CommonExtension ก็จะช่วยให้เราเข้าใจที่มาของคำสั่งสำหรับแอนดรอยด์ใน build.gradle.kts ได้ง่ายขึ้น ซึ่งจะทำให้เราสามารถเขียน Gradle Plugin เพื่อจัดการกับการทำงานได้ส่วนนี้ได้ง่ายขึ้นด้วยเช่นกัน

3rd Party Gradle Plugin ก็ทำงานในลักษณะเดียวกัน

ยกตัวอย่างเช่น Gradle Android Test Aggregation Plugin ของ gmazzo ที่ใช้รวม Test Report ของ Test Result และ Test Coverage ของทุก Module ให้กลายเป็นไฟล์เดียวกัน ที่จะมีคำสั่งของ Gradle เพื่อให้นักพัฒนากำหนดการทำงานของ Plugin ตัวนี้ผ่าน build.gradle.kts ที่อยู่ใน Project Level และ Module Level ได้

// build.gradle.kts (Project)
plugins {
    id("io.github.gmazzo.test.aggregation.coverage") version "<latest>" 
    id("io.github.gmazzo.test.aggregation.results") version "<latest>"
}

testAggregation {
    modules { include(project(":app")) }
    coverage { include("com/**/Login*") }
}

// build.gradle.kts (App Module)
android {
    /* ... */
    productFlavors {
        create("stage") { 
            aggregateTestCoverage.set(true)
        }
        create("prod") { 
            aggregateTestCoverage.set(false)
        }
    }
}

ที่ทำแบบนี้ได้ก็เพราะว่าผู้สร้าง Plugin ได้สร้าง Extension Function สำหรับ Project​ Level และ Module Level เตรียมไว้ให้แบบนี้

// https://github.com/gmazzo/gradle-android-test-aggregation-plugin/blob/main/plugin/src/main/kotlin/io/github/gmazzo/android/test/aggregation/InternalDSL.kt#L19-L24C10
internal val Project.testAggregationExtension: TestAggregationExtension
    get() = extensions.findByType()
        ?: extensions.create<TestAggregationExtension>("testAggregation").apply {
            modules.includes.finalizeValueOnRead()
            modules.excludes.finalizeValueOnRead()
        }

// https://github.com/gmazzo/gradle-android-test-aggregation-plugin/blob/main/plugin/src/main/kotlin/org/gradle/kotlin/dsl/TestAggregationDSL.kt#L24-L28
val BuildType.aggregateTestCoverage: Property<Boolean>
    get() = extensions.getByName<Property<Boolean>>(::aggregateTestCoverage.name)

val ProductFlavor.aggregateTestCoverage: Property<Boolean>
    get() = extensions.getByName<Property<Boolean>>(::aggregateTestCoverage.name)

ดังนั้นเมื่อเราดู Extension Function ที่ Plugin ตัวนี้ได้เตรียมไว้ให้ ก็จะทำให้รู้ได้ทันทีว่าคำสั่ง testAggregation ใช้ได้เฉพาะใน Project Level เท่านั้น ส่วนคำสั่ง aggregateTestCoverage สามารถใช้ใน buildType {..} หรือ productFlavor {..} ก็ได้

Android Base Plugin

โดยปกติแล้วถ้าเป็น App Module จะต้องใช้ Plugin ของ Android Gradle Plugin ที่ชื่อว่า com.android.application และถ้าเป็น Library Module ก็จะต้องใช้ com.android.library

// build.gradle.kts (App Module)
plugins {
    id("com.android.application")
}

// build.gradle.kts (Library Module)
plugins {
    id("com.android.library")
}

แต่ในการสร้าง Gradle Plugin บางครั้งก็อาจจะต้องการใช้คำสั่งของ Android Gradle Plugin โดยที่ไม่ต้องการกำหนดว่าเป็น App Module หรือ Library Module

ในกรณีนี้เราสามารถใช้ Plugin ID ที่ชื่อว่า com.android.base แทนได้ ซึ่งเป็น Base Plugin ของ Android Gradle Plugin ที่ไม่มีผลใด ๆ กับ Module ที่นำไปใช้ ซึ่งเราจะได้เห็นและเข้าใจวิธีการใช้ Plugin ID ตัวนี้ได้ในบทความถัด ๆ ไป

สรุป

Android Gradle Plugin เป็น Gradle Plugin ที่รวม Plugin และชุดคำสั่งต่าง ๆ ไว้เพื่อให้นักพัฒนาใช้งานใน Build Script ไม่ว่าจะเป็น build.gradle.kts ที่อยู่ใน App Module หรือ Library Module ก็ตาม ซึ่งเป็นส่วนสำคัญที่เราจะต้องเข้าใจเบื้องหลังของมันเล็กน้อย เพื่อใช้ในการสร้าง Gradle Plugin

เมื่อเราเข้าใจการทำงานของ Android Gradle Plugin และวิธีที่ 3rd Party Gradle Plugin ใช้ เราก็จะสามารถสร้าง Gradle Plugin ด้วย Kotlin เพื่อใช้งานในโปรเจคแอนดรอยด์ของเราเองได้ไม่ยากแล้ว