I have a question about `CompositionLocalProvider`...
# compose
m
I have a question about
CompositionLocalProvider
. I know that it remembers its mappings for comparison on recompose. Mappings of instances by… •
compositionLocalOf
are backed by a mutable snapshot state, reused across recompositions, that invalidates readers when changed •
staticCompositionLocalOf
are not, and instead use a wrapper that’s replaced with a new one when mapped to a different value. But how does remembering a different value (because
local provides 1
during one composition and
local provides 2
during the next, for example) invalidate the
content: @Composable () -> Unit
recompose scope?
Copy code
@Composable
fun LocalDemo(modifier: Modifier = Modifier) {
    val state = remember { mutableIntStateOf(0) }
    Column(modifier = modifier) {
        val key = staticCompositionLocalOf { "Not tapped yet." }
        CompositionLocalProvider(key provides "Tapped ${state.intValue} times.") {
            Text(key.current)
        }
        Button(onClick = { state.intValue += 1 }) {
            Text("Tap")
        }
    }
}
How does Compose know to recompose the lambda passed to
CompositionLocalProvider
?
👀 1
s
Compose just invalidates everything inside that group (see
providersInvalid
flag in
Composer
)
m
@shikasd My guess is that the
startProvider
call conditionally leaves the
Composer
in the state where it runs the body of the
content: @Composable () -> Unit
argument which it’s also injected into.
Copy code
@Composable
@OptIn(InternalComposeApi::class)
@NonSkippableComposable
public fun CompositionLocalProvider(value: ProvidedValue<*>, content: @Composable () -> Unit) {
    currentComposer.startProvider(value)
    content()
    currentComposer.endProvider()
}
And unless a local by
staticCompositionLocalOf
has been remapped to a different value, the execution of the
content
is skipped.
Copy code
override fun startProvider(value: ProvidedValue<*>) {
    // ...
    providersInvalid = invalid
    providerCache = providers
    start(compositionLocalMapKey, compositionLocalMap, GroupKind.Group, providers)
}
But I can’t anything in the
start
method that skips the
content
unless the local has been remapped. Could you point me to the code please?
I most definitely would wish having to navigate the Compose codebase on my worst enemy
s
Skipping is done by the compiler
m
@shikasd But it’s a runtime decision that you make dynamically?
s
Yes, but logic is encoded by the compiler, I recommend looking into decompiled bytecode (e.g with jadx)
m
@shikasd Why can I not see the bytecode for
CompositionLocalProvider
?
s
don't use ide, it barely knows what bytecode your own code is producing, use external decompilers
c
As @shikasd points out, there is code generated by the compiler that checks whether or not a function should be skipped. It includes checking parameter values and the current composer's
skipping
state. If
skipping
is
false
the function will not be skipped regardless of which parameters are passed to it. When a static composition local is changed then
skipping
will return
false
for all children of the
CompositionLocalProvider
call. The recompose states are not invalidated as there is no need to. They will execute regardless as skipping is disabled until
CompositionLocalProvider
returns (technically, until
endProvider()
is called).
m
@shikasd @Chuck Jazdzewski [G] It seems that…
Copy code
val untracked = staticCompositionLocalOf { 0 }
CompositionLocalProvider(untracked provides state.intValue) {
    Log.d("LOCALDEMO", "provider where `untracked provides state.intValue`")
}
This becomes…
Copy code
ProvidableCompositionLocal untracked = CompositionLocalKt.staticCompositionLocalOf((Function0) objRememberedValue4);
CompositionLocalKt.CompositionLocalProvider(
    (ProvidedValue<?>) untracked.provides(Integer.valueOf(state.getIntValue())),
    ComposableSingletons$MainActivityKt.INSTANCE.m9057getLambda$441102124$app(), 
    $composer2, 
    ProvidedValue.$stable | 48,
);
Where the
m9057getLambda$441102124$app()
returns…
Copy code
/* JADX INFO: renamed from: lambda$-441102124, reason: not valid java name */
private static Function2<Composer, Integer, Unit> f149lambda$441102124 = ComposableLambdaKt.composableLambdaInstance(-441102124, false, new Function2() { // from class: com.example.composedemo.ComposableSingletons$MainActivityKt$$ExternalSyntheticLambda5
    @Override // kotlin.jvm.functions.Function2
    public final Object invoke(Object obj, Object obj2) {
        return ComposableSingletons$MainActivityKt.lambda__441102124$lambda$5((Composer) obj, ((Integer) obj2).intValue());
    }
});
Which, on invoke, calls…
Copy code
static final Unit lambda__441102124$lambda$5(Composer $composer, int $changed) {
    ComposerKt.sourceInformation($composer, "C:MainActivity.kt#ffoge4");
    if (!$composer.shouldExecute(($changed & 3) != 2, $changed & 1)) {
        $composer.skipToGroupEnd();
    } else {
        if (ComposerKt.isTraceInProgress()) {
            ComposerKt.traceEventStart(-441102124, $changed, -1, "com.example.composedemo.ComposableSingletons$MainActivityKt.lambda$-441102124.<anonymous> (MainActivity.kt:65)");
        }
        Log.d("LOCALDEMO", "provider where `untracked provides state.intValue`");
        if (ComposerKt.isTraceInProgress()) {
            ComposerKt.traceEventEnd();
        }
    }
    return Unit.INSTANCE;
}
So as part of transforming every
@Composable
at compile time, the provider
content: @Composable () -> Unit
is wrapped in a conditional
shouldExecute
check. The check seems to come down to
parametersChanged || !skipping
where…
Copy code
override val skipping: Boolean
    get() {
        return !inserting &&
            !reusing &&
            !providersInvalid &&
            currentRecomposeScope?.requiresRecompose == false &&
            !forciblyRecompose
    }
Of which
providersInvalid
is set in the composer
start{Provider, Providers}
methods. Is this correct?
c
Yes, every skippable composable function checks
shouldExecuted
to determine whether it should execute its content or skip. Non-skippable functions (e.g. inline composable functions or function explicitly marked non-skippable with
@NonSkippableComposable
) do not; they never skip. Changing the value of a static composition local will cause the map storing the composition locals to change. When the map changes
providersInvalid
is set to
true
until the corresponding
end
is called (i.e. the function returns). While
providersInvalid
is
true
shouldExecute
will return
true
. This means that changing the value of a static composition local is possible but is costly. The intent is that static composition locals never (or nearly never) change. However, if a value changes rarely then using a static local may be less costly overall than a dynamic local as the cost of tracking reads of a dynamic local has a cost too which statics do not pay.
m
@Chuck Jazdzewski [G] The summary evolves • The Compose compiler finds and transforms every
@Composable
at compile time ◦ A Kotlin compiler plugin; a compile time dependency that manipulates the IR ◦ A generated `Composer` parameter reads from and writes to a tree in a
Composition
◦ Unless
inline
or opted out of, the body mapped if
Unit
to a ‘recompose scope’ • The Compose runtime is a framework used by code generated by the Compose compiler ◦ A
Composition
is to broadcast its evolution for
Applier
implementors to derive from ◦ Drives compositions in a
CompositionContext
tree rooted at a
Recomposer
node ◦ Composes in a snapshot; observers record reads & writes by recompose scopes ◦
remember
computes + caches → reads while valid at the current
Composer
position •
ComponentActivity::setContent
populates + sets the activity content to a
ComposeView
◦ A
Composition
in an
AndroidComposeView
child to derive a `LayoutNode` tree from ◦ Recomposed if invalidated as driven by a
Recomposer
on global snapshot advance ◦ The global snapshot advanced in a suspending loop on resume if writes observed ◦ The derived tree to draw measures nodes and places their children postorder on layout • A
CompositionLocal<T>
is a key to map to and by which to look up
T
values ◦ Abstract; subtype
ProvidableCompositionLocal<T>
instantiated by factories ◦ A mapping brought into scope by a
CompositionLocalProvider
for its subtree ◦
.provides(T): ProvidedValue<T>
and the like on the subtype return a mapping ◦
.current: T
by a
@Composable
getter that searches up the composition • A
CompositionLocalProvider
remembers its mappings to compare to args on recompose ◦ The
content: @Composable () -> Unit
recompose scope checks to skip if identical ◦ Updated in place if by `compositionLocalOf`; backed by a snapshot state to write to ◦ Replaced if by
staticCompositionLocalOf
which sets a `Composer` flag not to skip Thank you for helping me put it together! Please let me know if I’ve got anything wrong
@Chuck Jazdzewski [G] I’ve observed behaviour that doesn’t match this explanation: > Non-skippable functions (e.g. inline composable functions or function explicitly marked non-skippable with
@NonSkippableComposable
) do not; they never skip.
Copy code
CompositionLocalProvider(tracked provides state.intValue) @NonRestartableComposable {
    Log.d("LOCALDEMO", "provider where `tracked provides state.intValue`; not restartable")
}
CompositionLocalProvider(tracked provides state.intValue) {
    Log.d("LOCALDEMO", "provider where `tracked provides state.intValue`; restartable")
}
CompositionLocalProvider(untracked provides state.intValue) {
    Log.d("LOCALDEMO", "provider where `untracked provides state.intValue`")
}
I expected the first and last providers to recompose their contents every time, but only the last one does:
Copy code
--------- beginning of main
--------- beginning of system
LOCALDEMO    com.example.composedemo    D  provider where `tracked provides state.intValue`; not restartable
LOCALDEMO    com.example.composedemo    D  provider where `tracked provides state.intValue`; restartable
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
The decompiled bytecode shows that the
@NonRestartableComposable
annotation has had no effect:
Copy code
static final Unit lambda_1641123563$lambda$4(Composer $composer, int $changed) {
    ComposerKt.sourceInformation($composer, "C:MainActivity.kt#ffoge4");
    if (!$composer.shouldExecute(($changed & 3) != 2, $changed & 1)) {
        $composer.skipToGroupEnd();
    } else {
        if (ComposerKt.isTraceInProgress()) {
            ComposerKt.traceEventStart(1641123563, $changed, -1, "com.example.composedemo.ComposableSingletons$MainActivityKt.lambda$1641123563.<anonymous> (MainActivity.kt:63)");
        }
        Log.d("LOCALDEMO", "provider where `tracked provides state.intValue`; not restartable");
        if (ComposerKt.isTraceInProgress()) {
            ComposerKt.traceEventEnd();
        }
    }
    return Unit.INSTANCE;
}
Is this a bug?
Copy code
CompositionLocalProvider(tracked provides state.intValue,
    @Composable @NonRestartableComposable fun () {
        Log.d("LOCALDEMO", "provider where `tracked provides state.intValue`; NOT restartable")
    }
)
CompositionLocalProvider(tracked provides state.intValue) {
    Log.d("LOCALDEMO", "provider where `tracked provides state.intValue`; restartable")
}
CompositionLocalProvider(untracked provides state.intValue) {
    Log.d("LOCALDEMO", "provider where `untracked provides state.intValue`")
}
I suspect that it is, because replacing the
{}
lambda with an anonymous
fun
one achieves the expected behaviour:
Copy code
LOCALDEMO    com.example.composedemo    D  provider where `tracked provides state.intValue`; NOT restartable
LOCALDEMO    com.example.composedemo    D  provider where `tracked provides state.intValue`; restartable
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
LOCALDEMO    com.example.composedemo    D  provider where `tracked provides state.intValue`; NOT restartable
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
LOCALDEMO    com.example.composedemo    D  provider where `tracked provides state.intValue`; NOT restartable
LOCALDEMO    com.example.composedemo    D  provider where `untracked provides state.intValue`
The
@NonRestartableComposable
runs every time now, and the bytecode also forgoes the
shouldExecute
check as expected:
Copy code
static final Unit lambda__522512317$lambda$4(Composer $composer, int $changed) {
    ComposerKt.sourceInformationMarkerStart($composer, -522512317, "C(<no name provided>):MainActivity.kt#ffoge4");
    if (ComposerKt.isTraceInProgress()) {
        ComposerKt.traceEventStart(-522512317, $changed, -1, "com.example.composedemo.ComposableSingletons$MainActivityKt.lambda$-522512317.<no name provided> (MainActivity.kt:63)");
    }
    Log.d("LOCALDEMO", "provider where `tracked provides state.intValue`; NOT restartable");
    if (ComposerKt.isTraceInProgress()) {
        ComposerKt.traceEventEnd();
    }
    ComposerKt.sourceInformationMarkerEnd($composer);
    return Unit.INSTANCE;
}
The documentation for the annotation doesn’t say it doesn’t work and isn’t to be used on
{}
lambdas either: > This annotation can be applied to
Composable
functions in order to prevent code from being generated which allow this function's execution to be skipped or restarted. This may be desirable for small functions which just directly call another composable function and have very little machinery in them directly, and are unlikely to be invalidated themselves.
s
It does not apply to lambdas
m
@shikasd That should either change because it doesn’t make much sense for that to be the intended behaviour, or at least be documented.
s
Probably, file a bug please