Does `derivedStateOf` track reads of `State` objec...
# compose
j
Does
derivedStateOf
track reads of
State
objects at run time or compile time (or perhaps I should say dynamically or statically)? šŸ§µ
I think Iā€™ve run into an edge case with this code:
Copy code
val aMutableListOfStrings = mutableListOf<String>()
val aSnapshotStateListOfStrings = mutableStateListOf<String>()

val myStuff: List<String> by derivedStateOf {
	aMutableListOfStrings.filter {
		aSnapshotStateListOfStrings.contains(it)
	}
}
The edge case happens when, at the very first run of the
derivedStateOf
lambda,
aMutableListOfStrings
is empty. In this case no actual reads of
aSnapshotStateListOfStrings
take place at run time. Now when the contents of
aSnapshotStateListOfStrings
changes, the
derivedStateOf
lambda is not run again: even if by then
aMutableListOfStrings
is not empty anymore (and therefore this time it would actually lead to reads of
aSnapshotStateListOfStrings
). I have the impression that this happens because the
derivedStateOf
lambda tracks reads to state objects at runtime during its first run. Since no reads were tracked in this edge case conditions, the logic assumes this derivedState can never change. I initially thought this edge case couldnā€™t happen because somehow the snapshot state system would be able to track the read to
aSnapshotStateListOfStrings
because it actually appeared in a statement in the code (as if reads were tracked statically, e.g. by code analysis) but then I though this might not be the case and that tracking might happen dynamically at runtime. Iā€™m not sure whether to call this a bug (Iā€™m more inclined to think this is working as intended) but I just wanted to make sure this is actually the behavior Iā€™m witnessing in my code.
a
All snapshot states are tracked at runtime but your code should work as iterating through the list is recorded even if the list is empty (because getting the size of list is recorded). If it doesnā€™t work, consider filing a bug.
j
Calling
filter
on an empty list never runs the filterā€™s lambda. Therefore the
contains()
on the
SnapshotStateList
list is never invoked.
n
doesn't both list should be using mutableStateListOf ?
you are using aMutableListOfStrings which is a simple list, in your
derivedStateOf
; so if you expect the state to update when the list change, you need your simple list to be a state
j
I expect the state to update only when
aSnapshotStateListOfStrings
changes (thatā€™s why it is the only
SnapshotStateList
).
a
How does
filter
know the list is empty?
j
It doesnā€™t, but
filter
is implemented with a for loop: when run, if the list you are filtering is empty, its lambda will never be called so
aSnapshotStateListOfStrings
will never be read.
a
if the list you are filtering is empty
How does the for loop know the list is empty?
The answer is
size
property of the list is read, and thus recorded.
n
but is it recorded when the list is not a stateList ?
this list is just a :
val list = mutableListOf<String>()
a
Ok I didnā€™t notice that
aMutableListOfStrings
isnā€™t a state list. So this is expected.
šŸ˜Œ 1
z
Itā€™s done at runtime. But I think thereā€™s an actual bug lurking here that someone else just discovered last week: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1642650486349500?thread_ts=1642625335.328100&amp;cid=CJLTWPH7S
j
I gave it a look but it seems a different issue, Iā€™ve filed this which also include a simpler test to reproduce: https://issuetracker.google.com/issues/216136031
a
Echoing nitrog42 and Albert above, this is expected behavior. Referring to the simpler example in your bug,
private var switch
is a plain
var
, so Compose has no way of tracking when it changes. The solution to get the behavior youā€™d expect is
private var switch: Boolean by mutableStateOf(false)
, so that
derivedStateOf
can re-run if
switch
changes.
j
Iā€™m not sure if we can call this a bug or expected behavior: The derived lambda does read from
state
even though the read is conditional on the value of
switch
(which is a plain var, no mutableStateOf ) so in principle the derived lambda should be rerun whenever
state
changes, regardless of the value of
switch
. But this doesnā€™t happen. My conjecture is that this most likely happens because the runtime decides whether to track
state
based on whether there had been any actual reads from it during the lambdaā€™s first run. So if, during this first lambda run,
switch
is false then
state
isnā€™t going to be tracked by this derived state. IMHO this is the result of a limitation of the system (i.e. the fact that tracking of reads of state vars happens dynamically at runtime). If the tracking were to be based e.g. on statical source code analysis, then the read to
state
would always be tracked (because the read statement is actually in the code:
state.uppercase()
). N.B. Iā€™m not asking nor suggesting to do statical state tracking, Iā€™m just trying to understand why this is happening and, if my conjecture is actually true, perhaps we can improve the docstring of
derivedState
highlighting this peculiarity.
n
actually this remind me of the stringResource() / resources() method :
Copy code
@Composable
@ReadOnlyComposable
private fun resources(): Resources {
    LocalConfiguration.current
    return LocalContext.current.resources
}
where
LocalConfiguration.current
is used to track changes and retrigger the get of resources instance
āž• 1
which in your example means you would have to do :
Copy code
private val derived: String by derivedStateOf {
    state
    if (switch) {
      state.uppercase()
    } else {
      "totallyBogusStuff"
    }
  }
just to track change, which is weird and you have to know "why", but it would works not sure if there will be any other solution for this (inside the framework/compiler I mean)
ā˜ļø 1
j
Exactly!
a
This is definitely the correct behavior. Tracking a state which is not read and causing recompostion when it changes results in bad performance. The correct solution is to use snapshot states, as Alex suggested.
j
Itā€™s a tradeoff. It wonā€™t cause recomposition though. It would re-run the derived lambda: recomposition is triggered only if the return value of the lambda is different from its previous, memoized, value.
a
Itā€™s not a tradeoff. You are not using Composeā€™s mechanism (snapshot state) so you canā€™t expect it to work out of the box.
a
At risk of sounding like ā€œitā€™s a feature, not a bug!ā€ the dynamic state tracking at runtime allows for behavior thatā€™s far more powerful than what static state tracking would allow. Letā€™s suppose you had:
Copy code
private val derived: String by derivedStateOf {
    if (someFunction()) {
        state.uppercase()
    } else {
        "empty"
    }
}
When the block runs,
derivedStateOf
will call
someFunction
, and if the result is true then, it will read the
state
. If
someFunction
returns
false
, then
state
wonā€™t be read, so from the perspective of
derivedStateOf
, thereā€™s no way that any changes to
state
can impact the outcome. So how can the result of
derivedStateOf
ever change? By changing any snapshot state that
someFunction
read to make its decision. The power is that
someFunction
can refer to state that
derivedStateOf
has absolutely no knowledge about.
someFunction
could be delegated to an interface, with various logic, to another class, 5, 10 levels deep. If
someFunction
depends on 5 pieces of snapshot state, then the only way for
someFunction
to change is if any of those 5 pieces change. If that happens, then
derivedStateOf
will run again. Maybe the new result is still
false
, but it depends on 10 pieces of different state this time. If any of those 10 pieces change, then we calculate again. The other thing is that this isnā€™t unique to
derivedStateOf
, this is the behavior throughout the Compose system. The limitation here is that you have to work with the Compose snapshot state system throughout. If you refer to some
var flag: Boolean
, then from the perspective of Compose, itā€™s like doing
if (false) state else "empty"
. From the perspective of Compose, thereā€™s no way for that
false
to ever become true. (this gets tricky for state managed outside of Compose, and the
resources
is a great example: the resources can change arbitrarily, so we need to depend on the
LocalConfiguration
as a Compose hook to trigger rerunning) The power of this system is that you donā€™t have to manually wire up all of the possible dependencies in a massive
Flow<T>
network (or choose your other observable type of choice). Each piece of internal state can independently and transparently create new state based on other state.
šŸ’Æ 3
j
Thanks for the very good explanation. I just wish I could have understood all of this just by looking at the docs. But with this edge case I was unsure whether I was doing things according to the docs or not.