一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 1 天,点击查看活动详情。
前言
前段时间优化 ViewBinding 的工具类时,突然想到了一个新的封装思路,能更进一步简化 ViewBinding 的使用。个人目前在网上没看到有人这样来封装 ViewBinding,感觉还是有必要分享一下。
不过可能有人会问,都 2022 年了还学 ViewBinding ?虽然现在官方在推 Jetpack Compose,但是 Compose 不仅要学 Kotlin,还要学一套新的写 UI 方式,学习成本非常高。很多人有着 “Java 又不是不能用”、“xml 布局又不是不能用”的想法,并不那么愿意或者没那么多时间去学 Compose,Copmpose 普及还是任重而道远。那么在 Compose 普及之前,ViewBinding 是最好的选择,个人认为还是有必要学一学的。即使现在有使用 Compose,也不会是全部页面都用,还是有些会写 xml 布局的。
先说明一下并不是推倒现有的封装方案,而是对已有的方案进行补充和改进,能扩展更多的使用场景。还有这并不是什么特别难想的思路,可能在别的场景有见过类似的思路,但是没看到有人在 ViewBinding 这么用。
本文会侧重讲封装,对 ViewBinding 不了解的可以先看看个人之前讲 ViewBinding 的文章。
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 需要做以下两件事:
- 创建 ViewBinding 对象;
- 缓存 ViewBinding 对象;
创建 ViewBinding 对象只有两种方案,第一种是反射静态方法创建 ViewBinding 对象。第二种是不使用反射,使用高阶函数把静态函数作为参数传进来,那么执行高阶函数就会调用静态函数创建 ViewBinding 对象。
然后就是缓存方案,为什么要缓存在前面特意提了,通常的方案是使用 Kotlin 属性委托,在属性的委托类声明一个变量进行缓存。如果只是延时初始化,可以直接用延时委托 lazy {...}
的方式进行创建,有些人会自定义一个委托类,其实作用和官方的延时委托是一样的,再写一个类其实没必要。但是在 Fragment
使用不仅要延时初始化,还要在 onDestroyView()
销毁对象,就只能自定义一个属性的委托类。
上面讲的都是已有的封装方案,在常见的场景使用是没问题的。但是缓存的方案会有局限性,就是用到了属性委托,需要声明一个属性。在 Activity
、Fragment
、Dialog
等继承的类里能声明一个 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
依赖增加了快速实现简单列表的用法。
这是基于 RecyclerView
的 ListAdapter
实现的(注意不是 ListView
的 ListAdapter
)。可能有的人没用过,简单介绍一下,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
}
复制代码
如果是 Int
、Long
、Float
、Double
、Boolean
、String
的基础数据类型,个人已经写好了对应的 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 支持一下哟 ~ 个人后面会分享更多封装相关的文章给大家。