为什么 UI 有时无法使用数据绑定在 recyclerview 中更新?

Why does UI sometimes fail to update in recyclerview with data binding?

我在 recyclerview 中有一个包含 5 个元素的列表,设置为待办事项列表。每行的复选框上都有一个侦听器,为了这个最小的可重现示例的目的,每当您选中任何复选框时,它都会随机设置 5 个复选框的值。当一个项目未被选中时,它应该以黑色文本显示,当一个项目被选中时它应该以灰色文本和斜体显示。

当我选中一个框并重置值时,UI 通常会按预期更新。但是,有时一项会卡在错误的布局中,因此复选框会显示正确的值,但文本样式是错误的。为什么这种行为不一致,我如何确保 UI 每次都刷新?

这是完整的 MRE:

MainActivity.kt

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.dalydays.android.mre_recyclerview_refresh_last_item.databinding.ActivityMainBinding
import kotlin.random.Random

class MainActivity : AppCompatActivity() {

    private lateinit var adapter: ToDoAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        binding.lifecycleOwner = this

        binding.itemsList.layoutManager = LinearLayoutManager(this)

        val onCheckboxClickListener: (ToDoItem) -> Unit = { _ ->
            adapter.submitList(getSampleList())
        }

        adapter = ToDoAdapter(onCheckboxClickListener)

        binding.itemsList.adapter = adapter

        adapter.submitList(getSampleList())
    }

    private fun getSampleList(): List<ToDoItem> {
        val sampleList = mutableListOf<ToDoItem>()

        sampleList.add(ToDoItem(id=1, description = "first item", completed = Random.nextBoolean()))
        sampleList.add(ToDoItem(id=2, description = "second item", completed = Random.nextBoolean()))
        sampleList.add(ToDoItem(id=3, description = "third item", completed = Random.nextBoolean()))
        sampleList.add(ToDoItem(id=4, description = "fourth item", completed = Random.nextBoolean()))
        sampleList.add(ToDoItem(id=5, description = "fifth item", completed = Random.nextBoolean()))

        return sampleList
    }
}

ToDoItem.kt

data class ToDoItem(
    var id: Long? = null,
    var description: String,
    var completed: Boolean = false
)

ToDoAdapter.kt

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.dalydays.android.mre_recyclerview_refresh_last_item.databinding.ChecklistItemCheckedBinding
import com.dalydays.android.mre_recyclerview_refresh_last_item.databinding.ChecklistItemUncheckedBinding

const val ITEM_UNCHECKED = 0
const val ITEM_CHECKED = 1

class ToDoAdapter(private val onCheckboxClick: (ToDoItem) -> Unit): ListAdapter<ToDoItem, RecyclerView.ViewHolder>(ToDoItemDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            ITEM_CHECKED -> ViewHolderChecked.from(parent)
            else -> ViewHolderUnchecked.from(parent)
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val toDoItem = getItem(position)
        when (holder) {
            is ViewHolderChecked -> {
                holder.bind(toDoItem, onCheckboxClick)
            }
            is ViewHolderUnchecked -> {
                holder.bind(toDoItem, onCheckboxClick)
            }
        }
    }

    override fun getItemViewType(position: Int): Int {
        val toDoItem = getItem(position)
        return when (toDoItem.completed) {
            true -> ITEM_CHECKED
            else -> ITEM_UNCHECKED
        }
    }

    class ViewHolderChecked private constructor(private val binding: ChecklistItemCheckedBinding)
        : RecyclerView.ViewHolder(binding.root) {

        fun bind(toDoItem: ToDoItem, onCheckboxClick: (ToDoItem) -> Unit) {
            binding.todoItem = toDoItem
            binding.checkboxCompleted.setOnClickListener {
                onCheckboxClick(toDoItem)
            }
            binding.executePendingBindings()
        }

        companion object {
            fun from(parent: ViewGroup): ViewHolderChecked {
                val layoutInflater = LayoutInflater.from(parent.context)
                return ViewHolderChecked(ChecklistItemCheckedBinding.inflate(layoutInflater, parent, false))
            }
        }
    }

    class ViewHolderUnchecked private constructor(private val binding: ChecklistItemUncheckedBinding)
        : RecyclerView.ViewHolder(binding.root) {

        fun bind(toDoItem: ToDoItem, onCheckboxClick: (ToDoItem) -> Unit) {
            binding.todoItem = toDoItem
            binding.checkboxCompleted.setOnClickListener {
                onCheckboxClick(toDoItem)
            }
            binding.executePendingBindings()
        }

        companion object {
            fun from(parent: ViewGroup): ViewHolderUnchecked {
                val layoutInflater = LayoutInflater.from(parent.context)
                return ViewHolderUnchecked(ChecklistItemUncheckedBinding.inflate(layoutInflater, parent, false))
            }
        }
    }
}


class ToDoItemDiffCallback : DiffUtil.ItemCallback<ToDoItem>() {
    override fun areItemsTheSame(oldItem: ToDoItem, newItem: ToDoItem): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: ToDoItem, newItem: ToDoItem): Boolean {
        return oldItem == newItem
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

        <data>

        </data>

        <RelativeLayout
            android:id="@+id/linearLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

                <androidx.recyclerview.widget.RecyclerView
                    android:id="@+id/items_list"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:paddingStart="8dp"
                    android:paddingEnd="8dp"
                    android:scrollbars="none" />

        </RelativeLayout>
</layout>

checklist_item_checked.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="todoItem"
            type="com.dalydays.android.mre_recyclerview_refresh_last_item.ToDoItem" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <CheckBox
            android:id="@+id/checkbox_completed"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:checked="@{todoItem.completed}"
            android:textAppearance="?attr/textAppearanceListItem"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tv_description"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:ellipsize="end"
            android:text="@{todoItem.description}"
            android:textAppearance="?attr/textAppearanceListItem"
            android:textColor="#65000000"
            android:textStyle="italic"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/checkbox_completed"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Mow the lawn" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

checklist_item_unchecked.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="todoItem"
            type="com.dalydays.android.mre_recyclerview_refresh_last_item.ToDoItem" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <CheckBox
            android:id="@+id/checkbox_completed"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:checked="@{todoItem.completed}"
            android:textAppearance="?attr/textAppearanceListItem"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tv_description"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:ellipsize="end"
            android:text="@{todoItem.description}"
            android:textAppearance="?attr/textAppearanceListItem"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/checkbox_completed"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Mow the lawn" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

修改这些方法

override fun areItemsTheSame(oldItem: ToDoItem, newItem: ToDoItem): Boolean {
    return oldItem.id == newItem.id
}

override fun areContentsTheSame(oldItem: ToDoItem, newItem: ToDoItem): Boolean {
    return ((oldItem.id == newItem.id) && (oldItem.description == newItem.description) && (oldItem.completed == newItem.completed) 
}

同时覆盖方法 getNewListSize() & getOldListSize()

也许不是 "best" 解决方案,但这是我想到的。我确定复选框动画和 recyclerview 刷新动画之间的时间可能存在问题。有时,根据 recyclerview 进行差异化所花费的时间,recyclerview 可以尝试在复选框完成动画之前或之后进行刷新。当它之前完成时,复选框动画会阻塞 recyclerview 动画并使 UI 处于错误状态。否则它似乎按预期工作。

我决定手动 运行 adapter.notifyItemChanged(position) 即使 RecyclerView.ListAdapter 应该自动处理。根据 diff 何时完成计算,它的动画仍然有些不一致,但这比让 UI 处于不良状态要好得多,而且比每次使用 notifyDataSetChanged().[= 刷新整个列表要好得多。 16=]

在 MainActivity 中,将复选框监听器更改为:

val onCheckboxClickListener: (ToDoItem, Int) -> Unit = { _, position ->
    adapter.submitList(getSampleList())
    adapter.notifyItemChanged(position)
}

在 ToDoAdapter 中,将 class header 更改为:

class ToDoAdapter(private val onCheckboxClick: (ToDoItem, Int) -> Unit): ListAdapter<ToDoItem, RecyclerView.ViewHolder>(ToDoItemDiffCallback()) {

并将两个 bind() 函数更改为:

fun bind(toDoItem: ToDoItem, onCheckboxClick: (ToDoItem, Int) -> Unit) {
    binding.todoItem = toDoItem
    binding.checkboxCompleted.setOnClickListener {
        onCheckboxClick(toDoItem, layoutPosition)
    }
    binding.executePendingBindings()
}