https://kotlinlang.org logo
j

julioromano

01/21/2021, 12:42 PM
Question about recomposition granularity Given this composable with state:
Copy code
data class State(
    val firstCounter: Int = 0,
    val secondCounter: Int = 0
)

@Composable
fun MyScreen(
    stateFlow: StateFlow<State>,
    onClick1: () -> Unit,
    onClick2: () -> Unit,
) {
    val state by stateFlow.collectAsState()
    Column {
        Button(onClick = onClick1) {
            Text(text = "One ${state.firstCounter}")
        }
        Button(onClick = onClick2) {
            Text(text = "Two ${state.secondCounter}")
        }
    }
}
If only one field of the
state
object changes will it trigger recomposition of only one (the one that’s reading that field) or both buttons ? Can someone point to some docs or examples to learn a bit more how recomposition actually works? 🙏
🙏 1
k

KamilH

01/21/2021, 1:02 PM
I see you edited it a little bit, but taking your previous version and defining
MyButton
like that:
Copy code
@Composable
fun MyButton(text: String, number: Int, onClick: () -> Unit) {
    onCommit(callback = {
        Log.i("MyButton", "committed: $text, $number")
    })
    Button(onClick = { onClick() }) {
        Text(text = "$text value: $number")
    }
}
you can discover it by yourself 🙂 From the documentation:
Copy code
The onCommit effect is a lifecycle effect that will execute [callback] every time the composition commits.
So, if I’m not mistaken this callback is called every time recomposition happens 🙂
To answer your question: if only one field of State object changes, it will trigger recomposition of only one button (tbh, I’m a bit surprised, because I thought be need
@Immutable
annotation to achieve that)
j

julioromano

01/21/2021, 2:30 PM
Great thanks! That’s actually the case when I define my activity as:
Copy code
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val stateFlow = MutableStateFlow(State())
        setContent {
            MyApplicationTheme {
                MyScreen(
                    stateFlow = stateFlow,
                    onClick1 = {
                        stateFlow.value = stateFlow.value.copy(
                            firstCounter = stateFlow.value.firstCounter + 1
                        )
                    },
                    onClick2 = {
                        stateFlow.value = stateFlow.value.copy(
                            secondCounter = stateFlow.value.secondCounter + 1
                        )
                    }
                )
            }
        }
    }
}
But things change if I change the Activity and make it like this (basically removing the
MyScreen
composable and throwing everything into
setContent {}
):
Copy code
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val stateFlow = MutableStateFlow(State())
        setContent {
            MyApplicationTheme {
                val state by stateFlow.collectAsState()
                Column {
                    MyButton(
                        text = "One ${state.firstCounter}",
                        onClick = {
                            stateFlow.value = stateFlow.value.copy(
                                firstCounter = stateFlow.value.firstCounter + 1
                            )
                        }
                    )
                    MyButton(
                        text = "Two ${state.secondCounter}",
                        onClick = {
                            stateFlow.value = stateFlow.value.copy(
                                secondCounter = stateFlow.value.secondCounter + 1
                            )
                        }
                    )
                }
            }
        }
    }
}
In this case changing just one field triggers recompose of both buttons. Why is that?
a

Adam Powell

01/21/2021, 3:21 PM
there are several factors at play here, both invalidation and skipping.
Plus where compose creates recompose scopes
In general, unless you declare a composable function
inline
or use some special compose compiler directive annotations, a recompose scope is created by each composable function body. This is both for composables declared with
fun
as well as composable lambda blocks
when a snapshot state object is accessed during composition, that state object is recorded along with the current recompose scope. When snapshot apply notifications are dispatched, the snapshot state objects that were changed are mapped back to any recompose scopes where they were read, and those recompose scopes are invalidated.
When one or more recompose scopes are invalidated, we schedule recomposition for that frame, and we restart the composable functions of those recompose scopes in the order they appeared in the composition, and those function bodies will run again.
Skipping happens when a composable function has stable parameters and those parameters have not changed since the last recomposition. We don't have to run a composable that you call during recomposition if nothing about it changed.
moving code around between functions both changes where recompose scopes are placed that can invalidate independent of the rest of the composition, and changes what work can skip at those function boundaries.
At a high level, all of this is performance optimization and shouldn't affect the correctness of your code. Composable functions should always be idempotent; it should never matter if the same function runs 100 times for the same parameters or once.
So to figure out where recompositions happen in the original code snippet, look for the state reads. The only snapshot state object in the example is the one returned by
collectAsState
. Your
data class State
object is just a plain data class that involves no snapshot state properties.
This code reads
state
in two places: each one of the string interpolations to create the text for each
Text
composable. If
state
changes, each of these must recompose to create new input strings for the `Text`s.
The
Button
composable is not declared
inline
, so that means that the current recompose scope for those two reads of
state
that create new strings will be the content body of each respective
Button
call.
Now, just because both
Button
content body blocks recompose doesn't mean they'll have a lot of work to do. They'll create new strings from the new value of
state
, but if those resulting strings match their old values, the calls to
Text
may skip, since there isn't new work to do.
j

julioromano

01/21/2021, 4:57 PM
Wow! This was a lot to swallow all at once :) Let’s double check with one last example: Example 1:
Copy code
val state by stateFlow.collectAsState()
    Column {
        // Scope A
        Button(onClick = onClick1) {
            // Scope B
            Text(text = "One ${state.firstCounter}")
        }
        Button(onClick = onClick2) {
            // Scope C
            Text(text = "Two ${state.secondCounter}")
        }
    }
In example 1
state
is read twice, one from within Scope B and one from within Scope C. Example 2:
Copy code
val state by stateFlow.collectAsState()
    Column {
        // Scope A
        MyButton(
            text = "One ${state.firstCounter}",
            onClick = onClick1
        )
        MyButton(
            text = "Two ${state.secondCounter}",
            onClick = onClick2
        )
    }
In example 2
state
is still read twice, but both reads happen within Scope A. Therefore when
state
changes: - In example 1 both `Button`s are always invalidated and re-run, but any
Text
composable whose input
text
param doesn’t change is skipped. - In example 2 it’s actually
Column
that gets invalidated and re-run, but any
MyButton
composable whose input
text
param doesn’t change is skipped. Can this “skipping” be regarded as some form of memoization? Thanks a lot for the explanation!
a

Adam Powell

01/21/2021, 5:01 PM
At a high level, yes, you've got it. Stability determination throws a bit of a wrench into when we can skip and still be correct occasionally, so I'm deliberately hand-waving a bit here
if a composable accepts unstable parameters (including
this
and receivers!) then we can't know it's safe to skip, so we won't
"stable" meaning anything that meets the
@Stable
contract, which these days we also infer through static analysis at compilation time
(and primitives and strings also count)
we also do some related analysis and memoization of lambda expressions if their capture is entirely made up of stable values
which then makes the result of those lambda expressions stable
j

julioromano

01/21/2021, 5:08 PM
Stability determination throws a bit of a wrench into when we can skip and still be correct occasionally, so I’m deliberately hand-waving a bit here
In fact while writing I wasn’t sure whether the
onClick
param could actually prevent the skipping for
MyButton
: one thing is to check equality on a string, but checking it on a lambda doesn’t seem equally simple.
Very thorough explanation though, makes me eager to dig even more. Thanks again!
👍 1
a

Adam Powell

01/21/2021, 7:43 PM
yes that's exactly right re. the
onClick
lambda. Setting a
StateFlow
value in that lambda as opposed to setting a
mutableStateOf()
snapshot state object (which we know is stable) likely behaves differently here.
c

Chuck Jazdzewski [G]

01/21/2021, 9:53 PM
Can this “skipping” be regarded as some form of memoization?
This is exactly what it is but we call it Positional Memoization because we consider the "position" of the call in the call graph as an implicit memoization parameter.
🙏 1
For
onClick
we use referential equality for lambdas. For lambda expressions in composable functions we will
remember
the lambda instance based on the values it captures.
🙏 1
As for
@Immutable
the compiler will now infer
@Stable
(the more general annotation) for types such as
State
class at the beginning of the thread. In most cases, you no longer need to mark a class as
@Stable
as we will infer it. The composable function includes checks for stability of inferred types crossing module boundaries but in such a way that R8 can remove the checks for the application using the modules.
🙏 2
4 Views