Android Uygulama Geliştirmede MVC, MVP, MVVM ve MVI Mimarilerinin Kullanımı(Bölüm 2)
MVI Mimarisi
MVI mimarisi, Model, View, Intent olmak üzere üç ana parçadan oluşur. View, kullanıcının gördüğü kısımdır. Model, ekranın o anki State’ini tutar. Kullanıcı ekranla etkileşime geçtiğinde ortaya çıkan Intent’ler, State’i günceller ve View yeni State ile tekrar çizilir.
Model, önceki mimarilerden farklı olarak UI State’i ifade etmektedir.
data class CounterScreenState(
val isLoading: Boolean = false,
val totalCount: Boolean? = null,
)
UI state immutable’dır, yani herhangi bir alan değiştirilemez. Eğer herhangi bir alan değiştirilmek isteniyorsa, state objesi tekrar yaratılmalıdır.
View, ekranda görünecek nesnelerin çizildiği ve kullanıcının etkileşime geçtiği yerdir.
Intent’ler, kullanıcıdan gelen aksiyonlardır. Örneğin, bir increment işlemi UI tarafından gelen bir intent olabilir
CounterViewModel {
fun increment(){
//update uı state
....
}
}
CounterApp’i MVI mimarisi ile oluşturalım.
Repository: Count verimizi CounterRepository içerisinde tutacağız.
class CounterRepository {
private var counter: Int = 0
suspend fun increment() {
delay(1000)
counter += 1
}
suspend fun decrement() {
delay(1000)
counter -= 1
}
fun getCount(): Int {
return counter
}
}
State ve Effect interface’lerimizi oluşturalım. Ardından kendi kullanacağımız State ve Effect sınıflarımızı oluşturalım. Oluşturacağımız State ve Effect classlarını bir arada tutmak için CounterContract içerisinde oluşturabiliriz, bu sayede Counter Sayfası ile ilgili her şey tek bir yerde olur.
interface UIState
interface SideEffect
class CounterContract {
data class State(
val count: Int = 0
) : UIState {
sealed class Effect : SideEffect {
data class ShowToast(val message: String) : Effect()
object OnNavigateSomePage : Effect()
}
}
State, View çizdirilirken ihtiyaç duyulan bilgileri içerir. Effect ise State’i etkilemeyen durumlar olarak düşünülebilir. Örneğin; navigasyon, Toast mesajı veya Snackbar gibi durumlar olabilir.
Şimdi Container sınıfımızı oluşturalım.
Bu sınıf, Effect ve State’i yönetecek ve aynı zamanda State’i Thread-Safe bir şekilde güncelleyecektir.
class Container<STATE, SIDE_EFFECT>(val coroutineScope: CoroutineScope, initialState: STATE) {
private val _uiState: MutableStateFlow<STATE> =
MutableStateFlow(initialState)
val uiState = _uiState.asStateFlow()
private val _effect: Channel<SIDE_EFFECT> = Channel(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
fun intent(transform: suspend Container<STATE, SIDE_EFFECT>.() -> Unit) {
coroutineScope.launch {
transform(this@Container)
}
}
suspend fun reduce(reducer: STATE.() -> STATE) {
withContext(COUNTER_THREAD) {
_uiState.value = reducer(_uiState.value)
}
}
suspend fun postSideEffect(effect: SIDE_EFFECT) {
_effect.send(effect)
}
companion object {
private val COUNTER_THREAD = newSingleThreadContext("CounterThread")
}
}
Container’ın içinde yer alan uiState
alanını, state'teki değişiklikleri dinlemek için kullanacağız. effect
alanı ise tek seferlik gerçekleşecek olayları dinlemek için kullanacağız.
Kısaca, neden Channel
ve StateFlow
kullandığımıza değinmek gerekirse; Channel
ile veriyi alıp bir kere okuduktan sonra bir daha okuyamayız. StateFlow
ise son emit edilen değeri tutar.
Effect’leri iletmek için postSideEffect
fonksiyonunu kullanacağız.
reduce
fonksiyonunu kullanarak state'i güncelleyeceğiz. Aynı anda birden fazla Thread’in state’e erişmeye çalışıp Race Condition oluşturabileceği için en iyi yol olmasa da basitleştirmek adına yeni bir thread kullanabiliriz.
intent
fonksiyonu, suspend fonksiyonları class constructor'ında gelen coroutine scope ile çağırarak bu sayede ViewModel içerisinde sürekli viewModel.launch{}
bloğunu çağırmaya ihtiyaç duymayacağız.
ViewModel’ı yaratalım.
class CounterViewModel() : ViewModel() {
val container = Container<CounterContract.State, CounterContract.Effect>(
viewModelScope, CounterContract.State(0)
)
private val repository = CounterRepository()
fun onIncrementClick() = container.intent {
repository.increment()
val count = repository.getCount()
reduce {
copy(count = count)
}
}
fun onDecrementClick() = container.intent {
repository.decrement()
val count = repository.getCount()
reduce {
copy(count = count)
}
}
fun onShowToastClick() = container.intent {
container.postSideEffect(CounterContract.Effect.ShowToast("Hello World"))
}
}
onIncrementClick
veya onDecrementClick
çağırıldığında, repository ile veri güncellenir ve gelen değer kullanılarak state, reduce
fonksiyonu ile güncellenerek View’ın tekrardan çizilmesini sağlar.
Counter Screen’i oluşturalım.
@Composable
fun CounterScreen() {
val viewModel: CounterViewModel = viewModel()
val state by viewModel.container.uiState.collectAsStateWithLifecycle()
val effect by viewModel.container.effect.collectAsStateWithLifecycle(null)
val context = LocalContext.current
when (effect) {
is CounterContract.Effect.ShowToast -> {
Toast.makeText(
context,
(effect as CounterContract.Effect.ShowToast).message,
Toast.LENGTH_SHORT
).show()
}
else -> {
}
//other effects
..
}
Column(Modifier.fillMaxSize()) {
Text(text = state.count.toString())
Button(onClick = { viewModel.onIncrementClick() }) {
Text(text = "Increment")
}
Button(onClick = { viewModel.onDecrementClick() }) {
Text(text = "Decrement")
}
Button(onClick = { viewModel.onShowToastClick() }) {
Text(text = "Toast Message")
}
}
}
Son durumda aşağıdaki gibi bir örnek akış gerçekleşmekte.
- Kullanıcı increment butonuna basar.
- ViewModel’da karşılanan intent Repository’den datayı günceller ve güncel count bilgisini çeker.
- Güncel state bilgisi kopyalanarak yeni gelen count bilgisi ile güncellenir.
- Güncellenen state bilgisi, Composable View’ın tekrardan ekrana çizilmesini sağlar.
Artılar
- View tarafından tek bir alan observe edilir. MVVM’de olduğu gibi birden fazla alan bulunmaz, bu da daha temiz bir yapı sağlar.
- State sadece ViewModel tarafından güncellenir, bu sayede ortaya beklenmeyen durumlar çıkmaz.
- State ve View arasındaki tek yönlü bir akış vardır, bundan dolayı bug’ların bulunması daha kolaydır.
Eksiler
- State’teki herhangi bir değişiklik state objesinin tekrardan yaratılmasına neden olacağı için çok fazla obje yaratılabilir ama modern cihazlarda sorun yaratmayacaktır.
- MVVM mimarisine göre bir miktar boilerplate kod yazmak gerekebilir.
- UI State’in stable olmasına dikkat etmezsek View’ın defalarca kez çizilmesine sebep olabilir bu da performans sorunlarına yol açacaktır.
Her seferinde yukarıdaki yapıyı oluşturmaya çalışmak yerine hali hazırda yazılmış popüler bir MVI kütüphanesi kullanmak daha mantıklı olabilir. Ayrıca kullanacağımız kütüphane Unit test desteği, State handling desteği ve daha iyi bir Thread yönetimi gibi konuları da bizim yerimize düşünmüş olacaktır.
Ford Otosan’da denemeye başladığımız Orbit kütüphanesini deneyebilirsiniz. Orbit kütüphanesinin yapısı da, yazdığımız MVI yapısına oldukça benzemektedir.
Kaynaklar: