更多 ViewBinding 的封装思路,适配 BRVAH 竟如此简单

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 1 天,点击查看活动详情

前言

前段时间优化 ViewBinding 的工具类时,突然想到了一个新的封装思路,能更进一步简化 ViewBinding 的使用。个人目前在网上没看到有人这样来封装 ViewBinding,感觉还是有必要分享一下。

不过可能有人会问,都 2022 年了还学 ViewBinding ?虽然现在官方在推 Jetpack Compose,但是 Compose 不仅要学 Kotlin,还要学一套新的写 UI 方式,学习成本非常高。很多人有着 “Java 又不是不能用”、“xml 布局又不是不能用”的想法,并不那么愿意或者没那么多时间去学 Compose,Copmpose 普及还是任重而道远。那么在 Compose 普及之前,ViewBinding 是最好的选择,个人认为还是有必要学一学的。即使现在有使用 Compose,也不会是全部页面都用,还是有些会写 xml 布局的。

先说明一下并不是推倒现有的封装方案,而是对已有的方案进行补充和改进,能扩展更多的使用场景。还有这并不是什么特别难想的思路,可能在别的场景有见过类似的思路,但是没看到有人在 ViewBinding 这么用。

本文会侧重讲封装,对 ViewBinding 不了解的可以先看看个人之前讲 ViewBinding 的文章。

  1. 《优雅地封装和使用 ViewBinding,该替代 Kotlin synthetic 和 ButterKnife 了》
  2. 《 ViewBinding 巧妙的封装思路,还能这样适配 BRVAH 》

ViewBinding 的本质

先简单过下 ViewBinding 用法,在 build.gradle 配置使用 ViewBinding。

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 ->
  // 长按事件
}
复制代码

基本能满足简单列表的需求了,复杂列表的话就需要大家另外实现了,其它适配器可以使用 ViewHolder.getBinding<VB>() 的扩展函数。

支持 PopupWindow

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

有个小伙伴提到了就顺手封装一下,大家如果觉得还缺什么 ViewBinding 的使用场景都可以提 Issues,个人会尽量满足。

总结

本文讲了 ViewBinding 的本质,其实就是一个 findViewById() 的工具。后面分享一个新的封装思路,能补充一些在控件上获取 binding 对象的场景,能很简单的在 BRVAH 使用 ViewBinding。最后分享了个人封装的库 ViewBindingKTX,介绍了 2.0 版本新增的 TabLayout 和列表功能,用起来更加方便。如果您觉得有帮助的话,希望能点个 star 支持一下哟 ~ 个人后面会分享更多封装相关的文章给大家。

猜你喜欢

转载自juejin.im/post/7082809725703684132