Jetpack-当 RecycleView 遇到 Databinding (二)

  • 配合 Databinding Listadapter 实现极简 RecycleView (二)

  • 资料来源:

    https://developer.android.com/topic/libraries/data-binding
    https://fraggjkee.medium.com/recyclerview-2020-a-modern-way-of-dealing-with-lists-in-android-using-databinding-part-2-df69f0a741f8

  • 更新

    1
    2
    21.02.09 初始化
    21.02.10 补充一个 recycleview 的坑

导语

最近重写 deepsleep 又回到这个主题了,参考 RecyclerView 2020: a modern way of dealing with lists in Android using DataBinding 又再次简化了写法.

目标:

  • 重用 RecycleAdapter 和 ViewHolder,不需要每个类型都写一个.
  • UI 的局部刷新,不需要每次都通知整个列表刷新.
  • 与 databinding 写法结合,越简单越好.

方案

Jetpack-当 RecycleView 遇到 Databinding 有几个问题

  • 每次都要给 RecycleAdapter layoutid,这样没法在同一个 RecycleView 创建多种布局.
  • 事件处理也是类似的,外部传入 Handle,无法在不通布局上通用.
  • RecycleAdapter 创建再绑定数据,到 Activity/Fragment 中实现还是繁琐.

因此参考 RecyclerView 2020: a modern way of dealing with lists in Android using DataBinding 有了几点调整.

  • 在原有 item 结构上添加 Handle 和 layoutid,这样就能随意复用布局了.
  • 传入 items 列表时,结合 databinding 简化了绑定数据.

Base

抽象出 BaseItemHandle 和 BaseItem,需要其他 item 继承自 BaseItem.

BaseItemHandle 是事件处理的基类.

1
2
3
4
5
open class BaseItemHandle {  
fun onClick() {
LogUtil.d("ItemHandle", "base")
}
}

BaseItem 是所有传入 Adapter 的 Item 的基类,创建时需要指定 handle 和 layoutId.同时配合使用 ListAdapter 需要实现对比 Item 的两个方法.

1
2
3
4
5
6
7
8
open class BaseItem(  
open val data: Any,
open val handle: BaseItemHandle,
@LayoutRes open val layoutId: Int
) {
open fun getID(): String = ""//两个item 是不是一个 item
open fun getContent(): Int = 0 //同一个 item 内容是否有更新
}

DiffCallback 是 ListAdapter 对比的实现,调用 BaseItem 的两个方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DiffCallback : DiffUtil.ItemCallback<BaseItem>() {  
/\*Is it the same object\*/
override fun areItemsTheSame(
oldItem: BaseItem,
newItem: BaseItem
): Boolean {
return oldItem.getID() == newItem.getID()
}

/\*Whether the content is the same\*/
override fun areContentsTheSame(
oldItem: BaseItem,
newItem: BaseItem
): Boolean {
return oldItem.getContent() == newItem.getContent()
}
}

Adapter

ViewHolder 的具体作用仅仅是绑定 databinding 生成类和数据了.(要求xml 布局中变量名是 item)

1
2
3
4
5
6
7
class ViewHolder(var binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) {  
// 绑定
fun bind(item: BaseItem?) {
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
}

RecycleAdapter

  • 必须实现的是 onCreateViewHolderonBindViewHolder.
  • onCreateViewHolder 创建 ViewHolder.
    • 需要 databinding 生成类 调用DataBindingUtil.inflate 需要 layoutid.
    • 这里覆写 getItemViewType 是其返回的值为 layoutid.
  • onBindViewHolder 是绑定,这里仅仅是调用 ViewHolder::bind
  • 很重要的一点必须覆写 getItemId,否则会有条目重复. 原因大概是 recycleview 的缓存机制.
  • 如果每个 item 都有唯一的 id 则设置 setHasStableIds(true) 会提高性能.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class RecycleAdapter : ListAdapter<BaseItem, ViewHolder>(DiffCallback()) {  

init {
setHasStableIds(true)//表示每个 item 都有唯一 id
}

// viewType 代表 layoutId ,需要覆写 getItemViewType() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
viewType,
parent,
false )
)
}

// 绑定
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}

// 复写 getItemViewType 返回 layoutId override fun getItemViewType(position: Int): Int {
return getItem(position).layoutId
}

// very import 防止重复条目
override fun getItemId(position: Int): Long = position.toLong()
}

xml

在 item 的 xml 布局中必须将变量名称声明成 item.

1
2
3
4
<data>  
<variable name\="item"
type\="com.js.deepsleep.ui.app.ItemApp" />
</data>

事件处理,则调用 handle.

1
2
3
4
5
6
7
<ImageView  
android:id\="@+id/ivExpander"
android:layout\_width\="30dp"
...
android:onClick\="@{() -> item.handle.load(item)}"
...
tools:ignore\="ContentDescription,OnClick" />

绑定

声明一个 databinding 自定义方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@JvmStatic  
@BindingAdapter("items")
fun setRecyclerViewItems(
recyclerView: RecyclerView,
items: List<BaseItem>?
) {
var adapter = (recyclerView.adapter as? RecycleAdapter)
if (adapter == null) {
adapter = RecycleAdapter()
recyclerView.adapter = adapter
}
// items 为空 orEmpty 返回空实例
adapter.submitList(items.orEmpty())
}

在这个方法中完成了 adapter 的绑定.而且全局复用一个 adapter.

之后要在父布局中声明带 list 数据的变量

1
2
3
4
5
<data>
<variable
name="vm"
type="com.js.deepsleep.ui.app.AppViewModel" />
</data>

声明 RecyclerView

1
2
3
4
5
6
7
8
9
10
11
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/app_list"
android:layout_width="match_parent"
android:layout_height="match_parent"

app:items="@{vm.list}"app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="MissingConstraints" />

之后要在 acticity/fragment 中初始化 AppViewModel,绑定数据.这里用的是 livedata 还需要绑定声明周期.

1
2
3
4
5
var binding = FragmentAppBinding.inflate(inflater, container, false)
//绑定 vm
binding.vm = viewModel
//绑定声明周期
binding.lifecycleOwner = this

java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid item position -1(offset:0).st

上面的一切完美,但是快速滑动获取快速切换数据源时,会出现闪退.logcat 提示 java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid item position -1(offset:0).st

搜索查阅后,只能说这是个 recycleview 的内部 bug,似乎是快速刷新时数据源没有来的及更新,recycleview 就刷新了.adapter 数据和集合中数据不一致.

解决方案

  • 不再使用 ListAdapter 自行实现 Adapter 的各种 notifyxx()
  • 覆写 LayoutManager,将这个错误打印而不是抛出.

因为实际上数据源刷新后,recycleview 能够正常显示,因此这个错误直接捕获不处理也是可行的.

MyLayoutManager 继承自 LinearLayoutManager.在 onLayoutChildren 中捕获错误输出.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class MyLayoutManager : LinearLayoutManager {  
constructor(context: Context?) : super(context)
constructor(context: Context?, orientation: Int, reverseLayout: Boolean) : super(
context,
orientation,
reverseLayout
)

constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)

override fun onLayoutChildren(
recycler: Recycler,
state: RecyclerView.State
) {
try {
super.onLayoutChildren(recycler, state)
} catch (e: IndexOutOfBoundsException) {
LogUtil.d("test2", "$e")
e.printStackTrace()
}
}
}

将 recycleview 的 layoutmamger 替换

1
app:layoutManager=".ui.databinding.MyLayoutManager"

一切 ok

结语

目前这个方案就在 deepsleep 中使用.只剩下 recycleview 的多选批处理了,来回折腾了好几次,都没有太好的办法.因此暂时搁置.