Jetpack Compose'da Tip Güvenli Navigasyon: Route'lar String Değil, Veridir
String tabanlı Compose Navigation route'larını serializable Kotlin nesneleriyle nasıl değiştirdiğim — tip güvenli argümanlar, iç içe graph'lar ve bir refactor'dan sağ çıkan deep link'ler.
Compose Navigation’ın ilk birkaç yılında her route bir string’di. "item/{itemId}" gibi bir şey yazılır, bir NavHost içinde eşleştirilir ve itemId, bir NavBackStackEntry’den getString("itemId") ile geri çekilirdi — giderken yazılan anahtarın dönerken yazılan anahtarla eşleştiğini umarak. Derleyici burada size yardımcı olamazdı. İki string’den birindeki bir yazım hatası sorunsuz derlenir ve runtime’da çökerdi, çoğunlukla değişikliği yaptığınız sırada bakmadığınız bir ekranda. Navigation Compose’un tip güvenli route’ları bunu, bir route’u string yerine bir Kotlin nesnesi haline getirerek çözüyor; production’daki bir uygulamada her ekranı buna taşıdıktan sonra argüman yazım hatası sınıfındaki bug tamamen ortadan kalkıyor — derleyici bunu build bitmeden yakalıyor.
Route’ları sealed hiyerarşi olarak tanımlamak
Fikir küçük: destination’lar sealed bir interface olarak tanımlanır, her biri @Serializable ile işaretlenir ve bunları route’a çevirip geri çevirme işi kotlinx.serialization’a bırakılır.
sealed interface Screen {
@Serializable
data object Home : Screen
@Serializable
data class ItemDetail(val itemId: Long) : Screen
@Serializable
data class EditItem(val itemId: Long, val fromDetail: Boolean = false) : Screen
}
Ne bir string template ne de senkronize tutulması gereken bir anahtar adı var — argümanlar sınıfın property’lerinin ta kendisi. Navigasyon, gerçek tiplerle yapılan bir fonksiyon çağrısı:
navController.navigate(Screen.ItemDetail(itemId = item.id))
itemId yukarıda bir yerde yeniden adlandırılsa ya da tipi Long’dan String’e değiştirilse, her çağrı noktası runtime’da parse edilemeyip çökmek yerine derlenemez hale gelir.
NavHost’u bağlamak
NavHost tarafı bunu, tipi bir string pattern yerine reified bir parametre olarak alan composable<T>() overload’ıyla yansıtır:
NavHost(navController = navController, startDestination = Screen.Home) {
composable<Screen.Home> {
HomeScreen(onItemClick = { id -> navController.navigate(Screen.ItemDetail(id)) })
}
composable<Screen.ItemDetail> { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
composable<Screen.EditItem> { backStackEntry ->
val args: Screen.EditItem = backStackEntry.toRoute()
EditItemScreen(itemId = args.itemId, cameFromDetail = args.fromDetail)
}
}
toRoute(), back stack entry’yi doğrudan destination tipine deserialize eder. Yanlış yazılabilecek bir Bundle anahtarı ya da eksik bir argüman için elle yapılan bir null-check yoktur — bulunmayan zorunlu bir property basitçe derlenmez, opsiyonel olan ise, tıpkı başka herhangi bir Kotlin fonksiyon argümanı gibi, tanımlanmış varsayılan değerini alır.
İç içe graph’lar da tip güvenli kalıyor
Aynı kalıp, string route’ların gerçekten acı verici hale geldiği yer olan iç içe navigasyon graph’larına da uzanıyor — iç içe bir graph’ın start destination’ındaki bir yazım hatası sessizce başarısız olur ve sadece boş bir ekran gösterir. Sealed route’larla iç içe graph, kendi tipine sahip olur:
sealed interface OnboardingGraph : Screen {
@Serializable data object Welcome : OnboardingGraph
@Serializable data object Permissions : OnboardingGraph
@Serializable data class Done(val skippedPermissions: Boolean) : OnboardingGraph
}
navigation<OnboardingGraph>(startDestination = OnboardingGraph.Welcome) {
composable<OnboardingGraph.Welcome> { /* ... */ }
composable<OnboardingGraph.Permissions> { /* ... */ }
composable<OnboardingGraph.Done> { entry ->
val args: OnboardingGraph.Done = entry.toRoute()
// ...
}
}
OnboardingGraph, Screen’i extend ettiği için, uygulamanın başka bir yerinde dış graph’a karşı navigasyon yapan kod, iç içe graph’ın var olduğunu bilmek zorunda değildir — sadece navigate(OnboardingGraph.Welcome) çağrılır ve graph sınırı string prefix eşleştirmesiyle değil, yapısal olarak çözülür.
Yanlış gidebilecek bir URI template olmadan deep link’ler
Deep link’ler aynı tipli destination’lara bağlanır, bu yüzden gelen bir URI, elle parse edilecek bir string yığını yerine yine de gerçek bir nesneye çözümlenir:
composable<Screen.ItemDetail>(
deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
myapp://item/42 ile uygulama içi bir navigate(Screen.ItemDetail(42)), ikisi de aynı tipli argümanla aynı composable’a düşer — bir ItemDetail destination’ının neye benzediğine sahip olan tam olarak tek bir yer vardır; ister bir bildirim dokunuşundan, ister bir widget’tan, ister uygulama içindeki bir butondan ulaşılmış olsun.
Geçiş yapmadan önce bilinmesi gereken iki şey
Bir route’taki custom tipler kendi NavType’ına ihtiyaç duyar. Bir Long ya da String argümanı kutudan çıktığı gibi çalışır, ama bir enum ya da value class taşıyan bir route, composable çağrısına kayıtlı küçük bir NavType<T> implementasyonuna ihtiyaç duyar, çünkü back stack sonunda route’u serialize edilmiş bir string olarak saklar ve tipinizi bunun içine nasıl encode/decode edeceğini bilmesi gerekir. Birkaç satırlık bir iş, ama bedava değil — non-primitive bir argümana ihtiyaç duyan ilk ekranda buna zaman ayırın.
Root’tan değil, leaf’ten başlayarak geçiş yapın. En içteki ekranları, onları barındıran graph’lardan önce dönüştürmek, her route bitene kadar hiçbir şeyin derlenmediği bir big-bang rewrite yerine, string ve tip güvenli route’ları ilerlerken yan yana çalıştırmanızı sağlar. Ben tek seferde bir flow taşıdım — önce onboarding, sonra item detail, sonra settings — her birinden sonra navigasyonu elle doğruladım ve hiçbir zaman elimde tutarsız durumda bir avuçtan fazla dosya olmadı.
Sonuç daha hızlı navigasyon kodu değil, code review’dan sağ çıkmayı başaran bug sınıfının — yeniden adlandırılmış ama hâlâ yanlış tip olarak parse edilen bir argümanın — artık basitçe var olmadığı bir navigasyon kodu. Yazım hatasını yakalayacak ikinci bir reviewer’ı olmayan, solo bakımı yapılan bir uygulama için en çok önem taşıyan argüman bu.
// İlgili okumalar
Günlükten dahası
Android In-App Review API: sıkıcı olmadan puan istemek
Google'ın In-App Review API'sine pratik bir rehber — gerçekte nasıl çalıştığı, ne zaman tetiklenmesi gerektiği ve alışılmış 'bizi puanlayın' popup'ının Play Store puanınızı sessizce nasıl zedelediği.
Android uygulama kısayolları ve Hızlı Ayarlar Karosu: uygulamayı açmadan su kaydetmek
Android'in dinamik ShortcutManager API'sine ve TileService'e pratik bir rehber — tek dokunuşlu bir eylemin uygulamayı tamamen atlamasını nasıl sağlarsınız, çoğu uygulamayı çuvallatan tuzaklarla birlikte.
Android'de 2026'da ön plan servis türleri: özelliğinize gerçekten uyanı seçmek
Android'in ön plan servis türü kısıtlamalarına pratik bir rehber — dataSync, mediaPlayback, specialUse, shortService — ve öldürülmeden ya da reddedilmeden doğru olanı nasıl seçeceğiniz.