More ViewBinding packaging ideas, adapting to BRVAH is so simple

Get into the habit of writing together! This is the first day of my participation in the "Nuggets Daily New Plan · April Update Challenge", click to view the details of the event .

foreword

When optimizing the tool class of ViewBinding some time ago, I suddenly thought of a new encapsulation idea, which can further simplify the use of ViewBinding. Personally, I haven't seen anyone encapsulate ViewBinding in this way on the Internet, and I feel it is necessary to share it.

But some people may ask, are you still learning ViewBinding in 2022? Although the official push of Jetpack Compose is now, Compose not only needs to learn Kotlin, but also a new way of writing UI, and the learning cost is very high. Many people have the idea of ​​"Java is not unusable" and "xml layout is not unusable", and they are not so willing or have so much time to learn Compose. The popularization of Copmpose still has a long way to go. So before the popularity of Compose, ViewBinding is the best choice, and I personally think it is necessary to learn it. Even if Compose is used now, not all pages will use it, and some will write xml layout.

First of all, it is not to overthrow the existing packaging scheme, but to supplement and improve the existing scheme, which can expand more usage scenarios. Also, this is not a particularly difficult idea. You may have seen similar ideas in other scenarios, but I haven't seen anyone use it in ViewBinding.

This article will focus on encapsulation. If you don't know about ViewBinding, you can read the article about ViewBinding before.

  1. "Elegantly encapsulate and use ViewBinding, it's time to replace Kotlin synthetic and ButterKnife"
  2. "ViewBinding's ingenious encapsulation idea, it can also be adapted to BRVAH in this way"

The essence of ViewBinding

Let's briefly go over the usage of ViewBinding first, and use ViewBinding in the build.gradleconfiguration .

android {
    buildFeatures {
        viewBinding true
    }
}
复制代码

配置之后每个布局都会生成对应的 ViewBinding 类,比如我们创建一个 layout_test.xml

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <TextView
    android:id="@+id/tv_hello_world"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello world!"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
复制代码

这就会生成一个 LayoutTestBinding 类,有三种创建 Binding 对象的方式,可根据情况进行选择。通过 ViewBinding 对象可以获得布局上声明了 id 的控件。

val binding = LayoutTestBinding.inflate(layoutInflater)
// val binding = LayoutTestBinding.inflate(layoutInflater, parent, false)
// val binding = LayoutTestBinding.bind(view)
binding.tvHelloWorld.text = "Hello Android!"
复制代码

有些人可能会好奇这个 Binding 类是什么东西,我们一起来看下源码:

public final class LayoutTestBinding implements ViewBinding {
  @NonNull
  private final ConstraintLayout rootView;

  @NonNull
  public final TextView tvHelloWorld;

  private LayoutTestBinding(@NonNull ConstraintLayout rootView, @NonNull TextView tvHelloWorld) {
    this.rootView = rootView;
    this.tvHelloWorld = tvHelloWorld;
  }

  @Override
  @NonNull
  public ConstraintLayout getRoot() {
    return rootView;
  }
  
  // ...
}
复制代码

从上面部分的代码可以看到 Binding 类生成了布局上所有加了 id 的控件和根视图控件,然后这些控件都是在私有的构造函数传入。

我们再来看下剩下的代码,有三个创建 Binding 对象的静态函数。

public final class LayoutTestBinding implements ViewBinding {
  
  // ...

  @NonNull
  public static LayoutTestBinding inflate(@NonNull LayoutInflater inflater) {
    return inflate(inflater, null, false);
  }

  @NonNull
  public static LayoutTestBinding inflate(@NonNull LayoutInflater inflater,
      @Nullable ViewGroup parent, boolean attachToParent) {
    View root = inflater.inflate(R.layout.layout_test, parent, false);
    if (attachToParent) {
      parent.addView(root);
    }
    return bind(root);
  }

  @NonNull
  public static LayoutTestBinding bind(@NonNull View rootView) {
    // The body of this method is generated in a way you would not otherwise write.
    // This is done to optimize the compiled bytecode for size and performance.
    int id;
    missingId: {
      id = R.id.tv_hello_world;
      TextView tvHelloWorld = rootView.findViewById(id);
      if (tvHelloWorld == null) {
        break missingId;
      }

      return new LayoutTestBinding((ConstraintLayout) rootView, tvHelloWorld);
    }
    String missingId = rootView.getResources().getResourceName(id);
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
  }
}
复制代码

虽然有三个静态函数可以创建 Binding 对象,但其实只有 bind() 函数会创建,另外两个 inflate() 函数最终都是调用了 bind() 函数。而 bind() 函数只是做了最简单的 findViewById() 操作,那么其实三个静态函数最终都会走了一遍 findViewById() 逻辑,找到布局上所有声明了 id 的控件并创建 Binding 对象。

那么生成的 Binding 类的本质只是一个编译器自动帮我们生成的 findViewById() 工具,并不是什么高大上的东西。

还有一个很多人会忽略的点,三个创建 Binding 类的静态函数都会调用 findViewById() 把所有控件找到。对此不清楚的话可能会写出以下代码:

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
  val binding = ItemTextBinding.bind(holder.itemView)
}
复制代码

上面的用法是不推荐的,onBindViewHolder() 会很频繁的回调,每回调一次就执行一堆 findViewById(),这么用肯定不好。ViewBinding 对象要缓存到 ViewHolder 中。

封装思路

为什么封装呢?我们使用 ViewBinding 会有很多模板代码,比如:

private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  binding = ActivityMainBinding.inflate(layoutInflater)
  setContentView(binding.root)
}
复制代码

每个类都写一遍还是有些繁琐的,有必要封装一下简化代码。

已有的方案

那么怎么封装呢?封装 ViewBinding 需要做以下两件事:

  1. 创建 ViewBinding 对象;
  2. 缓存 ViewBinding 对象;

创建 ViewBinding 对象只有两种方案,第一种是反射静态方法创建 ViewBinding 对象。第二种是不使用反射,使用高阶函数把静态函数作为参数传进来,那么执行高阶函数就会调用静态函数创建 ViewBinding 对象。

然后就是缓存方案,为什么要缓存在前面特意提了,通常的方案是使用 Kotlin 属性委托,在属性的委托类声明一个变量进行缓存。如果只是延时初始化,可以直接用延时委托 lazy {...} 的方式进行创建,有些人会自定义一个委托类,其实作用和官方的延时委托是一样的,再写一个类其实没必要。但是在 Fragment 使用不仅要延时初始化,还要在 onDestroyView() 销毁对象,就只能自定义一个属性的委托类。

上面讲的都是已有的封装方案,在常见的场景使用是没问题的。但是缓存的方案会有局限性,就是用到了属性委托,需要声明一个属性。在 ActivityFragmentDialog 等继承的类里能声明一个 binding 属性,但是没法给已有的控件声明 binding 属性,除非再写一个控件的继承类,这么用的话感觉有点蠢。

所以在一些用不了属性委托的场景,需要一种新的缓存方式。

新的思路

先说一下个人之前给自己埋的一个坑。之前给 TabLayout 封装了一个快速实现自定义标签布局的扩展方法,用法如下:

TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
  tab.setCustomView<LayoutBottomTabBinding> {
    tvTitle.setText(titleList[position])
    ivIcon.setImageResource(iconList[position])
    ivIcon.contentDescription = titleList[position]
  }
}.attach()
复制代码

这里自定义布局只会在初始化时设置一次,不需要缓存 ViewBinding 对象,而且也不好缓存。但是之后有人问我怎么在 OnTabSelectedListener 拿到前面设置的 ViewBinding 对象,他要改变字体和图标大小。

这就比较尴尬了,因为官方的代码我们改不了,没法用属性委托。当时没想到好的办法来保存 ViewBinding 对象,就让他调用 bind() 方法获取。

tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
  override fun onTabSelected(tab: TabLayout.Tab) {
    val binding = tab.customView?.let { LayoutBottomTabBinding.bind(it) }
    // ...
  }

  override fun onTabUnselected(tab: TabLayout.Tab) {
  }

  override fun onTabReselected(tab: TabLayout.Tab) {
  }
})
复制代码

这个监听事件不会像 onBindViewHolder() 回调那么频繁,而且一般布局就两三个控件。即使每次切换底部标签都会 findViewById() 也能勉强接受吧。

但是个人有些完美主义,这个问题就成为了一个心结。前段时间有空回头来想一下有没什么好的办法解决。经过了一些分析后发现只能存到 Tab 对象中,那就翻一下源码看下官方有没预留什么给我们保存东西,如果没有就比较难办了。没想到真找到了个 tag 对象能保存,并且没有地方调用过。等等,View 不是也有 tag 吗?

没错,这就是新的封装思路。通过 bind() 方法只需要一个 View 就能得到 ViewBinding 对象,而我们有 View 了,又可以把 ViewBinding 对象设置给 View 的 tag。

简而言之,有 View 了就能得到 ViewBinding 对象并存起来,实现真正意义上的绑定。

应用场景

任意的 ViewHolder 获取 binding 对象

BaseRecyclerViewAdapterHelper 为例,由于 BaseViewHolder 是库提供的,如果想增加 binding 属性,必须写个类继承 BaseViewHolder。不过个人之前分享了一个装饰模式 + 扩展函数的封装思路,能把继承类给隐藏了,看起来像是直接“增加”了个属性。

class FooAdapter : BaseQuickAdapter<Foo, BaseViewHolder>(R.layout.item_foo) {

  override fun onCreateDefViewHolder(parent: ViewGroup, viewType: Int) : BaseViewHolder { 
    return super.onCreateDefViewHolder(parent, viewType).withBinding { ItemFooBinding.bind(it) }
  }
    
  override fun convert(holder: BaseViewHolder, item: Foo) {
    holder.getViewBinding<ItemFooBinding>().tvFoo.text = item.value
  }
}
复制代码

这可以在不改动原有的代码直接兼容 ViewBinding,不过需要重写一个创建的函数,还不够完美。

我们换个封装思路,并不声明 binding 对象,而是通过 itemView 的 tag 获取 binding 对象,这就不需要继承类,可以直接用扩展函数实现:

@Suppress("UNCHECKED_CAST")
fun <VB : ViewBinding> BaseViewHolder.getBinding(bind: (View) -> VB): VB =
  itemView.getTag(Int.MIN_VALUE) as? VB ?: bind(itemView).also { itemView.setTag(Int.MIN_VALUE, it) }
复制代码

这样就不需要重写创建的方法,直接通过 ViewHolder 得到 binding 对象,用法更加简单。

class FooAdapter : BaseQuickAdapter<Foo, BaseViewHolder>(R.layout.item_foo) {
    
  override fun convert(holder: BaseViewHolder, item: Foo) {
    holder.getBinding(ItemFooBinding::bind).tvFoo.text = item.value
  }
}
复制代码

ps: 由于 BaseRecyclerViewAdapterHelper 创建 ViewHolder 会反射一次,那么创建 ViewBinding 不建议再用反射了。

优化 Fragment 销毁 binding 的时机

在 Fragment 用属性委托封装会用到 Lifecycle 监听生命周期释放 binding 对象。而 Lifecycle 与 Fragment 的生命周期回调是下面的顺序执行:

Fragment onCreateView
Lifecycle onCreateView
Lifecycle onDestroyView
Fragment onDestroyView
复制代码

我们通常会在 Fragment 的 onDestroyView() 执行释放操作。

class SomeFragment: Fragment(R.layout.fragment_some) {
  private val binding by binding(SomeFragment::bind)
  // ...

  override fun onDestroyView() {
    super.onDestroyView()
    binding.someView.release()
  }
}
复制代码

上面的代码会报错,因为根据前面的执行顺序,Lifecycle 会先销毁 binding 对象,在 Fragment 的 onDestroyView() 获取 binding 会报空指针。所以个人之前设计的 Fragment 用法是需要在另一个接口方法执行释放操作。

class SomeFragment: Fragment(R.layout.fragment_some), BindingLifecycleOwner {
  private val binding by binding(SomeFragment::bind)
  // ...
  
  override fun onDestroyViewBinding() {
    binding.someView.release()
  }
}
复制代码

功能是没问题了,但是使用成本又高了一点点。而用新的思路可以完美地解决该问题,我们在 Fragment 能获得 View,用 View 来缓存 binding 对象就不需要在属性委托声明缓存变量,也就不需要用 Lifecycle 释放缓存变量了。

fun <VB : ViewBinding> Fragment.binding(bind: (View) -> VB) = FragmentBindingDelegate(bind)

class FragmentBindingDelegate<VB : ViewBinding>(private val bind: (View) -> VB) : ReadOnlyProperty<Fragment, VB> {
  @Suppress("UNCHECKED_CAST")
  override fun getValue(thisRef: Fragment, property: KProperty<*>): VB =
    requireNotNull(thisRef.view) { "The property of ${property.name} has been destroyed." }
      .let { getTag(Int.MIN_VALUE) as? VB ?: bind(this).also { setTag(Int.MIN_VALUE, it) } }
}
复制代码

这样封装的话只要 View 还存在,ViewBinding 对象就也存在,在 Fragment 的 onDestroyView() 执行释放操作就没有问题了。当 View 销毁后,ViewBinding 也一起销毁。

更新动态添加的布局

适用于 TabLayout 更新自定义标签布局、NavigationView 更新头部布局等动态添加布局的场景。比如封装一个 NavigationView 更新头部布局的扩展函数:

fun <VB : ViewBinding> NavigationView.updateHeaderView(bind: (View) -> VB, index: Int = 0, block: VB.() -> Unit) =
  getHeaderView(index)?.let { getTag(Int.MIN_VALUE) as? VB ?: bind(this).also { setTag(Int.MIN_VALUE, it) } }?.run(block)
复制代码
navigationView.updateHeaderView(LayoutNavHeaderBinding::bind) {
  tvNickname.text = nickname
}
复制代码

最终方案

最后分享一下个人封装的 ViewBinding 库 —— ViewBindingKTX。可能有些小伙伴已经在使用了,目前已经升级到了 2.0 版本,推荐升级一下。

新版有对源码进行优化,之前为了方便一些人拷贝源码使用,就把很多方法写在一个 kt 文件。随着适配的场景变多,代码会看得有点乱,所以升级 2.0 版本后将代码进行拆分,方便一些感兴趣的小伙伴阅读学习源码。

Feature

  • 支持 Kotlin 和 Java 用法
  • 支持多种使用反射和不使用反射的用法
  • 支持封装改造自己的基类,使其用上 ViewBinding
  • 支持 BaseRecyclerViewAdapterHelper
  • 支持 Activity、Fragment、Dialog、Adapter
  • 支持在 Fragment 自动释放绑定类的实例对象
  • 支持实现自定义组合控件
  • 支持创建 PopupWindow
  • 支持 TabLayout 实现自定义标签布局
  • 支持 NavigationView 设置头部控件
  • 支持无缝切换 DataBinding

Gradle

在根目录的 build.gradle 添加:

allprojects {
    repositories {
        // ...
        maven { url 'https://www.jitpack.io' }
    }
}
复制代码

添加配置和依赖:

android {
    buildFeatures {
        viewBinding true
    }
}

dependencies {
    // 以下都是可选,请根据需要进行添加
    implementation 'com.github.DylanCaiCoding.ViewBindingKTX:viewbinding-ktx:2.0.3'
    implementation 'com.github.DylanCaiCoding.ViewBindingKTX:viewbinding-nonreflection-ktx:2.0.3'
    implementation 'com.github.DylanCaiCoding.ViewBindingKTX:viewbinding-base:2.0.3'
    implementation 'com.github.DylanCaiCoding.ViewBindingKTX:viewbinding-brvah:2.0.3'
}
复制代码

2.0 版本新增功能

下面主要介绍 2.0 版本新增的功能,其它使用场景可自行看下 使用文档

更简单地适配 BRVAH

与老版本相比不需重写创建 BaseViewHolder 的方法,用法更加简单。

Kotlin 用法:

class FooAdapter : BaseQuickAdapter<Foo, BaseViewHolder>(R.layout.item_foo) {

  override fun convert(holder: BaseViewHolder, item: Foo) {
    holder.getBinding(ItemFooBinding::bind).tvFoo.text = item.value
  }
}
复制代码

Java 用法:

public class FooAdapter extends BaseQuickAdapter<Foo, BaseViewHolder> {

  public FooAdapter() {
    super(R.layout.item_foo);
  }

  @Override
  protected void convert(@NotNull BaseViewHolder holder, Foo foo) {
    ItemFooBinding binding = BaseViewHolderUtil.getBinding(holder, ItemFooBinding::bind);
    binding.tvFoo.setText(foo.getValue());
  }
}
复制代码

随心所欲地自定义 TabLayout

之前只是支持快速实现 TabLayout 的自定义布局,比如 TabLayout + ViewPager2 快速实现自定义底部导航栏:

TabLayoutMediator(tabLayout, viewPager2, false) { tab, position ->
  tab.setCustomView<LayoutBottomTabBinding> {
    tvTitle.text = getString(tabs[position].title)
    ivIcon.setImageResource(tabs[position].icon)
    tvTitle.contentDescription = getString(tabs[position].title)
  }
}.attach()
复制代码

现在增加了 TabLayout.updateCustomTab<VB> {...} 方法可以更新自定义的布局,比如收到消息后在第二个标签显示小红点:

viewModel.unreadCount.observe(this) { count ->
  tabLayout.updateCustomTab<LayoutBottomTabBinding>(1) {
    ivUnreadState.isVisible = count > 0
  }
}
复制代码

还能使用 TabLayout.doOnCustomTabSelected<VB> (...) 监听点击事件,比如点击了第二个标签后,更新未读数量隐藏小红点:

tabLayout.doOnCustomTabSelected<LayoutBottomTabBinding>(
  onTabSelected = { tab ->
    if (tab.position == 1) {
      viewModel.unreadCount.value = 0
    }
  })
复制代码

想加未读数量或者切换动画都很简单,基本可以实现任意布局的底部导航栏了。

快速实现简单的列表

之前考虑到大家用的列表适配器各不相同,所以只对 ViewHolder 进行了封装,并没有提供适配器基类。但是没有适配器基类并不是很方便,所以在 viewbinding-base 依赖增加了快速实现简单列表的用法。

这是基于 RecyclerViewListAdapter 实现的(注意不是 ListViewListAdapter)。可能有的人没用过,简单介绍一下,ListAdapter 是官方基于 DiffUtil 封装的适配器,设置最新的数据会自动执行改变的动画,用起来方便很多。

ListAdapter 之前需要写一个类继承 DiffUtil.ItemCallback<T>,实现两个方法,一个比较是否是同一项,一个比较内容是否相同。个人习惯将该类写在对应的实体类中,方便在 ListAdapter 复用。

data class Message(
  val id: String,
  val content: String
) {
  class DiffCallback : DiffUtil.ItemCallback<Message>() {
    override fun areItemsTheSame(oldItem: Message, newItem: Message) = oldItem.id == newItem.id
    override fun areContentsTheSame(oldItem: Message, newItem: Message) = oldItem == newItem
  }
}
复制代码

之后就可以写个类继承 ListAdapter,需要在构造函数传入对应的 DiffUtil.ItemCallback<T>,其它的和写一个 Adapter 差不多。

class MessageAdapter : ListAdapter<Message, MessageAdapter.ViewHolder>(Message.DiffCallback()) {

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
    ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_message, parent, false))

  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.tvContent.text = getItem(position).content
  }

  class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
    val tvContent: TextView = root.findViewById(R.id.tv_content)
  }
}
复制代码

之后发送列表数据就会自动执行更新动画,不需要调用插入、移除等方法。

adapter.submitList(newList)
复制代码

本库用 ListAdapter 结合 ViewBinding 封装了一个 SimpleListAdapter<T, VB> 类简化用法。

class MessageAdapter : SimpleListAdapter<Message, ItemMessageBinding>(Message.DiffCallback()) {

  override fun onBindViewHolder(binding: ItemMessageBinding, item: Message, position: Int) {
    binding.tvContent.text = item.content
  }
}
复制代码

如果适配器不需要复用,可以用 simpleListAdapter<T, VB>(callback) {...} 的委托方法创建适配器,不需要特地声明一个类。

private val adapter by simpleListAdapter<Message, ItemMessageBinding>(Message.DiffCallback()) { item ->
  tvContent.text = item.content
}
复制代码

如果是 IntLongFloatDoubleBooleanString 的基础数据类型,个人已经写好了对应的 Callback 类,可使用 simpleXXXListAdapter<VB> {...} 的委托方法,也提供了对应的 SimpleXXXListAdapter<VB> 基类。

private val adapter by simpleStringListAdapter<ItemFooBinding> {
  textView.text = it
}
复制代码

支持设置点击事件和长按事件:

adapter.doOnItemClick { item, position ->
  // 点击事件
}
adapter.doOnItemLongClick { item, position ->
  // 长按事件
}
复制代码

Basically, it can meet the needs of simple lists. For complex lists, you need to implement it separately. ViewHolder.getBinding<VB>()The .

Support PopupWindow

private val popupWindow by popupWindow<LayoutPopupBinding> {
// private val popupWindow by popupWindow(LayoutPopupBinding::inflate) {
  btnLike.setOnClickListener { ... }
}
复制代码

A small partner mentioned it and packaged it easily. If you feel that there are any ViewBinding usage scenarios that are missing, you can mention Issues, and I will try my best to satisfy them.

Summarize

This article talks about the essence of ViewBinding, which is actually a tool for findViewById(). A new encapsulation idea will be shared later, which can add some scenes of obtaining binding objects on controls, and can easily use ViewBinding in BRVAH. Finally, I shared the personal encapsulated library ViewBindingKTX , and introduced the new TabLayout and list functions in version 2.0, which are more convenient to use. If you find it helpful, I hope you can click a star to support it~ I will share more packaging related articles with you later.

Guess you like

Origin juejin.im/post/7082809725703684132