One Off the Slack: Remember Your Flows

Michal Mlimczak had a problem with some code:

    //causes infinite recomposition
    val playerUiStateWrong by player.observeState()
        .map { PlayerUIState(it, sound) }
        .collectAsState(PlayerUIState(null, null))
        
    //works fine
    val playerState by player.observeState().collectAsState(PlayerState.Stopped)
    val playerUiState = PlayerUIState(playerState, sound)

I thought they would both do the same thing, but the first one, whenever I use playerUiStateWrong as a parameter in a modifier, causes infinite recompositions, whereas the second one works fine.

Google’s Adam Powell went back to a previous Slack thread to point out the problem:

Assembling an operator chain in a composable function will yield a new Flow instance each time it’s recomposed and collectAsState can’t tell that it’s semantically equal

In playerUiStateWrong, map() returns a new Flow. On each recomposition, that Flow will be different than the previous Flow, so the old collectAsState() will be cancelled and a new one invoked.

He went on to say:

If you remember the assembled flow based on its inputs you’ll see the same results as with your second snippet

Michal wondered if this would be fixed at some point… and Adam replied that this is not a bug, but a feature:

No, it’s by design. If collectAsState did not cancel an old subscription and start a fresh one when the flow being collected changes then its behavior would be incorrect when a new Flow is used, and Flows do not implement equality for collectAsState to tell the difference between a new and very different flow vs. a new but semantically equivalent one.

Michal then wondered if a Lint check could catch this sort of thing, since this might trip up newcomers to Compose. Adam was unconvinced:

Possibly, but it’s easy to defeat a lint check or create false positives for something like this. Consider the example from the linked thread a day or so ago; nothing says the ViewModel can’t or wouldn’t cache returned flows based on id, use shareIn internally, etc. It would catch only very basic cases

The motive for memoizing assembled flows is several layers of semantic meaning away from anything compose understands or arguably should understand

I’m not sure it would be an overall win for understanding the mental model of composition if things like flows/collectAsState did some sort of magic thing here that would be somehow different from the basic constructs anyone else would write themselves (


Read the original thread in the kotlinlang Slack workspace. Not a member? Join that Slack workspace here!