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

Jetpack Compose'da StateFlow ve SharedFlow: replay bug'ı olmadan UI state modellemek

Bir Compose ViewModel'inde StateFlow'u ne zaman, SharedFlow'u ne zaman kullanmalısınız — ve ikisini karıştırınca ortaya çıkan tek seferlik olay bug'ı.

MFKAPPS 4 dk okuma

Ürettiğim her Compose ekranı sonunda aynı soruyu soruyor: bu bilgi, ekranın şu anda neye benzediğini mi tarif ediyor, yoksa bir kez gerçekleşmiş bir şeyi mi? Cevabı yanlış aldığınızda belirgin, can sıkıcı bir bug ortaya çıkıyor — rotasyondan sonra tekrar beliren bir toast, iki kez tetiklenen bir navigasyon olayı, kullanıcı uygulamayı arka plana alıp yeniden açtığında tekrar oynayan bir “seans tamamlandı” kutlaması. Çözüm bir workaround değil. Doğru Flow tipini en baştan seçmek.

Kotlin, çağrı noktasından birbirinin yerine geçebilirmiş gibi görünen iki hot flow sunuyor — ikisi de collectAsStateWithLifecycle() ya da bir LaunchedEffect ile toplanıyor, ikisi de bir ViewModel içinde yaşıyor, ikisi de configuration change’lerden sağ çıkıyor. Ama birbirlerinin yerine geçemezler ve fark tam olarak yukarıdaki ayrım.

StateFlow: her zaman bir değeri var, her zaman onu replay ediyor

StateFlow, state için tasarlandı — kimse izlemese bile her an bir mevcut değeri olan veri. Bir zamanlayıcının kalan saniyesi, bir formun doğrulama hataları, bir listenin loading/error/success durumu. Yeni collector’lar hemen en son değeri alır ve her collector her zaman aynı şeyi görür.

private val _uiState = MutableStateFlow(TimerUiState.Idle)
val uiState: StateFlow<TimerUiState> = _uiState.asStateFlow()

fun start(durationMinutes: Int) {
    _uiState.value = TimerUiState.Running(
        remainingSeconds = durationMinutes * 60,
    )
}

“Yeni collector’lara en son değeri replay etme” davranışı StateFlow’un bütün amacı — bir Compose ekranını configuration change sonrasında doğru yapan şey de bu. Cihazı döndürün, Composable recompose olsun, yeni bir collector bağlansın; bir sonraki emission’ı bekleyen boş bir ekran yerine mevcut state’i hemen render eder.

SharedFlow: gerçekleşmiş bir şeyi tarif eder

Aynı replay davranışı, tek seferlik bir olay için tam olarak yanlış. Diyelim ki bir Pomodoro seansı bitti ve tamamlanma anında bir “aferin” sesi ile ince bir kutlama animasyonu tetiklemek istiyorsunuz — bu, ekran her recompose olduğunda değil, bir kez, gerçekleştiği anda olmalı.

Bu olay tamamlanmada true’ya set edilen bir StateFlow<Boolean> olarak modellenirse, her yeni collector — rotasyondan sonra oluşturulan ya da kullanıcı uygulamadan çıkıp geri döndüğünde oluşturulan dahil — hemen true’yu görür ve kutlamayı tekrar oynatır. İnsanların ilk başvurduğu çözüm genellikle manuel bir reset’tir (_sessionComplete.value = false, tüketildikten hemen sonra), bu da iki collector değeri okumak için yarışana ya da reset yanlış dispatcher’da çalışıp flag zamanında geri dönmeyene kadar işe yarar.

Burada doğru primitive, hiç replay yapmayan SharedFlow, çünkü bir “mevcut değer” tutmuyor bile — sadece emission anında aktif olarak subscribe olmuş collector’lara yayın yapıyor:

private val _events = MutableSharedFlow<TimerEvent>()
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()

private fun onSessionComplete() {
    viewModelScope.launch {
        _events.emit(TimerEvent.SessionComplete)
    }
}

Olay tetiklendiğinde dinlemiyor olan bir collector onu basitçe hiç görmez — bu bir kutlama animasyonu için doğru, ama zamanlayıcının state’i için yanlış olurdu. Bu asimetri kararın tamamı: geç subscribe olan biri mevcut değeri görmeliyse state’tir; görmemesi gerekiyorsa olaydır.

Gerçekten işe yarayan pratik kural

Kararı vermek için tek bir soru kullanıyorum ve bu, kurduğum her ekranda tutarlı çalıştı: yepyeni bir collector’ın bu değeri hemen almasının bug olması gerekir mi?

  • Zamanlayıcı state’i, form içeriği, loading flag’leri, liste içeriği → hayır, yeni bir collector mevcut değeri görmeli → StateFlow.
  • “Bu snackbar’ı göster,” “bu ekrana git,” “bu sesi çal” → evet, geç subscribe olana bunu replay etmek bug’ın ta kendisi → SharedFlow(replay = 0).

Bilinmeye değer tek istisna: SharedFlow(replay = 1) var ve bir orta yol gibi görünüyor, ama pratikte bu, “her zaman bir değeri var” garantisi ve senkron okuma için .value olmadan bir StateFlow’dan farksız — state modelliyorsanız gerçek bir StateFlow’a, olay modelliyorsanız SharedFlow(replay = 0)’a başvurmak yerine buna geçmek için nadiren bir sebep var.

Bir Compose ekranına bağlamak

İkisi farklı şekilde tüketiliyor ve bunu karıştırmak, bu bug’ın ikinci en yaygın versiyonu:

@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                TimerEvent.SessionComplete -> playCompletionSound()
            }
        }
    }

    TimerContent(uiState)
}

collectAsStateWithLifecycle() StateFlow içindir — Compose state olarak ortaya koyacağı bir mevcut değere ihtiyaç duyar. events ise bunun yerine bir LaunchedEffect içinde toplanır, tam olarak tutacak bir mevcut değeri olmadığı için; o, okunacak bir değer değil, tepki verilecek bir akış.

Sonuç

Bu bug’ın önlediği şey genelde fark edilmeden shipping’e çıkacak kadar ince — rotasyonda tekrarlanan bir toast, uygulama arka plandan dönünce yeniden tetiklenen bir animasyon — çünkü hiç ayrılmayan tek bir collector’ın olduğu yaygın durumda her şey normal görünüyor. Sadece bir ekran collector’ını yeniden başlattığında ortaya çıkıyor, Compose’un her configuration change’de yaptığı da tam olarak bu. State’i StateFlow ile modelleyin çünkü yeni collector’ların yakalaması gerekiyor. Olayları replay yapmayan bir SharedFlow ile modelleyin çünkü gerekmiyor. Bu ayrımı Mintly’nin zamanlayıcısında kullanıyorum: çalışan geri sayım, her recomposition’ın doğru yansıtması gereken bir state; seans sonu kutlaması ise tam olarak bir kez tetiklenmesi gereken bir olay — asla iki kez, az önce yeniden açılmış bir ekranda asla.