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
너무 길어지는 관계로 다음 글로 이어서 작성합니다.