Type-safe Navigation Compose ile bile yeni bir ekran eklemek üç ayrı düzenleme istiyor: route'u tanımlamak, composable<T> bloğunu yazmak ve bu bloğu doğru NavHost'a eklemek. Son ikisinin yapılıp yapılmadığını derleyici kontrol etmiyor. Bu yazıda bu bağlantıyı Kotlin Symbol Processing (KSP) ile derleme zamanında üreteceğiz: her ekranın üzerinde bir @Destination anotasyonu olacak, bir SymbolProcessor bunları toplayacak ve KotlinPoet her modül için bir NavGraphBuilder extension'ı yazacak.
İşlemci küçük ama eksiksiz. Yol boyunca işin gerçekten çalışıp çalışmayacağını belirleyen kısımlara bakacağız: doğrulama ve ertelenen semboller, aggregating bayrağı ve KSP'nin aynı anda yalnız bir modülü görmesi.
Neden graph üretelim?
Navigation 2.8.0'dan beri route'ları @Serializable tipler olarak tanımlayabiliyor, composable<T> ile kaydedebiliyor ve argümanları backStackEntry.toRoute<T>() ile geri okuyabiliyoruz. Böylece "profile/{userId}" gibi string route'lar ve argüman anahtarlarındaki yazım hataları ortadan kalktı.
Ekranları kaydetmek ise hâlâ elle yapılıyor. Küçük bir uygulamada sorun değil. Ama feature modüllerine dağılmış onlarca destination olduğunda NavHost elle bakımı yapılan uzun bir listeye dönüşüyor ve kaydetmeyi unuttuğunuz ekran ancak runtime'da ortaya çıkıyor. Graph ekranların kendisinden üretilirse unutulacak bir şey kalmıyor. Tekrarlanan geri tuşu mantığını OnBackPressedDispatcher üzerine yazdığım extension'lara taşımamın sebebi de aynıydı: mekanik bağlantı işi elle yazılan kodun dışında daha iyi duruyor.
Kısaca KSP, neden KAPT değil?
KSP, kaynak kodu çözülmüş semboller olarak okumanızı ve derleme sırasında yeni kaynak dosyaları üretmenizi sağlayan, Kotlin odaklı bir API. KAPT farklı çalışıyor: Kotlin kodunuz için Java stub'ları üretiyor, bunun için de bütün tipleri baştan çözmesi gerekiyor, sonra bu stub'ları javac'a veriyor. KSP'de stub yok; bir tipi ancak işlemci istediğinde çözüyor. Kotlin dokümanlarına göre bu, annotation processing'i yaklaşık iki kata kadar hızlandırıyor. KSP ayrıca extension function ve declaration-site variance gibi Java'da karşılığı olmayan Kotlin yapılarını da anlıyor.
KSP salt okunur. İşlemci mevcut kodu değiştiremiyor, yalnız yeni dosya ekleyebiliyor. Yani ekranlara dokunmayacağız; onları okuyup hepsini birbirine bağlayan ayrı bir dosya üreteceğiz. Bu, AOP kütüphanem Aspectify'den farklı bir yaklaşım; Aspectify runtime'da bir java.lang.reflect.Proxy üzerinden çalışıyor. Burada her şey derleme zamanında oluyor ve sonuç, açıp okuyabileceğiniz düz Kotlin kodu.
Proje kurulumu
Üç parçaya ihtiyacımız var: anotasyon modülü (düz Kotlin/JVM), işlemci modülü (o da JVM) ve bunları kullanan feature modülleri.
// navgraph-processor/build.gradle.kts
dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:<ksp-version>")
implementation("com.squareup:kotlinpoet:<kotlinpoet-version>")
implementation("com.squareup:kotlinpoet-ksp:<kotlinpoet-version>")
}
// feature/profile/build.gradle.kts
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation(project(":navgraph-annotations"))
ksp(project(":navgraph-processor"))
}
ksp {
arg("navgraph.package", "com.example.feature.profile")
arg("navgraph.name", "profile")
}
Feature modülünde ayrıca her zamanki Navigation Compose ve kotlinx.serialization kurulumu gerekiyor. İki arg değeri işlemciye option olarak ulaşıyor; her modülün neden kendi değerlerine ihtiyaç duyduğunu aşağıda göreceğiz.
Anotasyon
Anotasyonu küçük tutuyoruz. Bir composable'ı destination olarak işaretliyor ve onu tanımlayan @Serializable route tipini gösteriyor.
package com.example.navgraph
import kotlin.reflect.KClass
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class Destination(val route: KClass<*>)
KSP anotasyonu kaynak koddan okuduğu için SOURCE retention yeterli. Anotasyonun yalnız derleme sırasında var olması gerekiyor ve bytecode'da iz bırakmıyor.
Ekranlar sıradan type-safe route'ları kullanıyor:
package com.example.feature.profile
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import com.example.navgraph.Destination
import kotlinx.serialization.Serializable
@Serializable
data class ProfileRoute(val userId: String)
@Serializable
data object EditProfileRoute
@Destination(route = ProfileRoute::class)
@Composable
fun ProfileScreen(route: ProfileRoute, navController: NavController) {
// ...
}
@Destination(route = EditProfileRoute::class)
@Composable
fun EditProfileScreen(navController: NavController) {
// ...
}
Kural basit: bir ekran kendi route tipini ve bir NavController'ı parametre olarak alabilir, başka bir şey alamaz. Generator hangi lambda'ları geçirmek istediğinizi bilemez, o yüzden böyle sabit bir sözleşmeye ihtiyaç var. NavController'ı ekranlara geçirmenin bir bedeli var: Android dokümanları, ekranlar kolay test edilebilsin ve preview'da sorunsuz çalışsın diye ekranlara NavController yerine lambda geçirmeyi öneriyor. Bu sizin için önemliyse NavController yerine kendi küçük Navigator interface'inizi kullanın. İşlemci mantığı aynı kalır.
SymbolProcessor'ı yazmak
Bir KSP işlemcisi iki parçadan oluşuyor: KSP'nin bulduğu bir provider ve işlemcinin kendisi.
Provider
class NavGraphProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
NavGraphProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
options = environment.options,
)
}
KSP provider'ı tek satırlık bir service dosyasıyla buluyor: src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider. Dosyada provider'ın tam sınıf adı yazıyor. Bu dosyayı AutoService ile de ürettirebilirsiniz, ama o zaman işlemci modülünün de kendine ait bir annotation processor'a ihtiyacı oluyor. Tek bir satır için elle yazmak daha basit.
Ekranları bulmak ve doğrulamak
class NavGraphProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
private val options: Map<String, String>,
) : SymbolProcessor {
private var generated = false
override fun process(resolver: Resolver): List<KSAnnotated> {
val screens = resolver
.getSymbolsWithAnnotation(DESTINATION)
.filterIsInstance<KSFunctionDeclaration>()
.toList()
if (screens.isEmpty()) return emptyList()
// The graph is a single file. If any screen can't be resolved yet,
// defer all of them and generate the file in a later round.
if (screens.any { !it.validate() }) return screens
if (generated) {
logger.error("@Destination found after the graph was generated", screens.first())
return emptyList()
}
generated = true
val destinations = screens.mapNotNull { it.toDestination() }
if (destinations.size == screens.size) {
writeGraph(destinations)
}
return emptyList()
}
private companion object {
const val DESTINATION = "com.example.navgraph.Destination"
const val NAV_CONTROLLER = "androidx.navigation.NavController"
val COMPOSABLE = MemberName("androidx.navigation.compose", "composable", isExtension = true)
val TO_ROUTE = MemberName("androidx.navigation", "toRoute", isExtension = true)
}
}
KSP bir işlemciyi birden fazla turda çalıştırabiliyor. Bir ekran, başka bir işlemcinin henüz üretmediği bir tipe referans veriyor olabilir; bu durumda validate() false döner. process()'in döndürdüğü semboller bir sonraki tura ertelenir ve getSymbolsWithAnnotation bu sembolleri, yeni üretilen dosyalardaki sembollerle birlikte tekrar verir.
Örneklerin çoğu sembolleri ikiye ayırıp geçerli olanları işler, yalnız geçersizleri döndürür. Her sembol kendi dosyasını üretiyorsa bu yöntem sorunsuz. Bizim çıktımız ise tek bir dosya. İlk turda graph'ı geçerli ekranlarla yazarsak ikinci turda aynı dosyayı yeniden oluşturmamız gerekir, KSP ise aynı dosyanın iki kez oluşturulmasına izin vermiyor. İlk turun sembollerini saklayıp kalanları sonra eklemek de olmuyor, çünkü KSP sembol nesneleri turlar arasında saklanmamalı. Bu yüzden çözülemeyen tek bir ekran bile varsa bütün ekranları erteliyor, dosyayı her şeyin çözüldüğü turda tek seferde yazıyoruz. generated bayrağı da dosya yazıldıktan sonraki bir turda yeni ekran gelirse bunu yakalıyor.
Anotasyonu modele dönüştürmek
İşlemcinin geri kalanı KotlinPoet tiplerinden oluşan küçük bir modelle çalışıyor:
private enum class ScreenParameter { Route, NavController }
private class DestinationModel(
val screen: MemberName,
val route: ClassName,
val parameters: List<ScreenParameter>,
val file: KSFile?,
)
Anotasyonu okuyan fonksiyon NavGraphProcessor'ın içinde:
private fun KSFunctionDeclaration.toDestination(): DestinationModel? {
val annotation = annotations.first {
it.annotationType.resolve().declaration.qualifiedName?.asString() == DESTINATION
}
val routeType = annotation.arguments
.first { it.name?.asString() == "route" }
.value as KSType
val routeName = routeType.declaration.qualifiedName?.asString()
val screenParameters = parameters.map { parameter ->
when (parameter.type.resolve().declaration.qualifiedName?.asString()) {
routeName -> ScreenParameter.Route
NAV_CONTROLLER -> ScreenParameter.NavController
else -> {
logger.error(
"Unsupported parameter '${parameter.name?.asString()}' in a @Destination screen",
parameter,
)
return null
}
}
}
return DestinationModel(
screen = MemberName(packageName.asString(), simpleName.asString()),
route = routeType.toClassName(),
parameters = screenParameters,
file = containingFile,
)
}
KClass tipindeki bir anotasyon argümanını okuduğunuzda KSP size reflection sınıfı değil, KSType veriyor. kotlinpoet-ksp'deki toClassName() bunu KotlinPoet ClassName'ine çeviriyor. Desteklenmeyen bir parametrede logger.error'ı parametreyi node olarak vererek çağırıyoruz; böylece build başarısız oluyor ve hata ekrandaki doğru satırı gösteriyor.
KotlinPoet ile graph üretmek
CodeGenerator.createNewFile(...) ile ham metin de yazabilirsiniz, ama çıktıda import'lar ve iç içe bloklar olunca KotlinPoet işi çok kolaylaştırıyor. Import'ları KotlinPoet yönetiyor, kotlinpoet-ksp de FileSpec'i KSP'nin CodeGenerator'ı üzerinden yazıyor.
private fun writeGraph(destinations: List<DestinationModel>) {
val packageName = options["navgraph.package"]
val name = options["navgraph.name"]
if (packageName == null || name == null) {
logger.error("Set navgraph.package and navgraph.name with ksp { arg(...) }")
return
}
val graphFunction = FunSpec.builder("${name}Destinations")
.receiver(ClassName("androidx.navigation", "NavGraphBuilder"))
.addParameter("navController", ClassName("androidx.navigation", "NavController"))
destinations.sortedBy { it.screen.canonicalName }.forEach { destination ->
val arguments = destination.parameters.map { parameter ->
when (parameter) {
ScreenParameter.Route -> CodeBlock.of("it.%M<%T>()", TO_ROUTE, destination.route)
ScreenParameter.NavController -> CodeBlock.of("navController")
}
}
graphFunction.addStatement(
"%M<%T> { %M(%L) }",
COMPOSABLE,
destination.route,
destination.screen,
arguments.joinToCode(),
)
}
FileSpec.builder(packageName, "${name.replaceFirstChar { it.uppercase() }}NavGraph")
.indent(" ")
.addFunction(graphFunction.build())
.build()
.writeTo(
codeGenerator = codeGenerator,
aggregating = true,
originatingKSFiles = destinations.mapNotNull { it.file },
)
}
Ekranları sıralamamız bilinçli. getSymbolsWithAnnotation'ın döndürdüğü sıraya güvenmemek gerekiyor; aynı girdi her zaman aynı dosyayı üretmeli, yoksa çıktı sebepsiz yere build'den build'e değişebilir ve build cache işe yaramaz hâle gelir.
Profile modülü için üretilen dosya:
package com.example.feature.profile
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
public fun NavGraphBuilder.profileDestinations(navController: NavController) {
composable<EditProfileRoute> { EditProfileScreen(navController) }
composable<ProfileRoute> { ProfileScreen(it.toRoute<ProfileRoute>(), navController) }
}
writeTo(codeGenerator, aggregating, originatingKSFiles), KSP'nin kendi API'si üzerinde ince bir katman. KotlinPoet olmadan aynı iş şöyle görünüyor:
val dependencies = Dependencies(aggregating = true, *files.toTypedArray())
codeGenerator.createNewFile(dependencies, packageName, fileName)
.bufferedWriter()
.use { writer -> fileSpec.writeTo(writer) }
Dependencies, KSP'ye çıktının hangi kaynak dosyalardan geldiğini ve aggregating olup olmadığını söylediğimiz yer. Bunun neden önemli olduğu bir sonraki bölümün konusu.
Bedeller ve tuzaklar
Kod üretimi bedava değil ve maliyetin çoğu build sistemiyle yapılan sözleşmede.
Aggregating bayrağı
KSP'nin incremental çalışabilmesi için her çıktının hangi kaynak dosyalardan geldiğini ve isolating mi aggregating mi olduğunu bildirmesi gerekiyor. Isolating bir çıktı yalnız listelediği dosyalara bağlı; başka bir yerdeki değişiklik ona dokunmuyor. Her anotasyonlu sınıf için bir sınıf üreten generator'lar tipik isolating örneği. Aggregating bir çıktı ise KSP dokümanlarına göre herhangi bir girdiye bağlı olabilir; bu yüzden modülün kaynaklarındaki herhangi bir değişiklik, yeni dosyalar da dahil, KSP'nin işlemciyi bu çıktı için yeniden çalıştırmasına yol açıyor.
Graph'ın aggregating olması şart. Yeni bir ekran dosyası eklediğinizde o ekranın mevcut graph dosyasına girmesi gerekiyor ve yeni dosyalar yalnız aggregating çıktıları geçersiz kılıyor. Daha hızlı build için graph'ı isolating bildirirseniz, ekran ekledikten sonraki incremental build yalnız yeni dosyayı işleyebilir ve elinizde eski ya da bozuk bir graph kalır. Bu, yavaş bir build'den çok daha kötü bir hata. Yapabileceğiniz şey aggregating işlemciyi küçük tutmak ve sınıf başına üretim yapan işleri onunla karıştırmamak.
Aynı anda tek modül
KSP her seferinde tek bir modülü işliyor. getSymbolsWithAnnotation yalnız derlenen modülün kaynaklarındaki sembolleri döndürüyor; bağımlılıklar derlenmiş sınıflar olarak geliyor ve SOURCE retention'lı bir anotasyon orada artık yok. :app içinde çalışan bir işlemci :feature:profile'daki ekranları göremiyor.
Yukarıdaki tasarımın her modül için ayrı bir graph üretmesinin sebebi bu. Her modülün kendi package'ı ve adı olmalı; iki modül aynı package'ta aynı dosyayı üretirse uygulama build edilirken duplicate class hatası alırsınız. Modül graph'larını app'ten çağırmak ise hâlâ elle yapılıyor, her modül için bir satır:
val navController = rememberNavController()
NavHost(navController = navController, startDestination = HomeRoute) {
homeDestinations(navController)
profileDestinations(navController)
}
Bunu da otomatikleştirmek mümkün. KSP classpath'teki tanımları okuyabiliyor (örneğin @KspExperimental olarak işaretli Resolver.getDeclarationsFromPackage ile), yani app modülündeki bir işlemci her feature modülünde üretilmiş işaret tanımlarını bulabilir. Ama bu, ikinci bir işlemci ve ikisi arasında bir isimlendirme sözleşmesi demek. Birkaç feature modülü için modül başına bir satır daha ucuz. Start destination da app'te kalıyor; ilk hangi ekranın açılacağı tek bir feature modülünün vereceği bir karar değil.
Üretilen kodu test etmek
Üretilen kod da kod ve test edilmesi gerekiyor. İki seviyede test etmenizi öneririm. Önce işlemcinin kendisini, KSP'yi destekleyen kotlin-compile-testing'in bakımı süren fork'uyla test edin: bellekteki kaynakları derleyin, üretilen dosyanın metnini kontrol edin ve desteklenmeyen parametreli bir ekranın beklenen hatayı verdiğini doğrulayın. Sonra işlemciyi gerçekten kullanan bir örnek modülü CI'da build edin; böylece üretilen kodun gerçek Navigation API'leriyle derlendiğini görürsünüz.
Generator'ın üretmesini istediğiniz kodu önce elle yazmak, işlemciye ancak ondan sonra ürettirmek de işi kolaylaştırıyor. Bir projeyi RxJava'dan Kotlin Coroutines'a taşırken de mekanik bir dönüşümün güvenli olması için önce hedef yapının net olması gerekiyordu.
Son söz
Generator'ın tamamı tek bir anotasyonu okuyan bir SymbolProcessor, KotlinPoet tiplerinden oluşan bir model ve aggregating çıktı olarak yazılan bir FileSpec'ten ibaret. Bir modül içinde ekran kaydetmeyi unutmak artık mümkün değil ve ekranlar typed route argümanlarını elle yazılmış toRoute() çağrıları olmadan alıyor. Elle kalan kısım, her modülün graph'ını app'ten çağırmak ve start destination'ı seçmek. En dikkat edilmesi gereken iki ayrıntı: originating dosyalarla birlikte aggregating bayrağı ve bütün ekranlar çözülene kadar hepsini ertelemek.
Kaynaklar
Sık sorulan sorular
- Annotation processing için neden KAPT yerine KSP kullanmalıyım?
- KSP, KAPT'nin ihtiyaç duyduğu Java stub üretimini atlar ve tipleri yalnız işlemci istediğinde çözer; Kotlin dokümanlarına göre bu, annotation processing'i yaklaşık iki kata kadar hızlandırır. Ayrıca Kotlin'e özgü yapıları anlar ve salt okunurdur: işlemci yeni dosya ekleyebilir ama kaynak kodunuzu değiştiremez.
- Navigation graph çıktısı neden aggregating olmak zorunda?
- Graph, birçok dosyadaki ekranı tek bir üretilmiş dosyada topluyor ve yeni eklenen ekran dosyası da aynı dosyaya girmeli. Yeni dosyalar yalnız aggregating çıktıları geçersiz kılar. Isolating bildirilirse incremental build yalnız yeni dosyayı işleyip eski ya da bozuk bir graph bırakabilir.
- Üretilen graph typed route argümanlarını ekranlara geçiriyor mu?
- Evet, yazıdaki kurala göre. Ekranın kendi route tipindeki parametresine it.toRoute<Route>(), NavController parametresine NavController geçirilir; başka bir parametre varsa işlemci kod üretmek yerine hata verir.
- Destination anotasyonundaki SOURCE retention derlenmiş uygulamada ne anlama gelir?
- KSP anotasyonu kaynak koddan okuduğu için anotasyonun yalnız derleme sırasında var olması yeterli. SOURCE retention sayesinde yayımlanan uygulamanın bytecode'unda hiçbir iz bırakmaz.