Hi all, I have a question about MVI vs MVVM architecture. Consider a large application, which has a ...
d
Hi all, I have a question about MVI vs MVVM architecture. Consider a large application, which has a lot of elements and UI states, let's take 50 state elements as an example.
Copy code
data class ExampleModel(
    val field1: Int = 0,
    val field2: Int = 0,
    val field3: Int = 0,
    ...
)
Typically we would model this as follows- 1. MVVM- we would have individual
liveData/stateFlow
for each state element, and whenever we want to update any state element, we would update the stateFlow for that particular element 2. MVI- we would encapsulate all the elements into a single
immutable data class
, and whenever we want to update any state element, we would make a copy of this data class object, and mutate the required property
The first question is whether MVI will cause a lot of memory overhead since we make a copy of the object every time we want to update any element of the state. In a timer application where the state can be modelled as
data class TimeModel(val remainingTime: String)
, and update the state as follows-
1. MVI-
stateFlow.update { timeModel.copy(remainingTime = timeModel.remainingTime - 1) }
2. MVVM-
stateFlow.update { it - 1 }
In both cases, we are making a new string object, but in MVI we are making a new object for
TimeModel
as well. And since kotlin introduced value classes to reduce the memory footprint of simple model classes, is MVI nullifying that advantage?
The second question is that while using jetpack compose, does it make more sense to use MVVM for bigger projects than MVI? In MVI, we would make a copy of the class object, thus changing its
hashCode
. So in every composable, we would need to have a
derivedStateOf
just to reduce the number of recompositions caused by other state updates, otherwise, it would trigger a recomposition even if some other state element is changing. Example below-
Copy code
@Composable
fun ExampleComposable1(field1: () -> Int) {
    println("ExampleComposable1: recomposing")
    Text(text = "field 1 is ${field1()}")
}
@Composable
fun ExampleComposable2(field2: () -> Int) {
    println("ExampleComposable2: recomposing")
    Text(text = "field 2 is ${field2()}")
}
@Composable
fun ExampleComposable3(field3: () -> Int) {
    println("ExampleComposable3: recomposing")
    Text(text = "field 3 is ${field3()}")
}
Copy code
data class ExampleModel(
    val field1: Int = 0,
    val field2: Int = 0,
    val field3: Int = 0,
)
var state by remember {
    mutableStateOf(ExampleModel())
}
Button(
    onClick = { state = state.copy(field1 = state.field1 + 1) }
) {
    Text(text = "update state 1")
}
ExampleComposable1(field1 = { state.field1 })
ExampleComposable2(field2 = { state.field2 })
ExampleComposable3(field3 = { state.field3 })
If we check the logs, we can see that all the 3 composables recompose every time even though we are updating only
field1
, and the other 2 are constant. To solve this problem, we would have to use
derivedStateOf
inside every composable to check if the value is changing or not, thus creating even more state objects, and increasing memory footprint. I understand this would not hamper an example application, but in a production application, where new features are added every day, this will become a huge problem.
🧵 1
c
Disclaimer: Definitions of the terms MVI/MVVM are always open to interpretation. The way I understand these terms, the difference between MVI and MVVM is in how the View interacts with the ViewModel/Presenter/Whatever. In MVVM, the ViewModel has one method per user interaction while in MVI there's a single method (let's call it
dispatch()
) that accepts an interface/sealed interface/sealed class. So in my understanding of these patterns, your first question is not valid. Neither MVVM nor MVI dictate the granularity of your state. Both patterns allow you to have stateFlows for individual elements or to have a single immutable data class the combines all state elements.
For your second question: A more realistic example would you have a top-level composable that accepts your
ExampleModel
as an input, but the closer you go to the leaves of your tree, the more likely that the composables accept more primitive types as inputs; and the more likely that those inputs did not change. I guess another way of putting this is - yes maybe composables closer to the top of the tree do recompose a lot but it is likely that those further near the leaves will skip recomposition. But also, remember that recomposition in itself is not evil. If your composables are not doing much then it is likely that recomposition is not even noticeable.
d
But most MVI libraries support one class in the viewmodel. Let's take orbit-mvi for an example (i've used this extensively). If we try to make separate stateFlows for each element of the state, that would mean creating separate viewmodels, or we abandon using a pre-built solution and create an in-house solution. As for the second point, creating composables that accept only primitive values is a little verbose, as the state in a typical production application is quite large, and to make sure that the composable function parameters are primitives would mean that the function signature is very large, potentially containing upwards of 15-20 parameters.
c
Right. Just to be clear, I wasn't advocating one flow per state element, just pointing out that the pattern itself is not opinionated about this. I use MVI now but in the past I used MVVM where I also had a single immutable data class to represent my state. I guess my point here is that there is no difference between MVI and MVVM in this regard. It is just a matter of how you (or the library you're using) chooses to interpret this pattern.
Regarding the second point, I think I did not explain it clearly enough. I'm not suggesting to have your top level screen accept primitive-ish inputs. Your top level screen (say,
FooScreen
) will still accept the
FooState
object. But
FooScreen
will be made up of several sections. Not every section requires all the pieces from
FooState
. So
FooScreen
will call
Section1
,
Section2
etc. I'm suggesting that these smaller composables that make up the sections of your screen do not necessarily need to take FooState as an input. They could take a small data class that is only relevant for them, or they could just operate on primitives. The implication is that maybe
FooScreen
recomposes very often but when
Section1
recomposes, maybe
Section2
is skipped, and so on.
You can see this pattern in action is large apps like Tivi, NowInAndroid etc. I've used it in multiple apps in production without noticeable performance issues.
m
Hi the problem "we can see that all the 3 composables recompose every time even though we are updating only `field1`" is not due to MVVM or MVI its due to the way you are passing the data to composable. By default if you pass same value to a composable it will skip recomposition, also make sure the data you are passing to composable are
@Stable
in your case as you are passing primitive types they are by default stable and instead of passing data through lambda, passing Int directly will skip recompositions.
also to reduce memory overhead, if you dont need previous data models you can use
MutableSharedFlow
and can set replay cache to 0 hence your previous models won't be remembered
d
If we pass primitives to our composable, then we won't we able to use
derivedStateOf
construct, as it won't be treated as a state read
For example, if we try to modify state and pass it as follows, the child doesn't recompose
Copy code
data class Counter(val intValue: Int)
var counter by remember {
    mutableStateOf(Counter(1))
}

// update counter state

StateParameter(counter = counter.intValue)
Copy code
@Composable
fun StateParameter(counter: Int) {
    val derivedCounter = remember {
        derivedStateOf { counter % 5 == 0 }
    }
    Text(text = "count is ${derivedCounter.value}")
}
c
How are you changing the counter value in this example?
If we pass primitives to our composable, then we won't we able to use derivedStateOf construct, as it won't be treated as a state read
Are you sure about that? As far as I know there's no difference in the way
derivedStateOf
treats primitives versus objects.
In any case it looks like this discussion is getting into an XY problem situation. I suggest you to consider rephrasing your question: what exactly are you trying to achieve? What's your end goal? There are several large apps including those in production that use the pattern of having a top level Composable that accepts a single immutable data class and then splits up this state into smaller units as it is passed down the tree. So this pattern itself is proven to be practical. If you could explain what's special or different about your app/architecture that prevents this pattern from being applied, then perhaps we could help.
d
i am updating the value of state as follows-
Copy code
counter = counter.copy(intValue = counter.intValue + 1)
my end goal is to have a single pattern that all the composables should follow with MVI, and the points of comparison being- 1. should we pass state inside a lambda so that we can use derived state, with the caveat that it will recompose everytime the parent state changes 2. should we pass state as a function parameter so that compose should skip unchanged parameters, with the caveat being that derived state cannot be used anywhere or we could have a pattern where both things work
490 Views