In this post, I am going through the process of investigating the code and figuring out what is going on.
The app I got was very simple. One slider, button and text. We can move the slider and clicking on the button changes text value. Both text and slider are stored in the same state object in the ViewModel.
Desired behaviour was the following. Whenver sliderValue
changes only MainScaffold
is re-composed, not CounterRow
. However, given code was also updating CounterRow
and I wanted to know why.
To start investigation, I need to share with you the code I was given:
class MainActivity : ComponentActivity() { | |
private val viewModel by viewModels<MainViewModel>() | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContent { | |
ComposeStateTestTheme { | |
val state: MainState by viewModel.state.collectAsState() | |
MainScaffold( | |
state, | |
onValueUpdate = { viewModel.updateSlider(it.roundToInt()) }, | |
onButtonClick = { viewModel.updateCounter() } | |
) | |
} | |
} | |
} | |
} |
@Composable | |
fun MainScaffold(state: MainState, onValueUpdate: (Float) -> Unit, onButtonClick: () -> Unit) { | |
Scaffold(modifier = Modifier.fillMaxSize()) { | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 12.dp) | |
.verticalScroll(rememberScrollState()) | |
) { | |
Slider( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 12.dp), | |
value = state.sliderValue.toFloat(), | |
onValueChange = onValueUpdate, | |
valueRange = 0f..10f, | |
steps = 10 | |
) | |
CounterRow(counter = state.counter, onButtonClick = onButtonClick) | |
} | |
} | |
} | |
@Composable | |
fun CounterRow(counter: Int, onButtonClick: () -> Unit) { | |
/** SHOULD NOT BE CALLED ON SLIDER CHANGE **/ | |
Row(modifier = Modifier.fillMaxWidth()) { | |
Button(onClick = onButtonClick) { | |
Text(text = "Click me!") | |
} | |
Spacer(modifier = Modifier.width(24.dp)) | |
Text(text = counter.toString()) | |
} | |
} |
data class MainState( | |
val sliderValue: Int = 0, | |
val counter: Int = 0 | |
) |
class MainViewModel : ViewModel() { | |
private val _state = MutableStateFlow(MainState()) | |
val state: StateFlow<MainState> | |
get() = _state.asStateFlow() | |
fun updateCounter() { | |
_state.value = state.value.copy(counter = state.value.counter.plus(1)) | |
} | |
fun updateSlider(value: Int) { | |
_state.value = state.value.copy(sliderValue = value) | |
} | |
} |
What we are examining is the following composable:
@Composable fun CounterRow(counter: Int, onButtonClick: () -> Unit) { /** SHOULD NOT BE CALLED ON SLIDER CHANGE **/ Row(modifier = Modifier.fillMaxWidth()) { Button(onClick = onButtonClick) { Text(text = "Click me!") } Spacer(modifier = Modifier.width(24.dp)) Text(text = counter.toString()) } }
With the original code whenever we moved the slider, CounterRow
was re-created. How do I know? Just put a logging call inside the function or a non-suspendable breakpoint inside that prints to logs whenever a function is executed.
Property to blame
The first thought was that it’s maybe the counter
parameter in CounterRow
function because it comes from the MainState
object and that object changes whenever the slider is moved. However, it’s not the case because what matters for re-composition here is the fact that the parameter is a primitive type Int
and on subsequent compositions value is not changing.
So I told to myself, let’s check the second param and that was it. A simple check with removing the param proved I am right:
@Composable fun CounterRow(counter: Int) { /** onButtonClick removed from params **/ Row(modifier = Modifier.fillMaxWidth()) { Button(onClick = {}) { Text(text = "Click me!") } Spacer(modifier = Modifier.width(24.dp)) Text(text = counter.toString()) } }
Documentation to the rescue
I knew what causes re-composition but I didn’t know why. So it was time to look into documentation. Fortunately, Google did a good job with documenting Jetpack Compose. If you wanna learn about re-composition check out the Lifecycle section in the documentation. I will be referring to that section to explain what is going on.
Below you will find the most important part of documentation explaining what it takes for inputs of composable (function parameters) to be skippable on the next composition.
After reading that part carefully a few times I was puzzled. I was passing primitive Int
and lambda of a type () -> Unit
that never changes. But is it really never changing?
Job Offers
Let’s examine MainActivity
where callback is created:
ComposeStateTestTheme { val state: MainState by viewModel.state.collectAsState() MainScaffold( state, onValueUpdate = { viewModel.updateSlider(it.roundToInt()) }, onButtonClick = { viewModel.updateCounter() } ) }
As you can see it is created whenever MainScaffold
is created. Since state
changes on slider change, our MainScaffold
along withonButtonClick
is also recreated. Compose uses equals()
to check if composable’s input has changed and it did in this case. You can easily test it by putting another breakpoint or log message with lambda’s hashCode()
. I could end here but I wanted to know what can I do to actually avoid that re-composition for CounterRow
.
My initial thought was to move { viewModel.updateCounter() }
to a variable so it’s reused every time:
val state: MainState by viewModel.state.collectAsState() val onButtonClick = { viewModel.updateCounter() } MainScaffold( state, onValueUpdate = { viewModel.updateSlider(it.roundToInt()) }, onButtonClick = onButtonClick ) }
Problem solved, right? Not so fast my friends 🙂
val onButtonClick = { viewModel.updateCounter() }
I have found out that the above line is also called again when state
changes. Everything inside ComposeStateTestTheme
is called again since inside that composable our state
is read. So the solution to the problem is moving lambda creation out of it:
setContent { val state: MainState by viewModel.state.collectAsState() val onButtonClick = { viewModel.updateCounter() } ComposeStateTestTheme { MainScaffold( state, onValueUpdate = { viewModel.updateSlider(it.roundToInt()) }, onButtonClick = onButtonClick ) } }
Moving state
out is not needed at all but it looks a bit cleaner to have it on the same level. Now we are done. Problem solved.
EDIT:
had a very good point in the comment. We don’t have to move lambda but instead use method reference:
setContent { ComposeStateTestTheme { val state: MainState by viewModel.state.collectAsState() MainScaffold( state, onValueUpdate = { viewModel.updateSlider(it.roundToInt()) }, onButtonClick = viewModel::updateCounter ) } }
That way we are reusing existing function in the ViewModel every time re-composition happens.
key()
There is one more thing I tried but decided to write about it last because that wasn’t that relevant yet a bit surprising.
Documentation mention key
function to be very useful if we need to explicitly tell what should be used to compare composable on re-composition. I thought that maybe that can solve the problem for me, if I wrap my CounterRow
with it, like this:
key(state.counter) { CounterRow(counter = state.counter, onButtonClick = onButtonClick) }
I was mistaken. It doesn’t work like that. In the docs, there is an example with a collection of movies and a movie being injected at the top of the collection. Each movie composable is associated with the collection index. So moving all movies down changes the index for all of them. But, when re-composition happens old composables are not gone. First, compose checks which can be re-used and key()
helps with that.
In our case, there is just 1 old composable and it has different input than a new one. So key()
is not able to point another composable because there is none.
Conclusion
I hope this study will help others to understand better Compose and encourage to make own. I believe it’s very important to dive deep and understand how things work. Especially now, when Jetpack Compose is super fresh and new for everyone. It’s very easy to get messy in more complex cases if we don’t understand the basics.
Happy coding!