James Olrog
James Olrog

Reputation: 435

Preventing unnecessary recompositions on list updates in Jetpack Compose

I am writing an Android app using Jetpack Compose. I have a Composable called MultiSelectGroup which needs to modify and return a list of selections whenever a FilterChip is clicked. Here is the code:

@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MultiSelectGroup(
    items: List<String>,
    currentSelections: List<String>,
    onSelectionsChanged: (List<String>) -> Unit,
) {
    FlowRow {
        items.forEach { item ->
            FilterChip(
                label = { Text(item) },
                selected = currentSelections.contains(item),
                onClick = {
                    val newSelectedChips = currentSelections.toMutableList().apply {
                        if (contains(item)) {
                            remove(item)
                        } else {
                            add(item)
                        }
                    }
                    onSelectionsChanged(newSelectedChips)
                },
            )
        }
    }
}

This component is currently called using the following code:

val allItems = remember { (1..6).map {"$it"} }

val selectedItems = remember {
    mutableStateOf(emptyList<String>())
}

MultiSelectGroup(
    items = allItems,
    currentSelections = selectedItems,
    onSelectionsChanged = { selectedItems.value = it },
)

The problem is that this approach seems to be fairly inefficient in terms of recompositions; every time a FilterChip is clicked, all FilterChips are recomposed whether they change visually or not. My current understanding is that this is because the list is being re-set and, with List being a more unstable data type, Compose decides to just re-render all components dependant on the List rather than just the elements in that List have changed.

I have already considered hoisting the "updating list" logic in the onClick of each FilterChip out of the component - however it seems sensible for this component to do this logic as this behaviour will always be the same and would only be duplicated each time MultiSelectGroup is used. I have also tried to use combinations of mutableStateList, key and derivedStatedOf but I'm yet to find a solution that works.

Is a component like this doomed to always recompose each of its children? Or is there a way to optimise the recompositions for this kind of view? Thanks in advance!

Upvotes: 1

Views: 3439

Answers (2)

hasan.z
hasan.z

Reputation: 339

As mentioned in Android documentation:

Compose always considers collection classes unstable. such as List, Set and Map. This is because it cannot be guaranteed that they are immutable.

In a nutshell, the Compose compiler classifies parameters of Composable functions as either Stable or Unstable. Stability determines if a composable function can be skipped during recomposition.

  • Stable parameters: A parameter is considered stable if it is immutable or if Compose can reliably detect whether its value has changed between recompositions.
  • Unstable parameters: A parameter is unstable if Compose cannot determine whether its value has changed. If a composable has unstable parameters, Compose will always recompose it whenever the parent component is recomposed.

Solution:

To address this performance issue, Android documentation recommends use Kotlin Immutable Collections. These are guaranteed to be immutable, and Compose treats them as Stable.

fun MultiSelectGroup(
    items: ImmutableList<String>,
    currentSelections: ImmutableList<String>,
    onSelectionsChanged: (ImmutableList<String>) -> Unit,
)

Notes:

  • Ensure that the collection’s generic types are also stable. Primitive types and strings are considered stable by default.

  • You can use the stability report, exported by the Compose compiler to inspect stability issues and identify which composables are skippable.

Upvotes: 2

Thracian
Thracian

Reputation: 67149

You current function is unstable as you mentioned with params

restartable scheme("[androidx.compose.ui.UiComposable]") fun MultiSelectGroup(
  unstable items: List<String>
  unstable currentSelections: List<String>
  stable onSelectionsChanged: Function1<List<String>, Unit>
)

You can create a class that contains text and item selection status instead of two lists.

data class Item(val text: String, val selected: Boolean = false)

And you can replace List with SnapshotStateList which is stable

@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MultiSelectGroup(
    items: SnapshotStateList<Item>,
    onSelectionsChanged: (index: Int, item: Item) -> Unit,
) {

    SideEffect {
        println("MultiSelectGroup scope recomposing...")
    }

    FlowRow {

        SideEffect {
            println("FlowRow scope recomposing...")
        }
        
        items.forEachIndexed { index, item ->
            FilterChip(
                label = { Text(item.text) },
                selected = item.selected,
                onClick = {
                    onSelectionsChanged(index, item.copy(selected = item.selected.not()))
                },
            )
        }
    }
}

And use it as

@Preview
@Composable
private fun Test() {
    val allItems = remember { (1..6).map { Item(text = "$it") } }

    val selectedItems = remember {
        mutableStateListOf<Item>().apply {
            addAll(allItems)
        }
    }

    MultiSelectGroup(
        items = selectedItems,
        onSelectionsChanged = { index: Int, item: Item ->
            selectedItems[index] = item
        }
    )
}

And it will be

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MultiSelectGroup(
  stable items: SnapshotStateList<Item>
  stable onSelectionsChanged: Function2<@[ParameterName(name = 'index')] Int, @[ParameterName(name = 'item')] Item, Unit>
)

You can actually send a list instead of SnapshotList but then FlowRow scope will be recomposed. Using a SnapshotStateList neither scopes are non-skippable.

Also you might not use a callback since you can update SnapshotStateList inside MultiSelectGroup but i'd rather not updating properties of inputs inside function.

Upvotes: 5

Related Questions