Hello there fellas! Not so long ago I created this...
# compose
k
Hello there fellas! Not so long ago I created this stack question about recomposition: https://stackoverflow.com/questions/76797152/jetpack-compose-recomposition-and-recompositions-scopes-how-does-it-accually-w It is a long and maybe quite confusing question so let me just wrap it into a simpler one: How does recomposition really work? Maybe some under the hood explanation? Do we have some docs/articles about this other then
donut-hole skipping
one written by Vinay Gaba?
a
Pretty much all articles about this are based on talks by Compose engineers themselves, or compiled from docs, code, etc. If you want deep dives, you'd have to go through the compiler's code yourself, or alternatively look at a non-minified decompiled APK and see what it looks like.
Finding videos by them is a bit difficult (they're not contained in a single YouTube channel), but maybe some folks have them bookmarked and can share a few things with you
k
Ye, Android guys could have done a better job at maintaining the playlists of theirs. Those 1 h long live sessions that at 40 minutes mark have 1 or 2 useful insight are a nightmare to watch.
a
How does recomposition really work?
This "simple" question is the hardest to answer because there are so many things under the hood. Looks like the main part you want to ask about is recompose scope? The "donut-hole skipping" one and the one by Zach are both good posts on recompose scope. If you have read them, it might be better to ask more specifically on the part you didn't understand.
k
@Albert Chang There are a ton of more specific questions with example code in my stackoverflow question, but its a long read 😅 But without the code examples its hard to state the specific questions :< But If you ever feel like you have time to spare then please take a look 😄
@vide Already read this one, its a good one, but still some questions remain. For more specific questions I encourage to take a look into stackoverflow link. Its not like I try to lure people into stackoverflow, I just think that there is a lot of code and questions there and pasting it all here would be worse then just linking it.
v
@Kacper your stackoverflow question is quite large in scope 😅 I tried to simplify at least the first question:
Copy code
@Composable
fun Component(counter1: Int) = Column {
    LogCompositions("Component")
    Text(counter1.toString())
    CustomBlock { LogCompositions("CustomBlockScope1") }
}

@Composable
fun CustomBlock(content: @Composable () -> Unit) {
    LogCompositions("CustomBlock")
    content()
}
Output when
counter1
changes:
Copy code
Component
CustomBlockScope1
Why doesn't it skip recomposition of the composable lambda passed to
CustomBlock
?
Did I understand the question correctly?
This is actually a very interesting question 🤔
@Kacper where do you define your
LogCompositions
function?
k
Hi, Im kinda busy right now, so I cant take time to check if your simplified version of the question is 100% correct but I think it is. And for the LogCompositions fun its taken from the article donut-hole skipping
Copy code
class Ref(var value: Int)

// Note the inline function below which ensures that this function is essentially
// copied at the call site to ensure that its logging only recompositions from the
// original call site.
@Composable
inline fun LogCompositions(tag: String, msg: String) {
    if (BuildConfig.DEBUG) {
        val ref = remember { Ref(0) }
        SideEffect { ref.value++ }
        Log.d(tag, "Compositions: $msg ${ref.value}")
    }
}
v
yeah
but are you definining at top level
or inside an activity class?
because the behaviour changes depending on that
k
its inside the MainActivity.kt file but at the top level, outside the activity class
v
For me it skips `CustomBlockScope`s if the
LogCompositions
function is defined outside
MainActivity
but it doesn't skip if it's defined inside. 🤔
k
What a strange behavior 😄
a
Not strange at all, your activity will most likely be unstable, which is why any child composable in it will recompose
v
I don't think so, compile metrics claim:
Copy code
inline fun LogCompositions(
  stable msg: String
  stable tag: String? = @dynamic LiveLiterals$MainActivityKt.String$param-tag$fun-LogCompositions$class-MainActivity()
  unused stable <this>: MainActivity
)
a
Hmm that's interesting
v
debugger also claims this is unchanged
and still doesn't explain why Kacper sees recompositions even when the function is defined outside MainActivity 😅
a
Just for completeness, have you tested it on a release build? Don't forget to remove the DEBUG check in LogCompositions
v
yeah, happens there too
a
This is indeed very interesting, unless we're missing something basic here
v
Defining functions inside/outside MainActivity is probably a different issue than the original
I have an explanation for the phenomenon Kacper was seeing though. Here's an example:
@Kacper
Copy code
@Composable fun CustomBlock(content: @Composable () -> Unit) {
    Log.v("RECOMPOSITION", "CustomBlock body")
    content()
}

@Composable fun Test1(paramNeverChanges: Int = 0, paramChangesRapidly: Int) {
    Log.v("RECOMPOSITION", "Test1-paramChangesRapidly=$paramChangesRapidly")
    CustomBlock {
        Log.v("RECOMPOSITION", "Test1-CustomBlock scope")
        paramNeverChanges // reading the param triggers a recomposition of the scope
    }
}

@Composable fun Test2(paramNeverChanges: Int = 0, paramChangesRapidly: Int) {
    Log.v("RECOMPOSITION", "Test2-paramChangesRapidly=$paramChangesRapidly")
    CustomBlock {
        Log.v("RECOMPOSITION", "Test2-CustomBlock scope")
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        var paramChangesRapidly by mutableIntStateOf(0)
        lifecycleScope.launch { while (true) { paramChangesRapidly += 1; delay(1.seconds) } }

        setContent {
            Test1(paramChangesRapidly = paramChangesRapidly)
            Test2(paramChangesRapidly = paramChangesRapidly)
        }
    }
}
this will log:
Copy code
Test1-paramChangesRapidly=2
Test1-CustomBlock scope
Test2-paramChangesRapidly=2
Test1-paramChangesRapidly=3
Test1-CustomBlock scope
Test2-paramChangesRapidly=3
Test1-paramChangesRapidly=4
Test1-CustomBlock scope
Test2-paramChangesRapidly=4
etc.
So if just one parameter changes it assumes any could have changed and triggers recomposition of all scopes where any function parameter was read. (?)
There is probably not much documentation outside the source code about this behaviour... I'll try to check if I can find something more specifc.
@Albert Chang why does reading an unchanged function parameter trigger recomposition of composable lambdas reading it? Is this expected behaviour or a bug in compose-compiler? Afaik it shouldn't be doing that.
a
I'm not 100% sure but I believe if a composable function is recomposed, all the composable lambdas that capture one or more values will be invalidated and so re-executed. This is because every time the function is executed, a new lambda instance is created. Only those composable lambdas without any dependencies are singletonized and will never be invalidated. This is the current behavior of Compose. There isn't a document or anything saying it shouldn't be doing this. If you are confusing skipping with this, you have to understand that skipping only happens at function level, meaning that if a function isn't skipped, the whole function will be recomposed. When we say a function is skipped, the skip actually happens inside (at the beginning of) the function, and what is actually skipped is the body of the function. It's not the caller but the function itself that skips the body.
thank you color 1
This is arguably something that can be improved, though I'm not sure if there's anything preventing that.
v
Thanks for the explanation, this clarifies the behaviour of composable lambdas a lot. I presumed that they could also be skipped individually. Interesting to know that this not the case!