Part-6 State Hoist Mantığı. Selamlar , | by Muratdayan | Nov, 2024

Selamlar ,

Jetpack Compose yazı serimizin 6.partıyla devam ediyoruz. Bu yazıda State-hoist işlemlerini nerde ve nasıl yapmalıyız bunu konuşacağız.

Best Practice

State durumunu , onu değiştiren veya kullanan composable fonksiyonlar için en alt parent olarak kullanmalısınız.

UI State Türleri Ve Mantığı

Screen UI State-> Sayfada gösterilecek bileşenlerin durumunu tutar.

UI Element State-> Android’de xmlde örneğin Textview’in setText ve getText gibi state durumları kendi içinde zaten kontrol edilebilir ama Compose’da biz state’i composable fonksiyondan ayırıp dışarıda state olarak tutabiliyoruz ve state holder gibi bir ana merkezden yönetebiliyoruz. Bu duruma Element State denilir.

State Hoisting Her Zaman Gerekli Mi?

Eğer bir state başka hiçbir composable fonksiyonda da kullanılmıyorsa buna ihtiyacımız yoktur.UI Element state’i composable fonksiyonların içinde tutmak kabul edilebilir. Uyguladığınız state ve mantık basitse ve UI hiyerarşisinin diğer bölümleri state’e ihtiyaç duymuyorsa bu iyi bir çözümdür. Örneğin, bu durum genellikle animasyon durumu için geçerlidir.

Composable Fonksiyonlar İçinde Hoisting

Birden çok composable fonksiyonda aynı state kulanılacaksa bu state’i daha yüksek bir parent’a kaldırmalıyız(hoisting).

Yukarıdaki Örnekte JumpToBottom listenin en aşağısını gösterir ve listenin state’ini değiştirecektir.Ayrıca kullanıcı her yeni mesaj gönderdiğinde de liste en aşağı kayacaktır bu da listenin state’ini değiştirecektir.

Bu örneğin UI ağacını böyle varsayalım. LazyColumn’un state’i diğer composable fonksiyonlar tarafından da etkileneceği için Screen’e taşınır(hoisting). Bu örneğin kod kısmını aşağıya bırakıyorum.

@Composable
private fun ConversationScreen(/*...*/) {
val scope = rememberCoroutineScope()

val lazyListState = rememberLazyListState() // Screen düzeyinde State Hoist

MessagesList(messages, lazyListState) // MessagesList'e state gönderimi

UserInput(
onMessageSent = {
scope.launch {
lazyListState.scrollToItem(0)
}
},
)
}

@Composable
private fun MessagesList(
messages: List<Message>,
lazyListState: LazyListState = rememberLazyListState() // LazyListState default tanımlamaya sahiptir
) {

LazyColumn(
state = lazyListState // Lazy Column'a state gönderimi
) {
items(messages, key = { message -> message.id }) { item ->
Message(/*...*/)
}
}

val scope = rememberCoroutineScope()

JumpToBottom(onClicked = {
scope.launch {
lazyListState.scrollToItem(0) // UI düzeyinde logic
}
})
}

NOT: LazyListState’in, default değeri rememberLazyListState() olarak MessagesList fonksiyonunda parametre alırken tanımlandığına dikkat edin. Bu, Compose’da yaygın bir modeldir. Composable fonksiyonları daha yeniden kullanılabilir ve esnek hale getirir. Daha sonra composable’ı uygulamanın durumu kontrol etmesi gerekmeyen farklı bölümlerinde kullanabilirsiniz.

State Holder Class

Daha karmaşık UI işlemlerinde bu state görevleri bir state holder class’ına delege edilebilir. Bu kodumuzu daha temiz, kullanışlı ve test edilebilir yapar.(Seperation Of Concerns Princible).

Bu sınıflar, Composition’da oluşturulur ve hatırlanır. Composable’ın yaşam döngüsünü takip ettikleri için, rememberNavController() veya rememberLazyListState() gibi Compose kütüphanesi tarafından sağlanan türleri alabilirler. Yukarıdaki LazyListState’i bir class olarak tutsaydık aşağıdaki gibi olurdu.

@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
/**
* listenin kaydırılma durumlarını tutan state holder class
*/
private val scrollPosition = LazyListScrollPosition(
firstVisibleItemIndex, firstVisibleItemScrollOffset
)

suspend fun scrollToItem(/*...*/) { /*...*/ }

override suspend fun scroll() { /*...*/ }

suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState , LazyColumn’un kaydırma durumlarını takip eden bir state holder class halindedir. Composable bileşenlerin sorumlulukları arttığında bu sorumlulukları bir state holder’a aktarmak iyi bir yoldur.

ViewModel’da Business Logic State

ViewModeller composition’un bir parçası değildir. Ama Yukarıdak örnekte yapıldığı gibi inputMessage state’ini viewmodelden Screen’e sağlayabiliriz. Bu durumda da bizim State Holder class’ımız viewmodel olur.(lowest common ancestor)(source of truth)

Screen UI State

Screen UI State’lerde bir state holder’da tutulabilir. Aşağıdaki örnekte Viewmodel üzerinden anlatalım.

class ConversationViewModel(
channelId: String,
messagesRepository: MessagesRepository
) : ViewModel() {

val messages = messagesRepository
.getLatestMessages(channelId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)

// Business logic
fun sendMessage(message: Message) { /* ... */ }
}

Bu örnekte yukarıdaki viewmodel’i Screen sayfanıza enjekte edebilir ve viewmodel içinde sakladığınız state değerlerini kullanabilirsiniz. Aşağıda viewmodel ConversationScreen’e enjekte edilmiş ve messages state olarak izlenip değerleri alınıyor.

@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {

val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

ConversationScreen(
messages = messages,
onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
)
}

@Composable
private fun ConversationScreen(
messages: List<Message>,
onSendMessage: (Message) -> Unit
) {

MessagesList(messages, onSendMessage)
/* ... */
}

Property Drilling

Verilerin birkaç iç iç e composable fonksiyondan geçirilmesi anlamına gelir. Örneğin screen seviyesinde state tutup bunu alt composable bileşenlere aktarmak buna örnektir.

Eventleri paramatreler olarak almak Composable fonksiyonların sorumluluklarını bize en üst seviyede gösterir. Propert drilling bu parametrelerle sağlanmalı , wrapper classlar kullanmak karmaşıklığa yol açacaktır.

UI Element State

Chat app örneğini düşünelim.

kullanıcı @ işaretine dokunup yazmaya başladığında kullanıcıya kişiler öneriliyor ve kullanıcı seçtiği kişiyi etiketleyebiliyor. Bu öneriler veri tabanından gelir ve business logic olarak görünür. Aşağıdaki viewmodel’da bu durumu nasıl yazabiliriz görüyoruz.

class ConversationViewModel(/*...*/) : ViewModel() {

// Hoisted yapılan state
var inputMessage by mutableStateOf("")
private set

val suggestions: StateFlow<List<Suggestion>> =
snapshotFlow { inputMessage }
.filter { hasSocialHandleHint(it) }
.mapLatest { getHandle(it) }
.mapLatest { repository.getSuggestions(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)

fun updateInput(newInput: String) {
inputMessage = newInput
}
}

Örnekte inputMessage, TextField durumunu tutan bir değişkendir. Kullanıcı her yeni girdi yazdığında, uygulama öneriler üretmek için iş mantığını çağırır.

NOT: Eğer bu değişken, kullanıcı önerileri üretmek için şu anda ihtiyaç duyulduğu gibi business logic için gerekli olmasaydı, screen seviyesinde bir state holder olarak yükseltilmemeliydi. UI’da tanımlanmalı ve saklanmalı, buna ihtiyaç duyan composable fonksiyona daha yakın olmalıydı.

Dikkat

Viewmodel’deki state değişimleri sırasında bazı animasyon gibi işlemlerin cevap verme süresi olabilir bu durumu coroutine scopelarla dikkatli bir şekilde yönetmeliyiz.

Diyelim ki bir navigation drawer kullanıyosunuz ve bunun içeriği dinamik olarak açılıp ve kapatıldıktan sonra veri katmanından alıp yenilemeniz gerekiyor. Drawer state’ini ViewModel’e çekmelisiniz, böylece state holder olarak bu öğe üzerinde hem kullanıcı arayüzünü hem de iş mantığını çağırabilirsiniz. Ancak DrawerState’in close() yöntemini Compose UI’dan viewModelScope kullanarak çağırmak, “bu CoroutineContext’te bir MonotonicFrameClock mevcut değil” şeklinde bir mesajla IllegalStateException türünde bir hataya neden olur. Bunu düzeltmek için Composition’a kapsamlandırılmış bir CoroutineScope kullanın. Bu, CoroutineContext’te askıya alma işlevlerinin çalışması için gerekli olan bir MonotonicFrameClock sağlar.

NOT: MonotonicFrameClock, Jetpack Compose’da ve genel olarak Kotlin Coroutines’de kullanılan bir zamanlayıcıdır. Monotonik zamanlayıcılar, zamanın sürekli ve kesintisiz olarak ilerlemesini garanti ederler, bu da özellikle animasyonlar ve zamanlama gerektiren görevler için önemlidir.

Bu anlatılanı kod olarak incelemek isterseniz aşağıya bırakıyorum.

class ConversationViewModel(/*...*/) : ViewModel() {

val drawerState = DrawerState(initialValue = DrawerValue.Closed)

private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

fun closeDrawer(uiScope: CoroutineScope) {
viewModelScope.launch {
withContext(uiScope.coroutineContext) { // Default Context yerine coroutine context kullanımı
drawerState.close()
}
// Drawer içeriğini çekme ve güncelleme
_drawerContent.update { content }
}
}
}

@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val scope = rememberCoroutineScope()

ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

Sonuç

Bu yazıyla beraber bir state’in nerelerde nasıl hoist edilebileceğini ve best practice olarak neler yapılabileceğini konuştuk. State holder classları konuştuk. Bu yapılar uygulamamızın test edilebilirliğini, yeniden kullanılabilirliğini, karmaşıklığını, kod temizliğini düzenleyen yapılar olduğu için temiz bir mimari için dikkatle uygulanması gereken konulardır.

Okuduğunuz için teşekkürler:)

Bir daha ki yazıda görüşmek üzere!

Kaynaklar

https://developer.android.com/develop/ui/compose/state-hoisting

Leave a Reply