Jetpack Compose 惰性列在单个项目更新时重新组合所有项目

pcp*_*cle 11 android android-jetpack-compose compose-recomposition android-jetpack-compose-lazy-column

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

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>)
Run Code Online (Sandbox Code Playgroud)

我的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)
}
Run Code Online (Sandbox Code Playgroud)

和我的可组合函数;

@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)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

所有模型类在 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>
}
Run Code Online (Sandbox Code Playgroud)

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>
)
Run Code Online (Sandbox Code Playgroud)

当我想更改列表中单个项目的选定状态时,整个列表将被重新组合。我还尝试使用 kotlinx.collections 中的 ImmutableList 和 Persistant 列表。但问题并没有解决。

列表操作时如何避免不必要的重组?

Thr*_*ian 22

MutableState 使用结构相等来检查您是否使用新实例更新 state.value 。每次选择新项目时,您都会创建列表的新实例。

当您使用新实例添加、删除或更新现有项目时,您可以使用SnapshotStateList哪个触发重组。SnapshotStateList 是一个列表,它获取项目的时间复杂度为 O(1),item[index]而不是在最坏情况下使用 O(n) 迭代整个列表。

仅使用 mutableStateListOf

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

在此输入图像描述

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)
Run Code Online (Sandbox Code Playgroud)

您可以使用 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)
    }
}
Run Code Online (Sandbox Code Playgroud)

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,
                )
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

你的清单

@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)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

fun randomColor() = Color(
    Random.nextInt(256),
    Random.nextInt(256),
    Random.nextInt(256),
    alpha = 255
)
Run Code Online (Sandbox Code Playgroud)

使用视图状态

结果

在此输入图像描述

sealed class ViewState {
    object Loading : ViewState()
    data class Success(val data: List<Person>) : ViewState()
}
Run Code Online (Sandbox Code Playgroud)

并将 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)
    }
}
Run Code Online (Sandbox Code Playgroud)

1000 ms 和延迟用于演示。在真实的应用程序中,您将从 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()
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

稳定性 编辑

首先,当您将项目滚动到视口之外并且它们返回到视口中时,它们会被重新组合,这就是 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>
)
Run Code Online (Sandbox Code Playgroud)

注意:这是一个可重新启动可跳过的可组合项,如果您的列表项正在重新组合,请确保可组合项的输入稳定。

@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,
                )
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

ListScreen可组合性是不稳定的,因为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)
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

并添加了一个按钮来安排重组,以显示在 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)
            }
        )
    }
}
Run Code Online (Sandbox Code Playgroud)

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

如果您向下和向上滚动,您将看到项目按预期重新进入合成。

正如你在 gif 中看到的那样

  1. 单击任何项​​目只会触发该项目的重组
  2. 单击按钮会触发每个ListItem
  3. 单击按钮会触发重组ListScreen

在此输入图像描述

第二个问题发生,正如您在上面看到的 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)
    }
}
Run Code Online (Sandbox Code Playgroud)

并调用ListScreen

ListScreen(
    people = people,
    onItemClick = onClick
)
Run Code Online (Sandbox Code Playgroud)

现在您将看到任何组合MainScreen仅在文本(标题)中触发,并且ListScreen不会组合到列表项中。

在此输入图像描述

最后一部分是使 ListScreen 稳定。如果你改变

@Composable
fun ListScreen(
    people: List<Person>,
    onItemClick: (Int) -> Unit
) 
Run Code Online (Sandbox Code Playgroud)

@Composable
fun ListScreen(
    people: SnapshotStateList<Person>,
    onItemClick: (Int) -> Unit
) 
Run Code Online (Sandbox Code Playgroud)

你也可以参考这个答案

防止 Jetpack Compose 中的列表更新发生不必要的重组

当 Button 或在您的情况下它可能会触发重组时,不会重组任何内容。

在此输入图像描述

如果您想测试它,还有完整的演示。

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
)
Run Code Online (Sandbox Code Playgroud)