สร้าง Gradle Plugin ด้วย Kotlin เพื่อใช้งานบน Android - การสร้าง Configuration Sharing Plugin เพื่อใช้งานใน Library Module

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

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

สำหรับการขั้นตอนเริ่มต้นในการสร้าง Gradle Plugin จะอยู่ในบทความ "สร้าง Gradle Plugin ด้วย Kotlin เพื่อใช้งานบน Android - Getting Started" (แนะนำให้อ่านก่อน)

สร้าง Gradle Plugin ด้วย Kotlin เพื่อใช้งานบน Android - Getting Started
หลังจากเข้าใจโครงสร้างของ Android Gradle Plugin เบื้องต้นแล้ว สิ่งที่ต้องทำก่อนที่จะเขียน Gradle Plugin หรือ Convention Plugin ก็คือการเตรียมโปรเจคให้พร้อมเสียก่อน

มาเริ่มกันเถอะ

สมมติว่าเรามี Library Module หลายตัวที่มีโค้ด Gradle แบบนี้

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

android {
    namespace = "com.akexorcist.feature.a"
    compileSdk = 34

    defaultConfig {
        minSdk = 24

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles("consumer-rules.pro")
    }

    buildFeatures {
        viewBinding = true
    }

    buildTypes {
        debug {
            isMinifyEnabled = false
        }
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }

    kotlinOptions {
        jvmTarget = "11"
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.10.0")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

โดยเราจะย้าย Build Script บางส่วนไปไว้ใน Gradle Plugin แทน ยกเว้น

  • namespace: ที่แต่ละ Library Module จะต้องกำหนดค่าต่างกัน
  • dependencies: ที่แต่ละ Library Module อาจจะมี Dependency หรือ Library ที่ใช้งานแตกต่างกันไป หรือถ้า Library Module ทุกตัวมี Dependency ที่ใช้งานเหมือนกับบางส่วน ก็ทำเป็น Gradle Plugin แยกอีกตัวแบบบทความก่อนหน้าก็ได้เช่นกัน ไม่จำเป็นต้องรวมทั้งหมดไว้ใน Gradle Plugin ตัวเดียวเสมอไป
สร้าง Gradle Plugin ด้วย Kotlin เพื่อใช้งานบน Android - การสร้าง Dependency Sharing Plugin เพื่อใช้กับทุก Module
ในบทความนี้เราจะมาสร้าง Gradle Plugin ที่จะรวม Dependency หรือ Library ต่าง ๆ ที่ใช้บ่อย ๆ ในทุก Module ไม่ว่าจะเป็น App Module หรือ Library Module ก็ตาม

ดังนั้นเจ้าของบล็อกจึงสร้าง Gradle Plugin ชื่อว่า LibraryConventionPlugin พร้อมกับเตรียมโค้ดเบื้องต้นไว้แบบนี้

package com.akexorcist.sleepingforless.gradle

import com.android.build.api.dsl.ApplicationExtension
import com.google.firebase.perf.plugin.FirebasePerfExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies

class LibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            // Add build logic for library module here
        }
    }
}

โดยเริ่มจากการเพิ่ม Gradle Plugin ที่ต้องใช้ใน Library Module เสียก่อน

// buildSrc/src/main/kotlin/com/akexorcist/sleepingforless/gradle/LibraryConventionPlugin.kt

class LibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }
        }
    }
}

จากนั้นให้เรียกใช้ LibraryExtension เพื่อกำหนดค่าสำหรับ Library Module แบบนี้

// buildSrc/src/main/kotlin/com/akexorcist/sleepingforless/gradle/LibraryConventionPlugin.kt
/* ... */
import com.android.build.gradle.LibraryExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

class LibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<LibraryExtension> {
                compileSdk = 34

                defaultConfig {
                    minSdk = 24

                    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                    consumerProguardFiles("consumer-rules.pro")
                }

                buildFeatures {
                    viewBinding = true
                }

                buildTypes {
                    debug {
                        isMinifyEnabled = false
                    }
                    release {
                        isMinifyEnabled = true
                        proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
                    }
                }

                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_11
                    targetCompatibility = JavaVersion.VERSION_11
                }
            }
            
            tasks.withType<KotlinCompile>().configureEach {
                kotlinOptions {
                    jvmTarget = "11"
                }
            }
        }
    }
}

จะเห็นว่าคำสั่งส่วนใหญ่ที่ใช้ใน Library Module มักจะเรียกใช้งานผ่าน LibraryExtension ได้เลย จะมีแค่เพียง kotlinOptions {..} ที่ไม่ใช่คำสั่งของ LibraryExtension โดยตรง แต่เป็น Extension Function ของ KotlinJvmOptions ที่เรียกใช้งานจากในนี้ไม่ได้ จึงต้องเปลี่ยนมากำหนดค่าผ่าน Task ที่ชื่อว่า KotlinCompile แทน

จะเรียกใช้ KotlinCompile ข้างนอกหรือข้างใน LibraryExtension ก็ให้ผลเหมือนกัน เพราะกำหนดค่าโดยหา Task ที่อยู่ในโปรเจค ไม่ใช่ใน Library Module

โดยโค้ดทั้งหมดที่อยู่ใน LibraryConventionPlugin จะมีดังนี้

// buildSrc/src/main/kotlin/com/akexorcist/sleepingforless/gradle/LibraryConventionPlugin.kt

package com.akexorcist.sleepingforless.gradle

import com.android.build.gradle.LibraryExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

class LibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<LibraryExtension> {
                compileSdk = 34

                defaultConfig {
                    minSdk = 24

                    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                    consumerProguardFiles("consumer-rules.pro")
                }

                buildFeatures {
                    viewBinding = true
                }

                buildTypes {
                    debug {
                        isMinifyEnabled = false
                    }
                    release {
                        isMinifyEnabled = true
                        proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
                    }
                }

                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_11
                    targetCompatibility = JavaVersion.VERSION_11
                }
            }

            tasks.withType<KotlinCompile>().configureEach {
                kotlinOptions {
                    jvmTarget = "11"
                }
            }
        }
    }
}

และที่ขาดไปไม่ได้เลยก็คือการประกาศใน build.gradle.kts ของ buildSrc เพื่อให้รู้จัก Gradle Plugin ของเรานั่นเอง

// build.gradle.kts (buildSrc)
/* ... */
gradlePlugin {
    plugins {
        register("libraryConventionPlugin") {
            id = "akexorcist.library.convention"
            implementationClass = "com.akexorcist.sleepingforless.gradle.LibraryConventionPlugin"
        }
    }
}

เพียงเท่านี้ Gradle Plugin ตัวนี้ก็พร้อมนำไปใช้งานกับ Library Module ทั้งหมดในโปรเจคแล้ว และ Build Script ที่อยู่ใน Library Module ก็จะเหลือแค่นี้แทน

// build.gradle.kts (Library Module)
plugins {
    id("akexorcist.library.convention")
}

android {
    namespace = "com.akexorcist.feature.a"
}

dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.10.0")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

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

สรุป

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

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

แน่นอนว่าเราสามารถใช้แนวคิดนี้เพื่อทำ Gradle Plugin ที่รองรับทั้ง App Module และ Library Module ก็ได้เช่นกัน แต่ก็อย่าลืมว่า App Module มีแค่เพียงตัวเดียวในโปรเจค และมี Build Script ที่แตกต่างกับ Library Module เล็กน้อย ถ้าต้องการสร้าง Gradle Plugin เพื่อใช้ Build Script บางส่วนร่วมกัน ก็แนะนำให้ทำ Gradle Plugin แยกกันระหว่าง App Module กับ Library Module ดีกว่า แล้วทำ Function หรือ Extension Function เพื่อใช้งานโค้ดส่วนนั้นร่วมกันแทนดีกว่า