Consider the map: ```@Composable fun MyComponent(m...
# compose
f
Consider the map:
Copy code
@Composable fun MyComponent(map: Map<Int, String>) {
   val states = map.mapValues {(k,_) -> remember(k) {mutableStateOf(0) } }
   for ((k,v) in map) MyStatefulComponent(v,states[k]!!)
}
On a first look, this looks fine. We host the states for
map.size
items of
map
in a parent component
MyComponent
, and create
map.size
child `MyStatefulComponent`s, each with its own state. However, on a second look, this code makes no sense. The way
remember(key)
works, is that whenever a new value is passed, the state is erased and something new is remembered. So if there are at least two elements, the state of all elements except the last one will be erased. In practice, hocus pocus, it works. Each element of
map
has its own unique state cell. No clue how. If a child gets added to or removed from
map
, it updates and keeps working. Magic! But, in my case that I can't reproduce here, the state gets deleted whenever the map shrinks. So I want to understand: what actually makes this work?
🧵 1
a
This is a really great question. 🙂 I'll jump around a bit in the answer:
first, the code as posted has a bug. Instead of using
remember(k) { mutableStateOf(0) }
you'll want to use
key(k) { remember { mutableStateOf(0) } }
❤️ 1
This seems subtle and nitpicky, but the difference between these two is the answer to your last question of what actually makes this work
composition tracks the identity of things using groups; think of them as nodes in a tree. (the implementation is start/end markers in a flat buffer.)
we have three kinds of groups: replaceable, movable, and restartable. (You can see methods to start/end these kinds of groups on the
Composer
interface)
The compiler plugin inserts these group start/end markers around control flow; this is how compose knows the difference between content in one branch or another of an if/else or when, even if the shape of the content would otherwise look the same
so if you write something very silly like this:
Copy code
val count by if (someBoolean) {
  remember { mutableStateOf(0) }
} else {
  remember { mutableStateOf(0) }
}
then any time
someBoolean
changes,
count
will appear to "reset"
compose is inserting replaceable groups around each of the different branch bodies there
each of those replaceable groups has a key generated by the compiler plugin
so as the function recomposes, the if check happens, then the hidden key check for the group happens. If the replaceable group key doesn't match, the whole replaceable group is thrown out and we start composing the new group from scratch instead
remember keys work more or less the same way; if the key doesn't match, the remembered value is thrown out and recomputed instead
all of this is because running a composable function causes the composer to start at a particular position in the composition and walk forward in that record, comparing its notes of what code is currently executing vs. the record of what happened last time
composition only runs forward.
so now we get to things like iteration and collections, like your map. If the collection is reordered we need a way to match groups back up in the composition even if they don't appear in the same order as last time.
key() {}
does this by inserting a movable group. When a movable group is encountered it can be reordered with its immediate siblings rather than being overwritten entirely if expectations aren't precisely met.
(the third kind of group, restartable, is what wraps whole composable functions and allows us to start composing only the parts of the composition that changed rather than running the whole thing from the top. It keeps around a capturing lambda of the function's parameters.)
in short: using
remember
with a key depends not just on the position in the composable function (from the shape of the groups around it emitted by the compiler plugin) but also the order of its execution when it comes to things like iteration. If you need composition to reorder things for you, use the
key() {}
composable.
f
Wow, thanks for the detailed answer, using
key
in my case fixed my issue. But still, in the case I don't use
key
in the
mapValues
call, how does compose know how to save the value for each element of the map separately. Where are 'groups' added there?
👍 1
a
they aren't,
remember
is just writing key/value pairs into the composition whenever it's called. The mechanism of remember keys and replaceable group keys is basically the same though.
f
So it's sort of filling up a list every time its called? Ah, I see how that matches with using multiple
remember
in one place
👍 1
a
the logic of it is more or less
Copy code
if (key == composition[i++]) {
  return composition[i++]
} else {
  return rememberBlock().also { composition[i++] = it }
}
f
I assume groups are added whenever we use callbacks, right? Otherwise there could be issues
a
@Composable
functions have their own internal groups they add, yes. non-
@Composable
functions don't need them since they can't read or write the composition anyway.
f
Copy code
onlySometimes { val value = remember { 0 } }
val value = remember { 0 }
The second
value
could gain the value of the first one, since its suddenly moved from index 1 to index 0
a
yep. The
onlySometimes
function body itself will add its own groups as needed, and since the trailing lambda function you passed it has to be
@Composable
(otherwise it couldn't
remember
) it does too.
so then all of the usual control flow group insertion happens and it does the right thing.
f
Ahh right I haven't considered that the callback must be @Composable as well
a
if you were to write
Copy code
val lambdaToUse = if (something) {
  { val value = remember { ... } }
} else {
  { val value = remember { ...2... } }
}
onlySometimes(lambdaToUse)
then the identity of those composable function lambdas are different, as determined by their internally generated keys from the position in the source code, so they won't end up sharing state when it switches
and then there's a whole space around the compiler plugin being able to prove when it can elide groups for performance; we have some optimizations in there for that already but there's room for plenty more that we have in mind too 🙂
if you've seen things like
@ReadOnlyComposable
around the compose codebase, now you've got the idea of what it's for - it tells the compiler plugin it can skip adding some extra groups because it's making a promise that it only does things like reading CompositionLocals, it doesn't write any data into the composition that would need to be tracked and compared later.
❤️ 1
d
Great! This kind of detail should be in the docs!! Thanks for sharing it!
👍 1