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

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

by dev_gyu 2024. 12. 14.
728x90

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

 

[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. 결과물

 

NavGraphMain 만 존재시
NavGraphTest 라는 실드 클래스 추가 후 빌드 시 파일 변경

 

Composable 에서 Navigate 시 navigate(NavGraphMain.Main.route) 로 사용.

 

만약 파라미터를 필요로 하는 경우 위처럼 함수를 구현하고 navigate(NavGraphMain.MainTest.parameterRoute(2)) 로 사용하면 된다 !

728x90