본문 바로가기
프로그래밍/Jetpack Compose

[Jetpack Compose] KSP 를 활용하여 자동으로 NavHost Composable 코드 생성하기 (1/2)

by dev_gyu 2024. 12. 13.
728x90

Jetpack Compose 환경을 사용중인 안드로이드 앱 개발자라면 Navigation 을 통해 화면 이동하는 방식을 사용하고 있을 것이다.

이 Navigation 을 사용할 때는 위와 같이 유저와의 상호작용이 발생하였을 때 NavController 를 통해서 스택을 Pop 하여 화면을 뒤로가기 하거나 스택에 Push 하여 새로운 화면으로 이동하는데, 이러한 작업들을 진행하기 위해서 가장 먼저 필요한 것은 NavHost 에 Composable 함수를 통해 앱 내 화면들을 등록하는 것이다.

 

@Composable
fun TestScreen(
  val controller = rememberNavController()
) {
  ...
  NavHost(navController = controller, startDestination = ???) {
    composable<???> {
      ...
    }
    composable<???> {
      ...
    }
  }
}

 

나는 이러한 과정 속에서 화면을 쉽게 관리하기 위해 한 하나의 Sealed class 안에서 모든 화면을 관리하였지만, 추후 화면이 매우 많아짐에 따라 한화면 관리 및 유지보수에 대한 어려움이 있다는 것을 깨닫고 화면을 분리하기 위해 관심사 별로 Composable 을 분리하기로 생각하였다.

 

하지만 composable 을 각 화면마다 지정해주고 Route, Arguments 도 지정해주는 과정이 너무나 귀찮았을 뿐더러 개발자의 실수가 일어날 것 같다는 생각하에 KSP 를 활용하면 자동으로 모든 코드를 생성할 수 있지 않을까 생각이 되어 이 기능을 개발하였고, 꽤 잘 사용하고 있어서 이를 공유해보자 한다.


 

# 1. Abstract class 구현하기

가장 먼저 작업한 것은 Abstract class 구현이다.

NavGraphBuilder 에 composable 을 등록할 때는 각 화면에서 필요한 Arguments 와 그 화면의 Destination (Route) 를 지정해주어야 하는데, 이는 필수적인 절차이므로 모든 화면에서 필요한 값들이다.

그러므로 나는 다음과 같이

  • arguments (Composable Arguments)
  • screenName (화면의 이름. Sealed class 이름과 자기 자신의 이름)
  • route (화면 이름 + arguments 문자열) -> 실제로 쓰일 화면 경로

3가지 변수와 NavHost Arguments 에 사용될 CustomNamedNavArguments 를 구현해주었다.

// 이 클래스는 Arguments 를 Route 로 변환할때 문자열을 옵셔널로 설정할지 결정함
@Immutable
data class DevGyuNamedNavArguments(val namedNavArgument: NamedNavArgument, val isOptional: Boolean = false)
abstract class DevGyuNavGraph {
    // 기본 route + $argumentkey 반복
    open val arguments: List<DevGyuNamedNavArguments>
        get() = emptyList()

    /* 기본 Route - 부모 클래스 이름 + 클래스 이름
    클래스 이름 하나만 사용하면 서로 다른 NavGraph 에서 동일한 name 을 가져 route 가 덮어씌워질 수도 있음 */
    val screenName: String
        get() {
            val superclassName = this::class.java.superclass.simpleName
            val className = this::class.java.simpleName
            return "$superclassName/$className"
        }

    // 지정한 argument에 맞춰서 자동적으로 route를 생성
    val route: String
        get() {
            var isAddedQueryParam = false
            return screenName + (
                    arguments
                        .sortedBy { it.isOptional }
                        .joinToString(separator = "") {
                            val isOptional = it.isOptional
                            when {
                                isOptional && !isAddedQueryParam -> {
                                    isAddedQueryParam = true
                                    "?${it.namedNavArgument.name}={${it.namedNavArgument.name}}"
                                }
                                isOptional && isAddedQueryParam ->
                                    "&${it.namedNavArgument.name}={${it.namedNavArgument.name}}"

                                else -> "/{${it.namedNavArgument.name}}"
                            }
                        }
                    )
        }

    /** createRoute 를 직접 설정하는 경우 emptyString 과 같은 상황에서 에러 발생하거나, arguments 를 빼놓는 경우가 있어 추가 */
    protected fun replaceRoute(vararg arguments: Pair<String, Any?>): String {
        var replaceRoute = route
        arguments.forEach { (name, value) -> replaceRoute = replaceRoute.replace("{$name}", value.toString()) }
        return replaceRoute
    }

    @Composable
    abstract fun NavigateScreen(navController: NavController, entry: NavBackStackEntry)
}

 

replaceRoute 의 경우 NavController.Navigate 시 필요한 Argument 가 존재하는 경우 이를 현재 등록된 화면의 Route 문자열과 일치시켜주기 위한 함수이므로 Arguments 를 사용하지 않는다면 무시해도 괜찮음

# 2. Sealed Class 구현하기

sealed class NavGraphMain: DevGyuNavGraph() {
    data object Main: NavGraphMain() {
        @Composable
        override fun NavigateScreen(navController: NavController, entry: NavBackStackEntry) {
            MainScreen()
        }
    }

    data object MainTest: NavGraphMain() {
        @Composable
        override fun NavigateScreen(navController: NavController, entry: NavBackStackEntry) {
            MainTestScreen()
        }
    }
}

 

위와 같이 관심사에 맞는 Sealed class 를 구현해주고, 하위 object 를 선언해준다음 NavigateScreen 에 화면에 띄워줄 Composable 을 선언해준다.

위처럼 파일을 생성하고 NavGraphCore.Splash.route 를 프린트해보면 NavGraphCore/Splash 가 나온다.

# 3. Annotation 구현하기

이제부터 KSP 를 본격적으로 활용할 단계이다.

KSP 를 통해 특정 클래스 파일을 (현재 시점에서는 NavGraphBuilder 에 composable 을 등록할 Sealed class) 를 등록하기 위해서는 무언가 구분이 될 만한 것을 지정해주어야 하는데, 우리는 Annotation 을 활용하여 컴파일러에게 해당 Annotation 이 달려있는 클래스를 추출하여 파일을 생성하라는 것을 알려주려고 한다.

 

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

 

우선 특정 모듈에 위와 같이 Target 은 CLASS, Retention 은 SOURCE 나 BINARY 로 설정하여 Annotation 을 만들어주자.

런타임 환경에서는 필요하지 않은 주석이기에 AnnotationRetention.RUNTIME 로 설정하지 않는다.

 

# 4. KSP 파일 대상 클래스에 Annotation 붙여주기

2번에서 설정했던 Sealed Class 에 3번에서 추가한 Annotation 을 붙여준다.

@NavGraphRegister
sealed class NavGraphMain: DevGyuNavGraph() {
    data object Main: NavGraphMain() {
        @Composable
        override fun NavigateScreen(navController: NavController, entry: NavBackStackEntry) {
            MainScreen()
        }
    }

    data object MainTest: NavGraphMain() {
        @Composable
        override fun NavigateScreen(navController: NavController, entry: NavBackStackEntry) {
            MainTestScreen()
        }
    }
}

 

# 5. KSP 모듈 생성 

4번까지 작업을 완료하였다면, 이제 필요한 것은 NavGraphRegister Annotation 이 붙은 클래스들에 대한 파일을 생성하줄 SymbolProcessor Class 를 만드는 것이다.

 

나의 경우 멀티모듈 환경을 통해 관심사 분리를 진행하고 있으므로 다음과 같이 Processor 를 실행할 모듈을 생성하였다.

 

# 버전의 경우 각 kotlin version 에 맞춰서 사용
symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "symbolProcessingApi" }

 

 

그리고 Presentation 모듈 (Sealed class 가 존재하는 모듈) 의 Build.gradle.kts 에 다음과 같이 의존성을 넣어준다.

 

# 6. SymbolProcessor 클래스 생성

 

class NavGraphRegisterProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return NavGraphRegisterProcessor(environment.codeGenerator, environment.logger)
    }
}

 

우선 Processor 모듈 > main 에 위 클래스를 생성해준다.

NavGraphRegisterProcessor 클래스는 추후 생성해줄 것이므로 우선 무시하고 코드를 생성한다.

com.google.devtools.ksp.processing.SymbolProcessorProvider

 

그리고 Processor 모듈 > resources > META-INF > services 폴더 생성 후 위 이름의 파일을 생성하고 그 안에 다음과 같이 입력한다.

(Kotlin 컴파일러가 KSP를 초기화할때 파일에 선언된 클래스를 탐색할 수 있게 하기 위함)

 

패키지명.NavGraphRegisterProvider

 


너무 길어지는 관계로 다음 글로 이어서 작성합니다.

 

728x90