İçeriğe geç
Tüm yazılar

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.

MFKAPPS 4 dk okuma

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 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.