안드로이드 탐구 : build-logic & Custom Gradle Plugin

Skele·2025년 2월 26일
0
post-thumbnail

Configuring build-logic

프로젝트에 build-logic을 사용해 커스텀 플러그인을 적용해보자.

1. Create build-logic directory


먼저 build-logic 디렉토리를 만들어준다.
반드시 프로젝트 내부에 있을 필요는 없지만 Android Studio에서 보기 편하기에 프로젝트 내에 모듈로 생성하였다.

1.1. Add Settings File


모듈을 만들면 빌드 스크립트만 생성되고 settings.gradle.kts는 자동생성해주지 않는다.
build-logic을 독립적인 빌드로 관리하기 위해서는 settings.gradle.kts를 가지고 있어야한다.

settings.gradle.kts

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
    	// 여기서 생성된 libs는 
        // extensions.getByType<VersionCatalogsExtension>().named("libs") 로
        // 불러올 수 있다.
        create("libs") { 
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic" 		// 루트 프로젝트의 이름. 생성한 디렉토리와 같아야한다.
include(":convention")					// 커스텀 플러그인 등 공용 로직을 정의할 모듈 이름

1.2 Edit Settings File

build-logic을 구성해주었다면, 빌드 로직을 사용할 프로젝트에 해당 빌드를 포함시켜주어야한다.

settings.gradle.kts (Project using build-logic)

pluginManagement {
    includeBuild("build-logic") // build-logic 를 포함시켜준다
    ...
}
...

build-logic을 사용할 루트 프로젝트의 settings.gradle.kts에 포함시켜주어야 Gradle이 정상적으로 인식하여 빌드를 포함시킨다.


2. Creating Custom Plugin

build-logic 안에 Gradle API를 활용한 플러그인을 정의해 매번 의존성에 대한 정의를 할 필요 없이 해당 코드를 재사용할 수 있다.

2.1. Edit Version Catalog

컴포즈와 관련된 커스텀 플러그인을 만들것이기 때문에 필요한 라이브러리를 추가해주어야한다.
build-logic과 프로젝트 모두에서 같은 라이브러리를 사용할 것이기 때문에 프로젝트에 존재하는 버전 카탈로그에 필요한 라이브러리를 추가한다.

libs.versions.toml
[versions]
# gradle
agp = "8.7.1"
kotlin = "2.0.0"

# ksp
ksp = "2.0.20-1.0.25"

# android core
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"

# kotlinx
kotlinxSerializationCore = "1.5.1"
kotlinxImmutableCollections = "0.3.5"

# compose
composeBom = "2024.04.01"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
viewmodel-compose = "2.8.6"
lifecycleRuntimeComposeAndroid = "2.8.6"
navigation-compose = "2.8.3"

# hilt
hilt = "2.50"
hilt-navigation-compose = "1.2.0"

[libraries]
# gradle
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }

# android core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }

# kotlinx
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutableCollections" }

# android compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel-compose" }
androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }

# hilt
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }

[bundles]
android-core = [ "androidx-core-ktx", "junit", "androidx-junit", "androidx-espresso-core" ]

[plugins]
android-library = { id = "com.android.library", version.ref = "agp" }
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

# custom plugins
module-android-application = { id = "skele.android.application", version = "unspecified" }
module-android-library = { id = "skele.android.library", version = "unspecified" }
module-android-application-compose = { id = "skele.android.application.compose", version = "unspecified" }
module-android-library-compose = { id = "skele.android.library.compose", version = "unspecified" }
module-hilt = { id = "skele.android.hilt", version = "unspecified" }

2.2. Create Custom Plugin

커스텀 플러그인을 만들기에 앞서서 build-logic의 빌드 스크립트에 필요한 설정을 해주어야한다.

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    `kotlin-dsl` // Kotlin 으로 커스텀 플러그인을 만들 수 있게 해준다.
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

kotlin {
    compilerOptions {
        jvmTarget = JvmTarget.JVM_17
    }
}

dependencies {
    compileOnly(libs.android.gradlePlugin) // 안드로이드 Gradle 플러그인
    compileOnly(libs.kotlin.gradlePlugin) // 코틀린 Gradle 플러그인
}

빌드 스크립트의 설정이 끝났다면, Kotlin으로 플러그인을 작성하면 된다.
안드로이드 개발팀의 nowinandroid를 참고하여 작성하였다.

ProjectExtension.kt

간편하게 버전 카탈로그를 가져오기 위한 Extension Property를 정의한다.

import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType

val Project.libs
    get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

KotlinAndroidExtension.kt

안드로이드에서 같은 버전을 사용하고 관리하기 위한 Extension Function.

import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension

internal fun Project.configureKotlinAndroid(
    commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
    commonExtension.apply {
        defaultConfig.minSdk = 26
        compileSdk = 35

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
    }
    configure<KotlinAndroidProjectExtension>{
        compilerOptions.jvmTarget = JvmTarget.JVM_17
    }
}

AndroidComposeExtension.kt

컴포즈에 필요한 다양한 의존성을 정의한다.

import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies

internal fun Project.configureAndroidCompose(commonExtension: CommonExtension<*, *, *, *, *, *>) {
    commonExtension.apply {
        apply(plugin = "org.jetbrains.kotlin.plugin.compose")
        apply(plugin = "org.jetbrains.kotlin.plugin.serialization")

        buildFeatures {
            compose = true
        }
        composeOptions {
            kotlinCompilerExtensionVersion = "1.5.1"
        }

        dependencies {
            val bom = libs.findLibrary("androidx.compose.bom").get()
            add("implementation", platform(bom))
            add("androidTestImplementation", platform(bom))
            add("implementation", libs.findLibrary("androidx.lifecycle.runtime.ktx").get())
            add("implementation", libs.findLibrary("androidx.activity.compose").get())
            add("implementation", libs.findLibrary("androidx.ui").get())
            add("implementation", libs.findLibrary("androidx.ui-graphics").get())
            add("implementation", libs.findLibrary("androidx.ui.tooling.preview").get())
            add("implementation", libs.findLibrary("androidx.material3").get())
            add("debugImplementation", libs.findLibrary("androidx.ui.tooling").get())
            add("debugImplementation", libs.findLibrary("androidx.ui.test.manifest").get())
            add(
                "androidTestImplementation",
                libs.findLibrary("androidx.ui.test.junit4").get(),
            )
            add("implementation", libs.findLibrary("androidx.lifecycle.viewmodel").get())
            add(
                "implementation",
                libs.findLibrary("androidx.lifecycle.runtime.compose.android").get(),
            )

            // compose navigation
            add("implementation", libs.findLibrary("kotlinx.serialization.core").get())
            add("implementation", libs.findLibrary("androidx.navigation.compose").get())

            // compose material
            add("implementation", libs.findLibrary("androidx.material3").get())

            // compose immutable
            add("implementation", libs.findLibrary("kotlinx.collections.immutable").get())
        }
    }
}

AndroidApplicationComposeConventionPlugin.kt

import com.android.build.api.dsl.ApplicationExtension
import com.skele.build_logic.convention.extension.configureAndroidCompose
import com.skele.build_logic.convention.extension.configureKotlinAndroid
import com.skele.build_logic.convention.extension.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies

class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "com.android.application")
            apply(plugin = "org.jetbrains.kotlin.android")

            configure<ApplicationExtension>{
                configureKotlinAndroid(this)
                defaultConfig.targetSdk = 34
                configureAndroidCompose(this)
            }

            dependencies {
                add("implementation", libs.findBundle("android.core").get())
            }
        }
    }
}

AndroidLibraryComposeConventionPlugin.kt

import com.android.build.api.dsl.LibraryExtension
import com.skele.build_logic.convention.extension.configureAndroidCompose
import com.skele.build_logic.convention.extension.configureKotlinAndroid
import com.skele.build_logic.convention.extension.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies

class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "com.android.library")
            apply(plugin = "org.jetbrains.kotlin.android")

            configure<LibraryExtension>{
                configureKotlinAndroid(this)
                lint.targetSdk = 34
                configureAndroidCompose(this)
            }

            dependencies {
                add("implementation", libs.findBundle("android.core").get())
            }
        }
    }
}

HiltConventionPlugin.kt

import com.skele.build_logic.convention.extension.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies

class HiltConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager){
                apply(libs.findPlugin("ksp").get().get().pluginId)
                apply(libs.findPlugin("hilt").get().get().pluginId)
            }

            dependencies {
                add("ksp", libs.findLibrary("hilt.compiler").get())
                add("implementation", libs.findLibrary("hilt.android").get())
                add("implementation", libs.findLibrary("hilt.android.testing").get())
                add("implementation", libs.findLibrary("hilt.core").get())
                add("implementation", libs.findLibrary("hilt.navigation.compose").get())
            }
        }
    }
}

2.3. Define Plugin in Build Script

플러그인을 작성한 후에는 build-logic의 빌드 스크립트에 등록해주어야한다.

build.gradle.kts

gradlePlugin{
    plugins{
        register("androidApplicationConventionPlugin"){
            id = "skele.android.application"
            implementationClass = "AndroidApplicationConventionPlugin"
        }
        register("androidLibraryConventionPlugin"){
            id = "skele.android.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        register("androidApplicationComposeConventionPlugin"){
            id = "skele.android.application.compose"
            implementationClass = "AndroidApplicationComposeConventionPlugin"
        }
        register("androidLibraryComposeConventionPlugin"){
            id = "skele.android.library.compose"
            implementationClass = "AndroidLibraryComposeConventionPlugin"
        }
        register("androidHiltConventionPlugin"){
            id = "skele.android.hilt"
            implementationClass = "HiltConventionPlugin"
        }
    }
}

또한 이렇게 등록된 플러그인을 다른 모듈에서 간편하게 사용하기 위해서 버전 카탈로그에 등록한다.

# custom plugins
module-android-application = { id = "skele.android.application", version = "unspecified" }
module-android-library = { id = "skele.android.library", version = "unspecified" }
module-android-application-compose = { id = "skele.android.application.compose", version = "unspecified" }
module-android-library-compose = { id = "skele.android.library.compose", version = "unspecified" }
module-hilt = { id = "skele.android.hilt", version = "unspecified" }

2.4. Edit Build Script

커스텀 플러그인을 모듈에 적용시키기에 앞서 플러그인에 사용되는 ksp, hilt와 같은 공용 플러그인을 프로젝트에 사용하기 위해 프로젝트 레벨 빌드 스크립트에 선언을 해야한다.

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
}

2.5. Use Custom Plugin

이렇게 등록된 커스텀 플러그인은 다른 라이브러리와 같이 빌드 스크립트에 설정하여 사용할 수 있다.

plugins {
    alias(libs.plugins.module.android.application.compose)
}

원래는 dependency 아래에 있던 수많은 implementation을 플러그인으로 만듦으로써 단 한 줄로 대체할 수 있게 되었다.
같은 설정의 다른 앱 모듈을 만들더라도 수많은 의존성 코드를 작성할 필요없이 build-logic에 정의된 플러그인을 재사용하여 불필요한 반복코드를 줄일 수 있게 되는 것이다.

profile
Tireless And Restless Debugging In Source : TARDIS

0개의 댓글