Jetpack Compose中的lazy column,当单个项目更新时,所有项目都会重新组合。

10

我正在尝试进行一些列表操作,但遇到了一个问题,即当单个项目更新时,所有项目都会重新组合。

https://prnt.sc/8_OAi1Krn-qg

我的模型;

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)

@Stable
data class PersonsWrapper(val persons: List<Person>)

我的ViewModel和更新函数;

private val initialList = listOf(
    Person(id = 0, name = "Name0"),
    Person(id = 1, name = "Name1"),
    Person(id = 2, name = "Name2"),
    Person(id = 3, name = "Name3"),
    Person(id = 4, name = "Name4"),
    Person(id = 5, name = "Name5"),
    Person(id = 6, name = "Name6"),
)

val list = mutableStateOf(PersonsWrapper(initialList))

fun updateItemSelection(id: Int) {
    val newList = list.value.persons.map {
        if (it.id == id) {
            it.copy(isSelected = !it.isSelected)
        } else {
            it
        }
    }
    list.value = list.value.copy(persons = newList)
}

我的可组合函数;

@Composable
fun ListScreen(personsWrapper: PersonsWrapper, onItemClick: (Int) -> Unit) {
    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(2.dp),
        modifier = Modifier.fillMaxSize()
    ) {
        items(personsWrapper.persons, key = { it.id }) {
            ListItem(item = it, onItemClick = onItemClick)
        }
    }
}

在compose_reports中,所有的模型类似乎都很稳定。

stable class Person {
  stable val id: Int
  stable val name: String
  stable val isSelected: Boolean
  <runtime stability> = Stable
}
stable class PersonsWrapper {
  unstable val persons: List<Person>
}


restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ListScreen(
  stable personsWrapper: PersonsWrapper
  stable onItemClick: Function1<Int, Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ListItem(
  stable item: Person
  stable onItemClick: Function1<Int, Unit>
)

当我想要更改列表中单个项目的选定状态时,整个列表都会被重新组合。我还尝试了使用kotlinx.collections中的ImmutableList和Persistant list,但问题并没有得到解决。
如何避免在列表操作时进行不必要的重新组合?
2个回答

20
MutableState使用结构相等性来检查是否使用新实例更新state.value。每次选择新项目时,您都会创建一个新的列表实例。
您可以使用SnapshotStateList,在添加、删除或使用新实例更新现有项目时触发重新组合。 SnapshotStateList是一个列表,通过时间复杂度为O(1)的方式获取项目,而不是在最坏情况下使用O(n)的方式遍历整个列表。

仅使用mutableStateListOf

结果只有单个项目被重新组合。

enter image description here

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)

你可以使用SnapshotState列表来更新你的ViewModel。
class MyViewModel : ViewModel() {

    private val initialList = listOf(
        Person(id = 0, name = "Name0"),
        Person(id = 1, name = "Name1"),
        Person(id = 2, name = "Name2"),
        Person(id = 3, name = "Name3"),
        Person(id = 4, name = "Name4"),
        Person(id = 5, name = "Name5"),
        Person(id = 6, name = "Name6"),
    )

    val people = mutableStateListOf<Person>().apply {
        addAll(initialList)
    }

    fun toggleSelection(index: Int) {
        val item = people[index]
        val isSelected = item.isSelected
        people[index] = item.copy(isSelected = !isSelected)
    }
}

ListItem 可组合

@Composable
private fun ListItem(item: Person, onItemClick: (Int) -> Unit) {
    Column(
        modifier = Modifier.border(3.dp, randomColor())
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onItemClick(item.id)
                }
                .padding(8.dp)
        ) {
            Text("Index: Name ${item.name}", fontSize = 20.sp)
            if (item.isSelected) {
                Icon(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .background(Color.Red, CircleShape),
                    imageVector = Icons.Default.Check,
                    contentDescription = "Selected",
                    tint = Color.Green,
                )
            }
        }
    }
}

您的清单
@Composable
fun ListScreen(people: List<Person>, onItemClick: (Int) -> Unit) {
    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(2.dp),
        modifier = Modifier.fillMaxSize()
    ) {

        items(items = people, key = { it.hashCode() }) {

            ListItem(item = it, onItemClick = onItemClick)
        }
    }
}

我用于视觉检查重组的代码

fun randomColor() = Color(
    Random.nextInt(256),
    Random.nextInt(256),
    Random.nextInt(256),
    alpha = 255
)

使用ViewState

结果

enter image description here

sealed class ViewState {
    object Loading : ViewState()
    data class Success(val data: List<Person>) : ViewState()
}

更新ViewModel如下:
class MyViewModel : ViewModel() {

    private val initialList = listOf(
        Person(id = 0, name = "Name0"),
        Person(id = 1, name = "Name1"),
        Person(id = 2, name = "Name2"),
        Person(id = 3, name = "Name3"),
        Person(id = 4, name = "Name4"),
        Person(id = 5, name = "Name5"),
        Person(id = 6, name = "Name6"),
    )

    private val people: SnapshotStateList<Person> = mutableStateListOf<Person>()

    var viewState by mutableStateOf<ViewState>(ViewState.Loading)
        private set

    init {
        viewModelScope.launch {
            delay(1000)
            people.addAll(initialList)
            viewState = ViewState.Success(people)
        }
    }

    fun toggleSelection(index: Int) {
        val item = people[index]
        val isSelected = item.isSelected
        people[index] = item.copy(isSelected = !isSelected)
        viewState = ViewState.Success(people)
    }
}

1000毫秒和延迟是为了演示。在真实的应用程序中,您将从REST或数据库获取数据。
屏幕显示列表或使用ViewState加载。
@Composable
fun ListScreen(
    viewModel: MyViewModel,
    onItemClick: (Int) -> Unit
) {

    val state = viewModel.viewState
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        when (state) {
            is ViewState.Success -> {

                val people = state.data
                LazyColumn(
                    verticalArrangement = Arrangement.spacedBy(2.dp),
                    modifier = Modifier.fillMaxSize()
                ) {
                    items(items = people, key = { it.id }) {
                        ListItem(item = it, onItemClick = onItemClick)
                    }
                }
            }

            else -> {
                CircularProgressIndicator()
            }
        }
    }
}

稳定性编辑

首先,当您滚动超出视口的项目并且它们重新进入视口时,它们会重新组合,这就是LazyColumn的工作原理,也是为什么它与垂直滚动的Column相比重新组合更少的项目。它重新组合可见的项目和滚动方向上的项目。

要显示如果您按照上述方式实现代码,则除非在您的实现中存在项目的稳定性问题,否则项目不会重新组合。

如果您在SideEffect中看不到任何内容,那么无论布局检查器显示什么,该函数肯定不会重新组合。此外,当我们通过Modifier.background(getRandomColor)在Text组件上调用新的修饰符时,组合体无法跳过重新组合,因此如果没有视觉变化,则不会重新组合。

下面的组合体返回稳定性:

restartable scheme("[androidx.compose.ui.UiComposable]") fun MainScreen(
  unstable viewModel: MyViewModel
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun ListScreen(
  unstable people: List<Person>
  stable onItemClick: Function1<Int, Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ListItem(
  stable item: Person
  stable onItemClick: Function1<Int, Unit>
)
restartable skippable scheme("[0, [0]]") fun StabilityTestTheme(
  stable darkTheme: Boolean = @dynamic isSystemInDarkTheme($composer, 0)
  stable dynamicColor: Boolean = @static true
  stable content: Function2<Composer, Int, Unit>
)

注意:这是一个可重新启动和可跳过的可组合项,如果您的列表项正在重新组合,请确保您的可组合项的输入是稳定的。
@Composable
private fun ListItem(item: Person, onItemClick: (Int) -> Unit) {

    SideEffect {
        println("Recomposing ${item.id}, selected: ${item.isSelected}")
    }

    Column(
        modifier = Modifier.border(3.dp, getRandomColor())
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onItemClick(item.id)
                }
                .padding(8.dp)
        ) {
            Text("Index: Name ${item.name}", fontSize = 20.sp)
            if (item.isSelected) {
                Icon(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .background(Color.Red, CircleShape),
                    imageVector = Icons.Default.Check,
                    contentDescription = "Selected",
                    tint = Color.Green,
                )
            }
        }
    }
}

ListScreen Composable因为people: List<Person>而不稳定,但只有在MainScreen重新组合时才会重新组合。

@Composable
fun ListScreen(
    people: List<Person>,
    onItemClick: (Int) -> Unit
) {

    SideEffect {
        println("ListScreen is recomposing...$people")
    }

    Column {
        Text(
            text = "Header",
            modifier = Modifier.border(2.dp, getRandomColor()),
            fontSize = 30.sp
        )
        Spacer(modifier = Modifier.height(20.dp))
        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
            modifier = Modifier
                .fillMaxSize()
                .border(3.dp, getRandomColor(), RoundedCornerShape(8.dp))
        ) {
            items(
                items = people,
                key = { it.hashCode() }
            ) {
                ListItem(item = it, onItemClick = onItemClick)
            }
        }
    }
}

并添加了一个按钮来安排重新组合,以显示当在MainScreenScope中触发重新组合时,ListScreen会被重新组合。
@Composable
fun MainScreen(
    viewModel: MyViewModel
) {

    var counter by remember {
        mutableStateOf(0)
    }
    Column {
        val people = viewModel.people

        Text(text = "Counter $counter")

        Button(onClick = { counter++ }) {
            Text(text = "Increase Counter")
        }

        Spacer(modifier = Modifier.height(40.dp))

        ListScreen(
            people = people,
            onItemClick = {
                viewModel.toggleSelection(it)
            }
        )
    }
}

你应该能够在布局检查器中看到,点击任何项目都会跳过其他项目,但点击Button会重新组合ListScreen和标题。

如果你向下滚动然后向上滚动,你会看到项目按预期重新进入组合。

正如你在gif中所看到的那样:

  1. 点击任何项目只会触发该项目的重新组合
  2. 点击Button会触发每个ListItem的重新组合
  3. 点击Button会触发ListScreen的重新组合

enter image description here

第二个问题发生在你可以看到的ViewModel不稳定并且调用viewModel.toggle()或viewModel::toggle是不稳定的。
稳定性也适用于lambda表达式或回调函数,你可以在这个示例中进行测试。

https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/master/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_2_7LambdaRecomposition.kt

你可以将这个lambda函数保存在remember中。
val onClick = remember {
    { index: Int ->
        viewModel.toggleSelection(index)
    }
}

并调用ListScreen

ListScreen(
    people = people,
    onItemClick = onClick
)

现在你会看到,任何在MainScreen中触发的组合只有Text(header)和ListScreen会被组合,而不是ListItems。

enter image description here

最后一部分是使ListScreen稳定。如果你改变了
@Composable
fun ListScreen(
    people: List<Person>,
    onItemClick: (Int) -> Unit
) 

@Composable
fun ListScreen(
    people: SnapshotStateList<Person>,
    onItemClick: (Int) -> Unit
) 

你也可以参考这个答案 在Jetpack Compose中防止不必要的列表更新重新组合 当按钮或者在你的情况下可能是其他触发重新组合的东西时,不会重新组合任何内容。

enter image description here

如果你想测试,这是完整的演示。

class MainActivity : ComponentActivity() {

    private val mainViewModel by viewModels<MyViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            StabilityTestTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    MainScreen(mainViewModel)
                }
            }
        }
    }
}

@Composable
fun MainScreen(
    viewModel: MyViewModel
) {

    var counter by remember {
        mutableStateOf(0)
    }

    val onClick = remember {
        { index: Int ->
            viewModel.toggleSelection(index)
        }
    }

    Column(
        modifier = Modifier.padding(8.dp),

        ) {
        val people = viewModel.people

        Text(text = "Counter $counter")

        Button(onClick = { counter++ }) {
            Text(text = "Increase Counter")
        }

        Spacer(modifier = Modifier.height(40.dp))

        ListScreen(
            people = people,
            onItemClick = onClick
        )
    }
}

@Composable
fun ListScreen(
    people: SnapshotStateList<Person>,
    onItemClick: (Int) -> Unit
) {

    SideEffect {
        println("ListScreen is recomposing...$people")
    }

    Column {
        Text(
            text = "Header",
            modifier = Modifier.border(2.dp, getRandomColor()),
            fontSize = 30.sp
        )
        Spacer(modifier = Modifier.height(20.dp))
        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
            modifier = Modifier
                .fillMaxSize()
                .border(3.dp, getRandomColor(), RoundedCornerShape(8.dp))
        ) {
            items(
                items = people,
                key = { it.hashCode() }
            ) {
                ListItem(item = it, onItemClick = onItemClick)
            }
        }
    }
}

@Composable
private fun ListItem(item: Person, onItemClick: (Int) -> Unit) {

    SideEffect {
        println("Recomposing ${item.id}, selected: ${item.isSelected}")
    }

    Column(
        modifier = Modifier.border(3.dp, getRandomColor())
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onItemClick(item.id)
                }
                .padding(8.dp)
        ) {
            Text("Index: Name ${item.name}", fontSize = 20.sp)
            if (item.isSelected) {
                Icon(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .background(Color.Red, CircleShape),
                    imageVector = Icons.Default.Check,
                    contentDescription = "Selected",
                    tint = Color.Green,
                )
            }
        }
    }
}

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)

class MyViewModel : ViewModel() {

    private val initialList = List(30) { index: Int ->
        Person(id = index, name = "Name$index")
    }

    val people = mutableStateListOf<Person>().apply {
        addAll(initialList)
    }

    fun toggleSelection(index: Int) {
        val item = people[index]
        val isSelected = item.isSelected
        people[index] = item.copy(isSelected = !isSelected)
    }
}

fun getRandomColor() = Color(
    Random.nextInt(256),
    Random.nextInt(256),
    Random.nextInt(256),
    alpha = 255
)

谢谢,你的解决方案可行。此外,如果我们有一个持有列表的 ViewState 数据类,那么如何更新它呢?例如: data class ViewState(val persons: List<Person> = emptyList(), any other properties) private val viewState = MutableStateFlow(ViewState()) - pcparticle
@pcparticle更新了答案,并添加了一个ViewState示例。 - Thracian
1
这个解决方案仍然没有解决问题。您可以在AS布局检查器中看到LazyColumn的每个项目是如何重新组合的。 - datienza
1
无论它显示多少次都没关系,它并不总是正确的。真正的调试可以通过更改颜色或使用SideEffect来检查实际重组计数是什么来完成。 - Thracian
如果我们不知道哪个项目已经被更改了怎么办?所以我们需要像DiffUtil这样的东西,对吧? - Alexey Nikitin
1
@AlexeyNikitin key = { it.hashCode() }并使用SnapshotState处理该情况。您可以查看更新的答案的稳定性部分。 - Thracian

2

我认为这是因为你正在使用.map {..},它会创建一个全新的列表集合。

返回一个包含将给定转换函数应用于原始集合中每个元素的结果的列表。

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

通过这样做

 list.value = list.value.copy(persons = newList)

你实际上是在创建一个全新的列表集合,将它们分配给你的LazyColumn,从而导致它完全重新组合。


我建议采用以下方法:
使用SnapshotStateListmutableStateListOf
private val initialList = mutableStateListOf(
        Person(id = 0, name = "Name0"),
        Person(id = 1, name = "Name1"),
        Person(id = 2, name = "Name2"),
        Person(id = 3, name = "Name3"),
        Person(id = 4, name = "Name4"),
        Person(id = 5, name = "Name5"),
        Person(id = 6, name = "Name6"),
    )

只需简单地使用列表迭代器来修改结构(在您的情况下)

fun updateItemSelection(id: Int) {

        val iterator = list.value.persons.listIterator()

        while (iterator.hasNext()) {
            val current = iterator.next()
            if (current.id == id) {
                iterator.set(current.copy(isSelected = !current.isSelected))
            }
        }

      //  list.value = list.value.copy(persons = newList) // <- you don't need to assign a new list here as well. Remove this line
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接