Thread
#compose
    j

    julioromano

    1 year ago
    Question about recomposition granularity Given this composable with state:
    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? :thank-you:
    k

    KamilH

    1 year ago
    I see you edited it a little bit, but taking your previous version and defining
    MyButton
    like that:
    @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:
    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

    1 year ago
    Great thanks! That’s actually the case when I define my activity as:
    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 {}
    ):
    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?
    Adam Powell

    Adam Powell

    1 year ago
    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 Texts.
    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

    1 year ago
    Wow! This was a lot to swallow all at once 😃 Let’s double check with one last example: Example 1:
    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:
    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!
    Adam Powell

    Adam Powell

    1 year ago
    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

    1 year ago
    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!
    Adam Powell

    Adam Powell

    1 year ago
    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.
    Chuck Jazdzewski [G]

    Chuck Jazdzewski [G]

    1 year ago
    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.
    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.
    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.