```@Composable fun MyComponent() { Log.d("SCOP...
# compose
m
Copy code
@Composable
fun MyComponent() {
    Log.d("SCOPE", "🍎 MyComponent")
    var counter by remember { mutableIntStateOf(0) }
    val readInvalidatedOnClick = counter
    MyButton(onClick = {
        Log.d("SCOPE", "⭐️ MyComponent/onClick -> MyButton")
        counter += 1
    }) {
        Log.d("SCOPE", "🍎 MyComponent/content -> MyButton")
        CustomText(text = counter.toString())
    }
}
I don’t understand the behaviour of this function
Copy code
@Composable
fun MyButton(onClick: () -> Unit, content: @Composable () -> Unit) {
    Log.d("SCOPE", "⚾️ MyButton")
    Button(onClick = onClick) {
        Log.d("SCOPE", "⚾️ MyButton/content -> Button")
        content()
    }
}

@Composable
fun CustomText(text: String) {
    Log.d("SCOPE", "❄️ CustomText")
    Text(text = text)
}
Here's the rest of the code.
Copy code
------- PROCESS STARTED (7742) for package com.example.composedemo -------
SCOPE   com.example.composedemo     D   🍎 MyComponent
SCOPE   com.example.composedemo     D   ⚾️ MyButton
SCOPE   com.example.composedemo     D   ⚾️ MyButton/content -> Button
SCOPE   com.example.composedemo     D   🍎 MyComponent/content -> MyButton
SCOPE   com.example.composedemo     D   ❄️ CustomText
SCOPE   com.example.composedemo     D   ⭐️ MyComponent/onClick -> MyButton
SCOPE   com.example.composedemo     D   🍎 MyComponent
SCOPE   com.example.composedemo     D   🍎 MyComponent/content -> MyButton
SCOPE   com.example.composedemo     D   ❄️ CustomText
And these are logged when
MyComponent
is loaded and the button is clicked.
I understand that
readInvalidatedOnClick
in
MyComponent
is invalidated on button click, which in turn invalidates the
MyComponent
recomposition scope. But when the scope (ie the body of
MyComponent
) runs again on recomposition, it skips the call to
MyButton
. How is this achieved? I thought just the body of each
@Composable
function that returns
Unit
and isn’t
inline
was wrapped in a recomposition scope. Is each call to a
@Composable
also wrapped in an independent recomposition scope that knows to skip itself when it’s not been invalidated?
OH or is it that
MyComponent
does call
MyButton
on recomposition, but the body of
MyButton
is gated by its recomposition scope…
Copy code
// pseudocode
@Composable
fun MyButton(onClick: () -> Unit, content: @Composable () -> Unit) {
    recompositionScope {/* ... */}
//  ^^^^^^^^^^^^^^^^^^^^         ^Generated by the Compose compiler
//                      ^^^^^^^^^The original body source
}
…and that’s what sees that it’s not been invalidated and skips running the original body?
z
Correct.
m
Copy code
@Composable
fun MyButton(
    onClick: () -> Unit,
    unit: Unit = run { Log.d("SCOPE", "unit -> MyButton") },
    content: @Composable () -> Unit,
)
Really? I’ve added the
unit
parameter to
MyButton
, expecting the lambda to run every time since
MyComposable
doesn’t pass an argument for it.
Copy code
----------- PROCESS STARTED for package com.example.composedemo -----------
SCOPE   com.example.composedemo     D   🍎 MyComponent
SCOPE   com.example.composedemo     D   unit -> MyButton
SCOPE   com.example.composedemo     D   ⚾️ MyButton
SCOPE   com.example.composedemo     D   ⚾️ MyButton/content -> Button
SCOPE   com.example.composedemo     D   🍎 MyComponent/content -> MyButton
SCOPE   com.example.composedemo     D   ❄️ CustomText
SCOPE   com.example.composedemo     D   ⭐️ MyComponent/onClick -> MyButton
SCOPE   com.example.composedemo     D   🍎 MyComponent
SCOPE   com.example.composedemo     D   🍎 MyComponent/content -> MyButton
SCOPE   com.example.composedemo     D   ❄️ CustomText
But it looks like the call to
MyButton
itself is entirely skipped? If there was a call, which just immediately returned because the recomposition scope was still valid…
Copy code
SCOPE   com.example.composedemo     D   ⭐️ MyComponent/onClick -> MyButton
SCOPE   com.example.composedemo     D   unit -> MyButton
SCOPE   com.example.composedemo     D   🍎 MyComponent
SCOPE   com.example.composedemo     D   🍎 MyComponent/content -> MyButton
SCOPE   com.example.composedemo     D   ❄️ CustomText
…shouldn’t these have been logged on click?
> The
columns
parameter initially had a default value in the original Kotlin code, but the plugin removes it during the IR stage. In Compose, the semantics of default values differ from standard Kotlin. The plugin rewrites the handling of default values directly into the body of the composable function. > Decomposing Jetpack Compose If my unproven hypothesis is correct and
MyButton
indeed does get called but its recomposition scope that wraps its original body sees that none of its reads has been invalidated and skips running the wrapped body, and if the quoted explanation from a random blog post is to be believed, then that might explain why
unit -> MyButton
isn’t printed after
⭐ MyComponent/onClick -> MyButton
.
@Baiqin Wang Pardon the ping, but do you happen to be the author of the post? Could you please help me confirm that the above behaviour happens because of the Compose compiler? Compose alters the semantics of Kotlin code excessively and in invisible and unexpected ways at that, so I’m seriously struggling to understand how it works.
b
@min
MyComponent
,
MyButton
and
CustomText
are all recompose scopes. Your
MyButton
is roughly compiled to this:
Copy code
@Composable
@ComposableInferredTarget
fun MyButton(
  onClick: Function0<Unit>,
  unit: Unit?,
  content: Function2<Composer, Int, Unit>,
  $composer: Composer?,
  $changed: Int,
  $default: Int
) {
  $composer = Composer.startRestartGroup()
  val $dirty: Int = $changed
  if (... == ...) {
    $dirty = Int.or(...)
  }
  if (... == ...) {
    $dirty = Int.or(...)
  }
  if ( ... || ... ) {
    Composer.startDefaults()
    if (... == ... || Composer.defaultsInvalid) {
      ...
    } else {
      Composer.skipToGroupEnd()
      ...
    }
    Composer.endDefaults()
    Log.d(...)
    Button(...)
  } else {
    Composer.skipToGroupEnd()
  }
  Composer.endRestartGroup()?.updateScope {
    ...
    MyButton(...)
  }
}

@Composable
@ComposableTarget
fun CustomText(
  text: String,
  $composer: Composer?,
  $changed: Int
) {
  ...
  val tmp0_safe_receiver: ScopeUpdateScope? = Composer.endRestartGroup()?.updateScope {
    ...
    CustomText(...)
  }
}
As you can see, the default parameters are compiled into the body of
MyButton
, which is quite different from vanilla kt compiler. For your first question, the reason why compose can skip executing
MyButton
but execute
CustomText
is the
updateScope
call at the end of each recompose scope. It is basically a callback to tell compose runtime what do when current recompose scope invalidates. The callback mostly just recall the recompose scope itself.
MyButton
doesn't read invalidate state so compose runtime won't invoke the lambda passed to
updateScope
. But
CustomText
does read the
counter
so compose runtime will invoke the lambda passed to
updateScope
m
@Baiqin Wang Thanks for having a look. I have a followup though if you don’t mind. It makes sense that Compose doesn’t trigger the
updateScope
for the
MyButton
because no state it’s read gets invalidated. But as the logs show, it does recompose
MyComponent
, which also makes sense because the
readInvalidatedOnClick
read gets invalidated on button click. As part of the recomposition of
MyComponent
, its body must rerun if I’m understanding it correctly, which includes a call to
MyButton
. How does that call in particular get skipped?
b
@min You see the if .. else statements with %dirty %changed flags in the generated code? Those control flow code are also generated compose compiler and they wrap the whole function body of
MyButton
. If the arguments of
Mybutton
doesn't change with respect to stability rules. The if - else will "skip" the execution of the original
MyButton
function.
Strictly speaking, the
MyButton
function is still invoked. The control flow code is still inside
MyButton
.
m
@Baiqin Wang Ah, so this guess was correct, and the
recompositionScope {/* ... */}
actually looks like the following.
Copy code
if ( ... || ... ) {
    Composer.startDefaults()
    if (... == ... || Composer.defaultsInvalid) {
      ...
    } else {
      Composer.skipToGroupEnd()
      ...
    }
    Composer.endDefaults()
    Log.d(...)
    Button(...)
  } else {
    Composer.skipToGroupEnd()
  }
Have I got it right?
b
@min Yes exactly. The
MyButton
function is called but hit the second
Composer.skipToGroupEnd()
so whole function execution is skipped.
m
@Baiqin Wang I see. (cc @Zach Klippenstein (he/him) [MOD]) Do you also happen to be familiar with how
(WrappedComposition as Composition).setContent
takes snapshots to drive recompositions? I’m not expecting to be spoonfed answers, and attached are the notes I’ve been able to put together to show you that I’ve been doing my homework. But I’m new to Android, and I find Compose so vast and relentlessly complicated that I’m overwhelmed and struggling to understand how it works. As you can see, I think I understand the call chain from
ComposeView.setContent
in
MainActivity: ComponentActivity
to
WrappedComposition.setContent
. But how does it go from there to store the `.updateScope {}`s, and who gets notified when and how to call them?
b
Yes if you are trying understand how it works internally it is complex. You need to understand two parts: Compose compiler and compose runtime. Both are crucial for understanding how it works. One of the main job of the compiler is to inject a $composer parameter (real class
ComposerImpl
) to every composable function. As you can see here: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1773658889571999?thread_ts=1773129172.444859&amp;cid=CJLTWPH7S. The code composing is also written by the composer. So it is crucial to understand how the compiler works. The most important part of the code written by compose compiler is the "tree traversal code". Like the
startRestartGroup
(it effectively creates a tree node and traverse down the node). And corresponding
end..
functions just traverse up the UI tree. The data of the traversal is stored in a data structure called
SlotTable
and wrapped in a class called
CompositionImpl
. After composation, there is a UI tree ready for render in
SlotTable
. The tree is handled to compose ui module via the
Applier
interface. The snapshot updates are triggered from the state property delegates. You can read the source of how compose rewrite the getter and setter of
State<>
as a starting point. The states updates are also recorded in slot table. Initial composition is triggered at the time when setContent {} is called. Recomposition is triggered every frame update in
Choreographer
callback. The core code is in
Recomposer
class. Compose itself is platform agnostic. You can use it on other platforms like web and ios. The
AndroidComposeView
is the core class to glue compose to android platform.
And I highly recommend using LLMs to help you understand the code