Android KSP(Kotlin Symbol Processing)을 활용할 수 있는 샘플 코드 작업



개인 광고 영역

첫 번째 글에서 KSP 활용을 위한 준비 단계를 살펴보았다. 이번 글에서는 샘플 코드를 적용해 보려고 한다.

Kotlin Symbol Processing API - 공식 문서

이 글에서는 Annotation을 추가하고, KSP에서 이를 활용한 코드 Generated 방법을 소개한다.


이 글에서 알아볼 내용

  • Annotation 적용
  • ksp 활용할 수 있는 샘플 코드 작성
  • ksp에서 오류를 내는 방법


샘플을 위한 Annotation 추가

샘플 코드에 활용하기 위한 Annotation을 추가한다. 이 어노테이션을 통해 클래스 정보를 가져올 수 있다.

Annotation 이름은 동작에 따라 정의하는 게 좋은데, 여기서의 Annotation은 GeneratedAnnotation이란 이름을 활용한다.

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
annotation class GenerateAnnotation

이 Annotation은 Target을 class로 지정해두었기 때문에 class에서 만 활용이 가능하다.


SymbolProcessor

앞글에서 SymbolProcessorProvider에 대해 알아보았는데, 여기서는 실제 구현해야 할 코드 작성 위치인 SymbolProcessor를 알아본다.

SymbolProcessor는 process, finish를 재구현할 수 있다.

  • process : 이 프로세스의 시작점을 뜻한다. resolve 변수를 통해 Annotation을 검색하고, 이 클래스 정보를 가져올 수 있다.
  • finish : 이 함수는 선택적 override 할 수 있으며, 필요시 구현할 수 있다.
class SampleFactoryProcessor : SymbolProcessor {

    override fun process(resolver: Resolver): List<KSAnnotated> {
        TODO("Not yet implemented")
    }

    override fun finish() {
        super.finish()
    }
}

resolve를 통해 Annotation 정보를 가져올 수 있는데, 이를 통해 클래스 정보도 추가로 획득할 수 있다.

여기서 말하는 class는 위에서 정의한 Annotation 사용 시에 해당하는 내용이다. property 정의 시에는 조금 다르다.

  • 클래스 내부 함수 정보를 가져올 수 있다.
  • 클래스 내부의 property 정보를 가져올 수 있다.
  • class의 상속 class 정보를 가져올 수 있다.
  • class의 생성자 정보를 가져올 수 있다.

추가로 문서에 나오는 Multiple round processing을 소개하고 있는데, 개인적으로 자료를 봐도 어떤 경우에 사용하는 게 적합한지는 아직 판단하지 못했다.


KSP 활용 시 주의점

KSP는 기본적으로 증분을 사용한다. 증분을 사용한다는 것은 변화하지 않은 코드는 리빌드 하지 않음을 뜻한다.

그 말은 리빌드해야 할 클래스가 있을 때 정상적으로 리빌드 못하는 경우도 생길 수 있는데,

Target Z가 있다. Target Z는 A, B, C를 먼저 생성해야 Z를 만들 수 있다.

이때 A만 변경되었다면, A는 만들어질 수 있다. 하지만 B, C는 수정하지 않았기 때문에 Z를 만들려면 B, C 정보 역시 알아야 하는데, B, C는 증분으로 서치되지 못했다면 fail 날 수 있다.

이 경우라면 A, B, C를 각각의 파일로 만들고, Z는 이들의 파일만 바라보도록 하는 형태로 해결 가능하다.


SampleFactoryProcessor를 연결하자.

SampleFactoryProcessor를 SymbolProcessorProvider 상속 구현체에 연결해 주면 된다. 이때 필요한 정보를 생성자로 넘겨주도록 한다.

codeGenerator, logger와 필요한 정보를 options에서 꺼내서 넘겨주도록 한다.

class SampleFactoryProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {
  // 생략
}

그리고 이를 SymbolProcessorProvidercreate 함수에서 객체 생성하도록 구현해 준다. 여기에서 필요한 생성자 정보를 함께 전달한다.

class SampleProcessorProvider : SymbolProcessorProvider {

    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
        SampleFactoryProcessor(
            codeGenerator = environment.codeGenerator,
            logger = environment.logger,
        )
}


Annotation을 찾아보자

Annotation을 찾기 위한 코드는 간단한데, 그전에 KSP 동작 확인을 위한 작업을 추가해야 한다.

  • app build.gradle에 KSP 사용을 위한 디펜던시 추가
  • module build.gradle에 KSP 사용을 위한 디펜던시 추가
  • KSP sample, annotation에 대한 디펜던시를 추가한다.

KSP 사용을 위한 디펜던시 추가

root에 위치한 build.gradle 파일에 기존 코틀린, android tools와 함께 ksp gradle plugin을 추가한다.

buildscript {
    dependencies {
        classpath("com.android.tools.build:gradle:7.2.0")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21")
        classpath("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:1.6.21-1.0.5")
    }
}

module build.gradle에 KSP 사용을 위한 디펜던시 추가

그리고 사용할 모듈의 build.gradle에 몇 가지 기능을 추가한다.

plugins {
    id("com.android.application") // 또는 id("com.android.library")
    id("com.google.devtools.ksp")
}

다음으로 KSP 소스 셋을 설정해 줘야 한다. KSP 빌드 결과물은 build/generated/ksp/main/kotlin에 저장되는데, 안드로이드의 경우 debug/release 폴더와 flavor에 따라 폴더가 달라질 수 있다.

안드로이드 build.gradle.kts에서 Flavor 나눠진 경우 KSP 빌드 폴더를 flavor 별 sourceSets 설정 방법

안드로이드 debug/release 상황에 따른 대응을 해주기 위해 아래와 같이 구분하여 sourceSets을 지정해 준다.

android {
  sourceSets.create("debug") {
      kotlin.srcDir("build/generated/ksp/debug/kotlin")
  }
  sourceSets.create("release") {
      kotlin.srcDir("build/generated/ksp/release/kotlin")
  }
}

KSP sample, annotation에 대한 디펜던시를 추가한다.

마지막으로 위에서 생성한 ksp-sample에 대한 로컬 디펜던시를 추가해 준다.

dependencies {
  ksp(project(":ksp-sample"))
}


Annotation을 추가하고 Annotation 정의 한 class 정보를 찾아보자

1 차적으로 getSymbolsWithAnnotation()을 활용하여 정의한 Annotation을 찾아볼 수 있고, logger 사용하여 원하는 결과가 잘 찾아지는지 확인해 본다.

작성했던 annotation을 다음과 같이 추가한다.

@GenerateAnnotation
class MainActivity : AppCompatActivity()

다시 KSP 구현 코드로 돌아가 resolvegetSymbolsWithAnnotation을 이용하여 GenerateAnnotation을 찾아본다.

filter를 통해 정의했던 class에서 사용하였는지 확인하고, 정보를 가져온다.

마지막에 logger를 이용하여 class 정보를 잘 찾아왔는지 확인한다.

class SampleFactoryProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbol = resolver.getSymbolsWithAnnotation(GenerateAnnotation::class.java.canonicalName)
        symbol
            .filter { ksAnnotated ->
                logger.warn("findKSAnnotated ${ksAnnotated is KSClassDeclaration}")
                ksAnnotated is KSClassDeclaration
            }
            .map { ksAnnotated ->
                ksAnnotated as KSClassDeclaration
            }
            .forEach {
                logger.warn("findKSAnnotated ${it.simpleName.asString()}")
            }

        return emptyList()
    }
}

이제 rebuild 해보면 build 화면에서 아래와 같은 결과를 볼 수 있다. 만약 찾지 못했다면 설정이 잘못되었음을 확인해 보아야 한다.

w: [ksp] findKSAnnotated true
w: [ksp] findKSAnnotated MainActivity

process의 마지막 return이 emptyList인 이유는 Multiple round 활용을 하지 않았기 때문이다. 자세한 내용은 문서를 참고하시길

추가로 클래스의 정보 중 functionproperty 정보를 출력해 볼 수 있는데 다음과 같이 할 수 있다.

.forEach {
    logger.warn("findKSAnnotated ${it.simpleName.asString()}")
    it.getDeclaredFunctions().forEach {
        logger.warn("Function ${it.simpleName.asString()}")
    }
    it.getDeclaredProperties().forEach {
        logger.warn("Properties ${it.simpleName.asString()}")
    }
}

MainActivity에서 포함하고 있는 함수property 정보를 결과로 확인할 수 있다.

w: [ksp] Function onCreate
w: [ksp] Function onCreateOptionsMenu
w: [ksp] Function onOptionsItemSelected
w: [ksp] Function <init>
w: [ksp] Properties binding

Visitor를 추가로 사용하는 경우가 있는데, 이는 Multiple round 샘플에 해당한다. 하지만 실제 어떻게 동작하는지는 샘플을 찾지 못해 아직까지 잘 알지 못하므로 여기서는 넘어간다. 그리고 클래스 내부 정보는 Visitor를 사용치 않고도 알 수 있으며, resolve를 이용해도 서칭이 가능하다.


SecondActivity에 대한 extension을 자동으로 만들어보자.

이 글에서 테스트 코드는 매우 간단한 걸로 구현해 본다. showSecondActivity extension을 구현할 수 있는데, 다음의 코드를 그대로 KSP를 활용해 만들어보도록 하자.

fun Context.showSecondActivity() {
    Intent(this, SecondActivity::class.java).run {
        startActivity(this)
    }
}


코드 자체를 출력해 본다.

KSP의 별다른 기능 없이 kotlinpoet을 활용하여 코드 자체를 출력해 보도록 한다.

GitHub - square/kotlinpoet 사용법 - kotlinpoet

사용법 사이트에 설명이 매우 잘 나온다. 결국 출력하고 싶은 코드가 있다면 그거에 맞게 작성이 필요하다.

implementation("com.squareup:kotlinpoet:1.11.0")

file, class, function, property 까지 모두 추가할 수 있다.

이 중에 가장 쉬운 코드 작성 방법은 사용법 사이트에서도 소개되고 있지만 아래와 같다.

val main = FunSpec.builder("main")
  .addCode("""
    |var total = 0
    |for (i in 0 until 10) {
    |    total += i
    |}
    |""".trimMargin())
  .build()

위 코드는 아래와 같이 그대로 출력된다. 별도의 kotlinpoet 기법 없이 단순화 시켜 만들어진다.

fun main() {
  var total = 0
  for (i in 0 until 10) {
    total += i
  }
}

이 방법을 활용해 위에 showSecondActivity 함수를 그냥 그대로 파일로 출력해 보겠다.

그러기 위해서 process 함수에서 서칭한 결과물인 ksClassDeclaration을 List로 보관하도록 변경한다.

private val findClassList = mutableListOf<KSClassDeclaration>()

그리고 finish 함수에서 findClassList 기준으로 파일을 생성하고, extension을 만들어보도록 하겠다.

만드는 방법은 아래와 같다.

  • FileSpec을 구현한다. 여기서는 class는 존재하지 않는다.
  • file에 바로 function을 추가한다.
  • FunSpec에 함수 이름을 정의하고, addCode를 정의한다.
  • outputStream을 만들고,
  • OutputStreamWriter을 이용해 파일로 출력한다.
override fun finish() {
    super.finish()

    findClassList.forEach { findClass ->
        val newPackageName = "${findClass.packageName.asString()}.extensions"
        val newFileName = "${findClass.simpleName.asString()}Extension"
        val context = ClassName("android.content", "Context")

        val file = FileSpec.builder(newPackageName, newFileName)
        file.addFunction(
            FunSpec
                .builder("show${findClass.simpleName.asString()}") // 함수 이름
                .receiver(context) // Receiver
                .addCode(
                    "    Intent(this, SecondActivity::class.java).run {\n" +
                            "        startActivity(this)\n" +
                            "    }"
                )
                .build()
        )
        val outputStream = codeGenerator.createNewFile(
            Dependencies.ALL_FILES,
            newPackageName,
            newFileName,
        )
        OutputStreamWriter(outputStream, "UTF-8").use { file.build().writeTo(it) }
    }
}


새로운 패키지 정의

패키지를 정의해서 파일의 위치를 정의하는데, findClass의 packageName을 찾고, asString() 함수를 통해 풀 패키지 경로를 가져온다.

  • findClass.packageName.getShortName() : 결과 app
    • 현재 파일의 current packageName만 출력된다.
  • findClass.packageName.getQualifier() : 결과 tech.thdev
    • 현재 class의 상위 packageName 경로가 출력된다.
  • findClass.packageName.asString() : 결과 tech.thdev.app
    • 현재 class의 절대 packageName 경로가 출력된다.
val newPackageName = "${findClass.packageName.asString()}.extensions"

여기서는 package 경로를 .extensions을 추가해 파일을 생성하도록 만들었다.


새로운 파일 명

파일명 역시 Extension을 surffix로 추가한다. package와 동일하게 정보를 확인해 보면 아래와 같다.

  • findClass.simpleName.asString() : 결과 SecondActivity
  • findClass.simpleName.getQualifier() : 결과 없음
  • findClass.simpleName.getShortName() : 결과 SecondActivity

asString() 또는 getShortName()으로 ClassName을 가져올 수 있다.

val newFileName = "${findClass.simpleName.asString()}Extension"


FileSpec 생성

파일 스펙을 생성한다. 파일 스펙은 새로운 packageName과 파일 명을 활용해 만든다.

이 파일을 마지막에 outputStream을 이용해.kt로 끝나는 파일을 만든다.

outputStream의 확장자는 내부 옵션을 확인하면.java도 가능

FileSpec.builder(newPackageName, newFileName)

결국 이 파일에 정의하는 건 package 이름을 추가한다.


FunSpec

여기서는 extension을 만들 예정이기 때문에 FunSpec을 작성하는데, prefix로 붙일 이름과 simpleName을 합성해서 만든다.

FunSpec.builder("show${findClass.simpleName.asString()}")

receiver로 Context를 전달받도록 추가하는데, Context를 ClassName()으로 정의하고, 이를 receiver에 추가한다.

val context = ClassName("android.content", "Context")

// 생략
.receiver(context)

이에 대한 결과물은 아래와 같다.

fun Context.showSecondActivity()

그리고 위에서 작성했던 코드를 그대로 추가해 본다.

.addCode(
    "    Intent(this, SecondActivity::class.java).run {\n" +
            "        startActivity(this)\n" +
            "    }"
)

그리고 마지막에 build()를 호출해 함수 추가를 완료한다.

이 코드의 결과물은 아래와 같다.

package tech.thdev.app.extensions

import android.content.Context
import kotlin.Unit

public fun Context.showSecondActivity(): Unit {
      Intent(this, SecondActivity::class.java).run {
          startActivity(this)
      }
}

이 코드의 결과는 아래 패키지에 위치하게 된다.

image_03


문제를 찾아보자.

하나의 import가 없는데, context는 receiver에서 자동으로 import까지 해줬다. 하지만 Intent는 추가해 주지 못했다. 그렇기 때문에 Intent 역시 추가하도록 변경이 필요하다.

이때 SampleActivity 역시 import가 필요하다. 아래와 같이 2개의 줄을 추가한다.

// 현재 찾은 class import
file.addImport(findClass.packageName.asString(), findClass.simpleName.asString())

// Intent에 대한 import
file.addImport("android.content", "Intent")


마무리

KSP를 왜 사용하는지, KSP 프로젝트를 생성하는 부분까지 살펴보았다. Annotation을 타깃하고, 이를 잘 적용하도록 한다.

다음 글에서는 error를 알아보도록 하자.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments