[Jetpack Compose] KSP 를 활용하여 자동으로 NavHost Composable 코드 생성하기 (1/2)
Jetpack Compose 환경을 사용중인 안드로이드 앱 개발자라면 Navigation 을 통해 화면 이동하는 방식을 사용하고 있을 것이다.이 Navigation 을 사용할 때는 위와 같이 유저와의 상호작용이 발생하였을 때
dev-gyu.tistory.com
내용은 윗 글에서 이어집니다.
# 7. NavGraphComposable 생성하기
Processor 모듈에서 파일들을 모두 생성하였다면, 다시 NavHost 가 존재하는 모듈로 돌아와 아래의 코드들을 추가해준다.
inline fun <reified T : DevGyuNavGraph> NavGraphBuilder.devGyuNavGraphComposable(
navController: NavController
) {
val clazz = T::class
check(clazz.isSealed) { "${T::class.simpleName} is not Sealed class" }
clazz.getSealedSubclasses().forEach { type ->
composable(
route = type.route,
arguments = type.arguments.map { it.namedNavArgument },
content = { entry ->
Scaffold(
modifier = Modifier.fillMaxSize(),
content = {
Box(
Modifier
.animateContentSize()
.padding(it)
.systemBarsPadding(),
) {
type.NavigateScreen(navController, entry)
}
},
)
},
)
}
}
/**
* KClass 형식의 Instance 를 T 형식으로 변환해준다
* */
fun <T : Any> KClass<T>.getObjectInstance(): T = this.objectInstance as T
/**
* T class 의 서브 클래스들을 추출한다.
*
* ```
* sealed class Nav: WamooNavGraph() {
* data object A: Nav()
* data object B: Nav()
* }
*
* return [A, B]
* ```
* @see DevGyuNavGraph
* */
fun <T : DevGyuNavGraph> KClass<T>.getSealedSubclasses(): List<T> =
this.sealedSubclasses.map { it.getObjectInstance() }
devGyuNavGraphComposable 의 경우 KSP 로 자동 생성되는 코드에서 사용할 함수이므로 함수명과 내부 코드들은 알아서 설정해도 된다.
# 8. SymbolProcessor 클래스 추가
7번 코드들을 모두 추가해주었다면 다시 Processor 모듈로 돌아가 클래스를 하나 생성해주자.
NavGraphRegisterProvider 의 create 함수에서 return 하던 Processor 클래스를 추가해줄 것이다.
class NavGraphRegisterProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
// 패키지명은 자유롭게 설정.
private val packageName = "devgyu.blogproject.presentation.navigation"
private val fileAndFunctionName = "generateNavGraphComposable"
private val navGraphComposableList = mutableListOf<String>()
private val containingFileList = mutableListOf<KSFile>()
override fun finish() {
super.finish()
val file = codeGenerator.createNewFile(
Dependencies(true, sources = containingFileList.toTypedArray()),
packageName,
fileAndFunctionName
)
file.writer().use { writer ->
val stringBuilder = StringBuilder()
navGraphComposableList.forEachIndexed { index, s ->
if(index >= 1){ stringBuilder.append("\n\t") }
stringBuilder.append(s)
}
writer.write(autoNavigationFunctionFileString(stringBuilder))
}
}
override fun onError() {
super.onError()
logger.error("NavGraphRegisterProcessor error")
}
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(annotationName)
val filteringSymbols = symbols
.filterIsInstance<KSClassDeclaration>()
.filter { it.validate() }
filteringSymbols.forEach { classDeclaration ->
classDeclaration.containingFile?.let { it1 -> containingFileList.add(it1) }
classDeclaration.accept(NavGraphRegisterVisitor(
logger = logger,
annotationName = annotationName,
generateComposableString = { str ->
// 내부에서 sealed class 인지 파악 후 코드 구현 String 되돌려받음
if(str.isNotEmpty()){ navGraphComposableList.add(str) }
}
), Unit)
}
return filteringSymbols.toList()
}
private fun autoNavigationFunctionFileString(navComposableListString: StringBuilder): String{
return """
|package $packageName
|
|import androidx.navigation.NavController
|import androidx.navigation.NavGraphBuilder
|import androidx.compose.material3.SnackbarHostState
|import devgyu.blogproject.presentation.navigation.*
|import devgyu.blogproject.presentation.navigation.graph.*
|
|/**
|* @see devgyu.blogproject.processor.NavGraphRegisterProcessor
|*/
|fun NavGraphBuilder.${fileAndFunctionName}(navController: NavController) {
| ${navComposableListString}
|}
""".trimMargin()
}
companion object {
private val annotationName = NavGraphRegister::class.qualifiedName!! // 패키지명을 포함한 어노테이션 클래스 명
}
}
혹시나 파일 변경에 따른 재생성 옵션을 끄고 싶다면 codeGenerator.createNewFile > Dependencies 의 aggegating 속성을 false 로 변환해준다.
autoNavigationFunctionFileString 함수에서 | 와 trimMargin 을 넣은 이유는 저것을 넣지 않으면 자동 생성되는 코드들 앞에 공백이 생겨 예뻐보이지 않기 때문에 추가한 것이다. 중요하지는 않으므로 삭제해도 무방하다.
# 9. KSVisitorVoid 클래스를 상속하는 커스텀 클래스 구현
NavGraphRegisterProcessor 의 Process 코드가 실행될 때, KSClassDeclaration (클래스 정보) 에 접근하면 해당 클래스의 메타데이터를 추출할 수 있도록 NavGraphRegisterVisitor 라는 클래스를 구현해주자.
class NavGraphRegisterVisitor(
private val logger: KSPLogger,
private val annotationName: String,
private val generateComposableString: (String) -> Unit
) : KSVisitorVoid() {
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit){
// 실드 클래스에서만 사용할 수 있게 설정
if(!classDeclaration.modifiers.contains(Modifier.SEALED)){
logger.error("${annotationName}`s target is only Sealed [class or Interface]", classDeclaration)
return
}
logger.warn("generate [${classDeclaration}]'s navComposable code")
generateComposableString("devGyuNavGraphComposable<${classDeclaration}>(navController)")
logger.warn("generated code !!")
}
}
우리는 이를 통해서 NavGraphRegister 어노테이션이 붙은 클래스가 Sealed class 인지 확인이 가능하고, 자동 생성될 코드 내부에 실제 클래스 이름도 전달해줄 수 있다.
# 10. Build.gradle.kts 에 KTS Project 추가
이제 파일 자동 생성을 위한 모든 작업은 끝이 났다.
NavHost 를 구현할 모듈의 Build.gradle.kts (나의 경우 Presentation Module) 에 다음과 같이 KSP 를 추가해준다.
# 11. NavHost 구현
@AndroidEntryPoint
class MainActivity() : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
enableEdgeToEdge()
GyuDefaultTheme {
DevGyuNavigator()
}
}
}
}
@Composable
fun DevGyuNavigator(){
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = NavGraphMain.MainTest.route,
){
generateNavGraphComposable(navController)
}
}
Presentation Module (뷰 보여질 모듈) 로 돌아와 NavHost 를 구현하고 NavGraphRegisterProvider 에서 설정했던 fileAndFunctionName 을 추가해준다.
generacteNavGraphComposable 한 번 빌드가 진행되어야 파일이 생성되므로 참고 !! (빨간색으로 뜨더라도 그대로 빌드하면 정상 실행됨)
# 12. 결과물
Composable 에서 Navigate 시 navigate(NavGraphMain.Main.route) 로 사용.
만약 파라미터를 필요로 하는 경우 위처럼 함수를 구현하고 navigate(NavGraphMain.MainTest.parameterRoute(2)) 로 사용하면 된다 !