Kotlin-DSL을 활용하여 Dependency 관리하기 - (2)
이전 포스팅에서 Kotlin-DSL Gradle 설정하는 법 까지 진행 했다.
이를 활용해서 Dependency를 관리 하는법을 알아보자
Kotlin-DSL을 활용하여 Dependency 관리하기 - (1)
build.gradle.kts (:buildSrc)
gradle 파일 외부에서 kotlin 언어의 기능을 사용하기 위해서는 먼저 buildSrc라는 디렉토리를 만든다.
project 단에서 buildSrc라는 폴더를 만든 후 하위 디렉토리에 build.gradle.kts 파일을 만든 후 'sync now'를 누른다.
// project/buildSrc/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
jcenter()
}
plugins을 작성하지 않으면 다른 gradle에서 해당 디렉토리 하위에 있는 코드를 인식할 수 없다.
이후 buildSrc/src/main/java 디렉토리를 만들어주고 Version 과 Libs 파일을 만든다.
제대로 진행하고 있다면 위 이미지와 같은 구성이 된다.
Dependency를 Kotlin 코드로 관리 하기
internal object Versions {
const val KOTLIN_STDLIB = "1.3.72"
const val ANDROID_CORE_KTX = "1.3.2"
const val ANDROID_APPCOMPAT = "1.2.0"
const val ANDROID_MATERIAL = "1.3.0"
const val ANDROID_CONSTRAINT = "2.0.4"
const val TEST_JUNIT = "4.+"
const val ANDROID_TEST_JUNIT = "1.1.2"
const val ANDROID_TEST_ESPRESSO = "3.3.0"
}
object Libs {
const val KOTLIN_STDLIB = "org.jetbrains.kotlin:kotlin-stdlib:${Versions.KOTLIN_STDLIB}"
const val ANDROID_CORE_KTX = "androidx.core:core-ktx:${Versions.ANDROID_CORE_KTX}"
const val ANDROID_APPCOMPAT = "androidx.appcompat:appcompat:${Versions.ANDROID_APPCOMPAT}"
const val ANDROID_MATERIAL = "com.google.android.material:material:${Versions.ANDROID_MATERIAL}"
const val ANDROID_CONSTRAINT = "androidx.constraintlayout:constraintlayout:${Versions.ANDROID_CONSTRAINT}"
const val TEST_JUNIT = "junit:junit:${Versions.TEST_JUNIT}"
const val ANDROID_TEST_JUNIT = "androidx.test.ext:junit:${Versions.ANDROID_TEST_JUNIT}"
const val ANDROID_TEST_ESPRESSO = "androidx.test.espresso:espresso-core:${Versions.ANDROID_TEST_ESPRESSO}"
}
plugins {
id("com.android.application")
id("kotlin-android")
}
android {
compileSdkVersion(29)
buildToolsVersion("29.0.3")
defaultConfig {
applicationId = "com.munny.kotlindslsample"
minSdkVersion(23)
targetSdkVersion(29)
versionCode(1)
versionName("1.0")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("release") {
minifyEnabled(false)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}
dependencies {
implementation(Libs.KOTLIN_STDLIB)
implementation(Libs.ANDROID_CORE_KTX)
implementation(Libs.ANDROID_APPCOMPAT)
implementation(Libs.ANDROID_MATERIAL)
implementation(Libs.ANDROID_CONSTRAINT)
testImplementation(Libs.TEST_JUNIT)
androidTestImplementation(Libs.ANDROID_TEST_JUNIT)
androidTestImplementation(Libs.ANDROID_TEST_ESPRESSO)
}
상수로 작성된 dependency들을 kotlin 코드로 옮겼다.
이렇게 작성된 코드를 보고 이런 생각을 가졌다.
똑같은 implementation이 반복되고 있으니 반복문으로 처리할 수 있지 않을까?
그래서 비슷한 성격을 가진 Dependency끼리 묶어주기로 했다.
라이브러리 단위로 클래스 묶기
interface Libs {
fun getDependencies(): List<String>
object Kotlin : Libs {
private const val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib:${Versions.KOTLIN_STDLIB}"
override fun getDependencies() = listOf(
STDLIB
)
}
object Android : Libs {
private const val CORE_KTX = "androidx.core:core-ktx:${Versions.ANDROID_CORE_KTX}"
private const val APPCOMPAT = "androidx.appcompat:appcompat:${Versions.ANDROID_APPCOMPAT}"
private const val MATERIAL = "com.google.android.material:material:${Versions.ANDROID_MATERIAL}"
private const val CONSTRAINT = "androidx.constraintlayout:constraintlayout:${Versions.ANDROID_CONSTRAINT}"
override fun getDependencies() = listOf(
CORE_KTX,
APPCOMPAT,
MATERIAL,
CONSTRAINT
)
}
object Test : Libs {
private const val JUNIT = "junit:junit:${Versions.TEST_JUNIT}"
override fun getDependencies() = listOf(
JUNIT
)
}
object AndroidTest : Libs {
private const val JUNIT = "androidx.test.ext:junit:${Versions.ANDROID_TEST_JUNIT}"
private const val ESPRESSO = "androidx.test.espresso:espresso-core:${Versions.ANDROID_TEST_ESPRESSO}"
override fun getDependencies() = listOf(
JUNIT,
ESPRESSO
)
}
}
// build.gradle.kts (:app)
enum class ImplementationType {
IMPLEMENTATION, TEST_IMPLEMENTATION, ANDROID_TEST_IMPLEMENTATION
}
dependencies {
implementationDependencies(ImplementationType.IMPLEMENTATION, Libs.Kotlin)
implementationDependencies(ImplementationType.IMPLEMENTATION, Libs.Android)
implementationDependencies(ImplementationType.TEST_IMPLEMENTATION, Libs.Test)
implementationDependencies(ImplementationType.ANDROID_TEST_IMPLEMENTATION, Libs.AndroidTest)
}
fun DependencyHandler.implementationDependencies(type: ImplementationType, libs: Libs) {
libs.getDependencies().forEach {
when (type) {
ImplementationType.IMPLEMENTATION -> implementation(it)
ImplementationType.TEST_IMPLEMENTATION -> testImplementation(it)
ImplementationType.ANDROID_TEST_IMPLEMENTATION -> androidTestImplementation(it)
}
}
}
비슷한 유형에 속하는 라이브러리들을 클래스 단위로 묶고 getDependencies() 함수를 가진
Libs 인터페이스를 상속받는 구조로 바꿨다.
이런식으로 관리하면 코드가 간결해지지만 아직 의문이 남아있다.
"androidx.room:room-runtime:${Versions.ROOM}"
"androidx.room:room-ktx:${Versions.ROOM}"
"androidx.room:room-compiler:${Versions.ROOM}"
예시로 Room을 사용 할 때의 포함하는 Dependency를 가져왔다.
위 2개의 Dependency는 implementation으로 포함시켜야 하는데 아래의 compiler는 kapt로 포함 시켜야한다.
이 때 Room 단위로 클래스를 묶게되면
object Room : Libs {
private const val CORE = "androidx.room:room-runtime:${Versions.ROOM}"
private const val KTX = "androidx.room:room-ktx:${Versions.ROOM}"
private const val COMPILER = "androidx.room:room-compiler:${Versions.ROOM}"
override fun getDependencies() = listOf(
CORE,
KTX,
COMPILER
)
}
이렇게 되는데 아까 만든 함수는 여러 종류의 Dependency를 포함할 수 없으므로
코드를 추가로 작성하기로 했다.
sealed class를 활용해서 Dependency와 종류를 묶어내기
sealed class DependencyType(val value: Any) {
data class Implementation(private val notation: Any) : DependencyType(notation)
data class TestImplementation(private val notation: Any) : DependencyType(notation)
data class AndroidTestImplementation(private val notation: Any) : DependencyType(notation)
data class Kapt(private val notation: Any) : DependencyType(notation)
}
interface Libs {
fun getDependencies(): List<DependencyType>
object Kotlin : Libs {
private const val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib:${Versions.KOTLIN_STDLIB}"
override fun getDependencies() = listOf(
DependencyType.Implementation(STDLIB)
)
}
object Android : Libs {
private const val CORE_KTX = "androidx.core:core-ktx:${Versions.ANDROID_CORE_KTX}"
private const val APPCOMPAT = "androidx.appcompat:appcompat:${Versions.ANDROID_APPCOMPAT}"
private const val MATERIAL = "com.google.android.material:material:${Versions.ANDROID_MATERIAL}"
private const val CONSTRAINT = "androidx.constraintlayout:constraintlayout:${Versions.ANDROID_CONSTRAINT}"
override fun getDependencies() = listOf(
DependencyType.Implementation(CORE_KTX),
DependencyType.Implementation(APPCOMPAT),
DependencyType.Implementation(MATERIAL),
DependencyType.Implementation(CONSTRAINT)
)
}
object Room : Libs {
private const val CORE = "androidx.room:room-runtime:${Versions.ROOM}"
private const val KTX = "androidx.room:room-ktx:${Versions.ROOM}"
private const val COMPILER = "androidx.room:room-compiler:${Versions.ROOM}"
override fun getDependencies() = listOf(
DependencyType.Implementation(CORE),
DependencyType.Implementation(KTX),
DependencyType.Kapt(COMPILER)
)
}
object JUnit : Libs {
private const val JUNIT = "junit:junit:${Versions.TEST_JUNIT}"
private const val ANDROID_JUNIT = "androidx.test.ext:junit:${Versions.ANDROID_TEST_JUNIT}"
override fun getDependencies() = listOf(
DependencyType.TestImplementation(JUNIT),
DependencyType.AndroidTestImplementation(ANDROID_JUNIT)
)
}
object AndroidTest : Libs {
private const val ESPRESSO = "androidx.test.espresso:espresso-core:${Versions.ANDROID_TEST_ESPRESSO}"
override fun getDependencies() = listOf(
DependencyType.AndroidTestImplementation(ESPRESSO)
)
}
}
// build.gradle.kts (:app)
dependencies {
implementationDependencies(Libs.Kotlin)
implementationDependencies(Libs.Android)
implementationDependencies(Libs.Room)
implementationDependencies(Libs.JUnit)
implementationDependencies(Libs.AndroidTest)
}
fun DependencyHandler.implementationDependencies(libs: Libs) {
libs.getDependencies().forEach {
val notation = it.value
when (it) {
is DependencyType.Implementation -> implementation(notation)
is DependencyType.TestImplementation -> testImplementation(notation)
is DependencyType.AndroidTestImplementation -> androidTestImplementation(notation)
is DependencyType.Kapt -> kapt(notation)
}
}
}
이렇게 작성된 코드에서는 app모듈의 gradle에서는 해당 라이브러리가 어떠한 Dependency인지 종류인지
알 필요 없이 간단하게 포함할 수 있게 됐다.
implementationDependencies 함수를 전역적으로 사용하기
이렇게 작성하게 되면 어떤 이점이 있는가 의문이 들 수 있는데
라이브러리 단위로 Dependency를 관리하면 멀티 모듈 환경에서 손쉽게 동일한
라이브러리를 추가할 수 있는 장점이 있다.
그런데 현재 implementationDependencies 함수는 app 모듈에 종속되어 있으므로 buildSrc로 옮겼다.
하지만 간단하게 옮겨질거란 예상과는 달리 함수들을 찾을 수 없다고 나온다.
아마도 내부 구현이 전부 extension 형태로 되어있어서 buildSrc에서 접근 할 수 없는 것 같다.
그런데 자세히 보면 공통적으로 DependencyHandler.add(함수명, Dependency) 형태를 호출하게
되어있다.
그래서 같은 형태로 호출하게 작성했다.
// buildSrc/src/main/java/DependencyHandlerExtension.kt
fun DependencyHandler.implementationDependencies(libs: Libs) {
libs.getDependencies().forEach { dependencyType ->
val typeName = dependencyType.javaClass.simpleName.run {
replace(first(), first().toLowerCase())
}
add(typeName, dependencyType.value)
}
}
// build.gradle.kts (:app)
dependencies {
implementationDependencies(Libs.Kotlin)
implementationDependencies(Libs.Android)
implementationDependencies(Libs.Room)
implementationDependencies(Libs.JUnit)
implementationDependencies(Libs.AndroidTest)
}
아까 만든 sealed class의 하위목록의 첫글자를 소문자로 변환해서 add의 인자에 넣었다.
이제 새로 만드는 모든 module에서 implementationDependencies 함수를 이용해서 쉽게 의존성을 추가할 수 있게 됐다.
코드가 많아서 포스팅이 생각보다 길어졌지만 과정의 코드를
기록해두고 싶은 마음에 결과물에 비해 길게 써져버렸다.
마지막 문단의 코드는 아무래도 상수가 들어가는 코드이기 때문에 100% 안전한 코드라고는 보장할 수 없어서
안전하게 작성하려면 이전 문단의 함수를 모듈마다 작성하면 될것같다.
예시로 작성한 코드는 제 깃허브 링크에서 확인 할 수 있습니다.