Android | ViewBinding 与 Kotlin 委托双剑合璧

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 「Android 路线」| 导读 —— 从零到无穷大 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


前言

  • ViewBinding 是 Android Gradle Plugin 3.6 中新增的特性,用于更加轻量地实现视图绑定(视图与变量的绑定),可以理解为轻量版本的 DataBinding;
  • 在这篇文章里,我将总结 ViewBinding 使用方法 & 原理,如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

目录


前置知识

这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~


1. 概述

  • ViewBinding 解决了什么问题: ViewBinding 替代 findViewById,更优雅地实现视图交互;

  • ViewBinding 的实现原理: Android Gradle 插件会为每个 XML 布局文件创建一个绑定类,绑定类中包含布局文件中每个定义了android:id属性的 View 引用。假设布局文件为fragment_test.xml,则生成绑定类FragmentTestBinding.java

  • 所有 XML 布局文件都生成 Java 类,会不会导致包体积瞬间增大? 未使用的类会在混淆时被压缩。


2. ViewBinding 与其他视图绑定方案对比

在 ViewBinding 之前,业界已经有过几种视图绑定方案了,想必你也用过。那么,ViewBinding 作为后起之秀就一定比前者香吗,我带你分析下。

角度 findViewById ButterKnife Kotlin Synthetics DataBinding ViewBinding
简洁性
编译期检查
编译速度
支持 Kotlin & Java
收敛模板代码
  • 简洁性: findViewById 和 ButterKnife 需要在代码中声明很多变量,其他几种方案代码简洁读较好;

  • 编译检查: 编译期间主要有两个方面的检查:类型检查 + 只能访问当前布局中的 id。findViewById、ButterKnife 和 Kotlin Synthetics 在这方面表现较差;

  • 编译速度: findViewById 的编译速度是最快的,而 ButterKnife 和 DataBinding 中存在注解处理,编译速度略逊色于 Kotlin Synthetics 和 ViewBinding;

  • 支持 Kotlin & Java: Kotlin Synthetics 只支持 Kotlin 语言;

  • 收敛模板代码: 基本上每种方案都带有一定量的模板代码,只有 Kotlin Synthetics 的模板代码是较少的。

可以看到,并没有一种绝对优势的方法,但越往后整体的效果是有提升的。另外,❓是什么呢?


3. ViewBinding 怎么用?

这一节我们来介绍 ViewBinding 的使用方法,内容不多。

注意:ViewBinding 要求在 Android Gradle Plugin 版本在至少在 3.6 以上。

3.1 添加配置

视图绑定功能按模块级别启用,启用的模块需要在模块级 build.gralde 中添加配置。例如:

build.gradle

android {
    ...
    viewBinding {
        enabled = true
    }
}

对于不需要生成绑定类的布局文件,可以在根节点声明tools:viewBindingIgnore="true"。例如:

<LinearLayout
    ...
    tools:viewBindingIgnore="true" >
    ...
</LinearLayout>

3.2 创建绑定类

有三个创建绑定类的 API:

fun <T> bind(view : View) : T

fun <T> inflate(inflater : LayoutInflater) : T

fun <T> inflate(inflater : LayoutInflater, parent : ViewGroup?, attachToParent : Boolean) : T
  • 1、在 Activity 中使用

MainActivity.kt

class TestActivity: AppCompatActivity(R.layout.activity_test) {

    private lateinit var binding: ActivityTestBinding

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

        binding = ActivityTestBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.tvDisplay.text = "Hello World."
    }
}   
  • 2、在 Fragment 中使用

TestFragment.kt

class TestFragment : Fragment(R.layout.fragment_test) {

    private var _binding: FragmentTestBinding? = null
    private val binding get() = _binding!!

    override fun onViewCreated(root: View, savedInstanceState: Bundle?) {
        _binding = FragmentTestBinding.bind(root)

        binding.tvDisplay.text = "Hello World."
    }

    override fun onDestroyView() {
        super.onDestroyView()

        _binding = null
    }
}

3.3 避免内存泄露

这里有一个隐藏的内存泄露问题,你需要理解清楚(严格来说这并不是 ViewBinding 的问题,即使你采用其它视图绑定方案也要考虑这个问题)。

问题:为什么 Fragment#onDestroyView() 里需要置空绑定类对象,而 Activity 里不需要?
答:Activity 实例和 Activity 视图的生命周期是同步的,而 Fragment 实例和 Fragment 视图的生命周期并不是完全同步的,因此需要在 Fragment 视图销毁时,手动回收绑定类对象,否则造成内存泄露。例如:detach Fragment,或者 remove Fragment 并且事务进入返回栈,此时 Fragment 视图销毁但 Fragment 实例存在。关于 Fragment 生命周期和事务在我之前的一篇文章里讨论过:Android | Fragment 核心原理 & 面试题 (AndroidX 版本)

总之,在视图销毁但是控制类对象实例还存活的时机,你就需要手动回收绑定类对象,否则造成内存泄露。

3.4 ViewBinding 绑定类源码

反编译如下:

public final class ActivityTestBinding implements ViewBinding {
    private final ConstraintLayout rootView;
    public final TextView tvDisplay;
  
    private ActivityTestBinding (ConstraintLayout paramConstraintLayout1, TextView paramTextView)
        this.rootView = paramConstraintLayout1;
        this.tvDisplay = paramTextView;
    }
  
    public static ActivityTestBinding bind(View paramView) {
        TextView localTextView = (TextView)paramView.findViewById(2131165363);
        if (localTextView != null) {
            return new ActivityMainBinding((ConstraintLayout)paramView, localTextView);
        }else {
          paramView = "tvDisplay";
        }
        throw new NullPointerException("Missing required view with ID: ".concat(paramView));
    }
  
    public static ActivityMainBinding inflate(LayoutInflater paramLayoutInflater) {
        return inflate(paramLayoutInflater, null, false);
    }
  
    public static ActivityMainBinding inflate(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, boolean paramBoolean) {
        paramLayoutInflater = paramLayoutInflater.inflate(2131361821, paramViewGroup, false);
        if (paramBoolean) {
            paramViewGroup.addView(paramLayoutInflater);
        }
        return bind(paramLayoutInflater);
    }
  
    public ConstraintLayout getRoot() {
        return this.rootView;
    }
}

4. ViewBinding 与 Kotlin 委托双剑合璧

到这里,ViewBinding 的使用教程已经说完了。但是回过头看,有没有发现一些局限性呢?

  • 1、创建和回收 ViewBinding 对象需要重复编写样板代码,特别是在 Fragment 中使用的案例;
  • 2、binding 属性是可空的,也是可变的,使用起来不方便。

那么,有没有可优化的方案呢?我们想起了 Kotlin 属性委托,关于 Kotlin 委托机制在我之前的一篇文章里讨论过:Kotlin | 委托机制 & 原理。如果你还不太了解 Kotlin 委托,下面的内容对你会有些难度。

下面,我将带你一步步封装 ViewBinding 属性委托工具:

4.1 ViewBinding + Kotlin 委托 1.0

首先,我们梳理一下我们要委托的内容与需求,以及相应的解决办法:

需求 解决办法
需要委托 ViewBinding#bind() 的调用 反射
需要委托 binding = null 的调用 监听 Fragment 视图生命周期
期望 binding 属性声明为非空不可变变量 ReadOnlyProperty<F, V>

FragmentViewBindingPropertyV1.kt

private const val TAG = "ViewBindingProperty"

public inline fun <reified V : ViewBinding> viewBindingV1() = viewBindingV1(V::class.java)

public inline fun <reified T : ViewBinding> viewBindingV1(clazz: Class<T>): FragmentViewBindingPropertyV1<Fragment, T> {
    val bindMethod = clazz.getMethod("bind", View::class.java)
    return FragmentViewBindingPropertyV1 {
        bindMethod(null, it.requireView()) as T
    }
}

/**
 * @param viewBinder 创建绑定类对象
 */
class FragmentViewBindingPropertyV1<in F : Fragment, out V : ViewBinding>(
    private val viewBinder: (F) -> V
) : ReadOnlyProperty<F, V> {

    private var viewBinding: V? = null

    @MainThread
    override fun getValue(thisRef: F, property: KProperty<*>): V {
        // 已经绑定,直接返回
        viewBinding?.let { return it }

        // Use viewLifecycleOwner.lifecycle other than lifecycle
        val lifecycle = thisRef.viewLifecycleOwner.lifecycle
        val viewBinding = viewBinder(thisRef)
        if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
            Log.w(
                TAG, "Access to viewBinding after Lifecycle is destroyed or hasn't created yet. " +
                        "The instance of viewBinding will be not cached."
            )
            // We can access to ViewBinding after Fragment.onDestroyView(), but don't save it to prevent memory leak
        } else {
            lifecycle.addObserver(ClearOnDestroyLifecycleObserver())
            this.viewBinding = viewBinding
        }
        return viewBinding
    }

    @MainThread
    fun clear() {
        viewBinding = null
    }

    private inner class ClearOnDestroyLifecycleObserver : LifecycleObserver {

        private val mainHandler = Handler(Looper.getMainLooper())

        @MainThread
        @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
        fun onDestroy(owner: LifecycleOwner) {
            owner.lifecycle.removeObserver(this)
            mainHandler.post { clear() }
        }
    }
}

使用方法:

class TestFragment : Fragment(R.layout.fragment_test) {

    private val binding : FragmentTestBinding by viewBindingV1()

    override fun onViewCreated(root: View, savedInstanceState: Bundle?) {
        binding.tvDisplay.text = "Hello World."
    }
}

干净清爽!前面提出的三个需求也都实现了,现在我为你解答细节:

  • 问题 1、为什么可以使用 V::class.java,不是泛型擦除了吗? 利用了 Kotlin 内敛函数 + 实化类型参数,编译后函数体整体被复制到调用处,V::class.java 其实是 FragmentTestBinding::class.java。具体分析见:Java | 关于泛型能问的都在这里了(含Kotlin)

  • 问题 2、ReadOnlyProperty<F, V> 是什么? ReadOnlyProperty 是不可变属性代理,通过 getValue(...) 方法实现委托行为。第一个类型参数 F 是属性所有者,第二个参数 V 是属性类型,因为我们在 Fragment 中定义属性,属性类型为 ViewBinding,所谓定义类型参数为 <in F : Fragment, out V : ViewBinding>;

  • 问题 3、解释下 getValue(...) 方法? 直接看注释:

@MainThread
override fun getValue(thisRef: F, property: KProperty<*>): V {
    1、viewBinding 不为空说明已经绑定,直接返回
    viewBinding?.let { return it }

    2、Fragment 视图的生命周期
    val lifecycle = thisRef.viewLifecycleOwner.lifecycle

    3、实例化绑定类对象
    val viewBinding = viewBinder(thisRef)

    if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
        4.1 如果视图生命周期为 DESTROYED,说明视图被销毁,此时不缓存绑定类对象(避免内存泄漏)
    } else {
        4.2 定义视图生命周期监听者
        lifecycle.addObserver(ClearOnDestroyLifecycleObserver())
        4.3 缓存绑定类对象
        this.viewBinding = viewBinding
    }
    return viewBinding
}
  • 为什么 onDestroy() 要采用 Handler#post(Message) 完成? 因为 Fragment#viewLifecycleOwner 通知生命周期事件 ON_DESTROY 的时机在 Fragment#onDestryoView 之前。

4.2 ViewBinding + Kotlin 委托 2.0

1.0 版本使用了反射,真的一定要反射吗?反射调用 bind 函数的目的就是获得一个 ViewBinding 绑定类对象,或许我们可以试试把创建对象的行为交给外部去定义,类似这样:

inline fun <F : Fragment, V : ViewBinding> viewBindingV2(
    crossinline viewBinder: (View) -> V,
    crossinline viewProvider: (F) -> View = Fragment::requireView
) = FragmentViewBindingPropertyV2 { fragment: F ->
    viewBinder(viewProvider(fragment))
}

class FragmentViewBindingPropertyV2<in F : Fragment, out V : ViewBinding>(
    private val viewBinder: (F) -> V
) : ReadOnlyProperty<F, V> {
    相同 ...
}

使用方法:

class TestFragment : Fragment(R.layout.fragment_test) {

    private val binding by viewBindingV2(FragmentTestBinding::bind)

    override fun onViewCreated(root: View, savedInstanceState: Bundle?) {
        binding.tvDisplay.text = "Hello World."
    }
}

干净清爽!不使用反射也可以实现,现在我为你解答细节:

  • 问题 4、(View) -> V 是什么? Kotlin 高阶函数,可以把 lambda 表达式直接作为参数传递,其中 View 是函数参数,而 T 是函数返回值。lambda 表达式本质上是 「可以作为值传递的代码块」。在老版本 Java 中,传递代码块需要使用匿名内部类实现,而使用 甚至连函数声明都不需要,可以直接传递代码块作为函数值;

  • 问题 5、Fragment::requireView 是什么? 把函数 requireView() 作为参数传递。Fragment#requireView() 返回 Fragment 根视图,在 onCreateView() 之前调用 requireView() 会抛出异常;

  • 问题 6、FragmentTestBinding::bind 是什么? 把函数 bind() 作为参数传递,bind 函数的参数为 View,返回值为 ViewBinding,与函数声明 (View) -> V 匹配。

4.3 ViewBinding + Kotlin 委托 最终版

2.0 版本已经完成了针对 Fragment 的属性代理,但是实际场景中只会在 Fragment 中使用 ViewBinding 吗?显然并不是,我们还有其他一些场景:

  • Activity
  • Fragment
  • DialogFragment
  • ViewGroup
  • RecyclerView.ViewHolder

所以,我们有必要将委托工具适当封装得更通用些,完整代码和演示工程你可以直接下载查看:下载路径,这里只展示部分核心代码如下:

@JvmName("viewBindingActivity")
inline fun <A : ComponentActivity, V : ViewBinding> viewBinding(
    crossinline viewBinder: (View) -> V,
    crossinline viewProvider: (A) -> View = ::findRootView
): ViewBindingProperty<A, V> = ActivityViewBindingProperty { activity: A ->
    viewBinder(viewProvider(activity))
}

@JvmName("viewBindingActivity")
inline fun <A : ComponentActivity, V : ViewBinding> viewBinding(
    crossinline viewBinder: (View) -> V,
    @IdRes viewBindingRootId: Int
): ViewBindingProperty<A, V> = ActivityViewBindingProperty { activity: A ->
    viewBinder(activity.requireViewByIdCompat(viewBindingRootId))
}

// -------------------------------------------------------------------------------------
// ViewBindingProperty for Fragment
// -------------------------------------------------------------------------------------

@Suppress("UNCHECKED_CAST")
@JvmName("viewBindingFragment")
inline fun <F : Fragment, V : ViewBinding> Fragment.viewBinding(
    crossinline viewBinder: (View) -> V,
    crossinline viewProvider: (F) -> View = Fragment::requireView
): ViewBindingProperty<F, V> = when (this) {
    is DialogFragment -> DialogFragmentViewBindingProperty { fragment: F ->
        viewBinder(viewProvider(fragment))
    } as ViewBindingProperty<F, V>
    else -> FragmentViewBindingProperty { fragment: F ->
        viewBinder(viewProvider(fragment))
    }
}

@Suppress("UNCHECKED_CAST")
@JvmName("viewBindingFragment")
inline fun <F : Fragment, V : ViewBinding> Fragment.viewBinding(
    crossinline viewBinder: (View) -> V,
    @IdRes viewBindingRootId: Int
): ViewBindingProperty<F, V> = when (this) {
    is DialogFragment -> viewBinding(viewBinder) { fragment: DialogFragment ->
        fragment.getRootView(viewBindingRootId)
    } as ViewBindingProperty<F, V>
    else -> viewBinding(viewBinder) { fragment: F ->
        fragment.requireView().requireViewByIdCompat(viewBindingRootId)
    }
}

// -------------------------------------------------------------------------------------
// ViewBindingProperty
// -------------------------------------------------------------------------------------

private const val TAG = "ViewBindingProperty"

interface ViewBindingProperty<in R : Any, out V : ViewBinding> : ReadOnlyProperty<R, V> {
    @MainThread
    fun clear()
}

abstract class LifecycleViewBindingProperty<in R : Any, out V : ViewBinding>(
    private val viewBinder: (R) -> V
) : ViewBindingProperty<R, V> {

    private var viewBinding: V? = null

    protected abstract fun getLifecycleOwner(thisRef: R): LifecycleOwner

    @MainThread
    override fun getValue(thisRef: R, property: KProperty<*>): V {
        // Already bound
        viewBinding?.let { return it }

        val lifecycle = getLifecycleOwner(thisRef).lifecycle
        val viewBinding = viewBinder(thisRef)
        if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
            Log.w(
                TAG, "Access to viewBinding after Lifecycle is destroyed or hasn'V created yet. " +
                        "The instance of viewBinding will be not cached."
            )
            // We can access to ViewBinding after Fragment.onDestroyView(), but don'V save it to prevent memory leak
        } else {
            lifecycle.addObserver(ClearOnDestroyLifecycleObserver(this))
            this.viewBinding = viewBinding
        }
        return viewBinding
    }

    @MainThread
    override fun clear() {
        viewBinding = null
    }

    private class ClearOnDestroyLifecycleObserver(
        private val property: LifecycleViewBindingProperty<*, *>
    ) : LifecycleObserver {

        private companion object {
            private val mainHandler = Handler(Looper.getMainLooper())
        }

        @MainThread
        @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
        fun onDestroy(owner: LifecycleOwner) {
            mainHandler.post { property.clear() }
        }
    }
}

class FragmentViewBindingProperty<in F : Fragment, out V : ViewBinding>(
    viewBinder: (F) -> V
) : LifecycleViewBindingProperty<F, V>(viewBinder) {

    override fun getLifecycleOwner(thisRef: F): LifecycleOwner {
        try {
            return thisRef.viewLifecycleOwner
        } catch (ignored: IllegalStateException) {
            error("Fragment doesn't have view associated with it or the view has been destroyed")
        }
    }
}

class DialogFragmentViewBindingProperty<in F : DialogFragment, out V : ViewBinding>(
    viewBinder: (F) -> V
) : LifecycleViewBindingProperty<F, V>(viewBinder) {

    override fun getLifecycleOwner(thisRef: F): LifecycleOwner {
        return if (thisRef.showsDialog) {
            thisRef
        } else {
            try {
                thisRef.viewLifecycleOwner
            } catch (ignored: IllegalStateException) {
                error("Fragment doesn't have view associated with it or the view has been destroyed")
            }
        }
    }
}

5. 总结

ViewBinding 是一个轻量级的视图绑定方案,Android Gradle 插件会为每个 XML 布局文件创建一个绑定类。在 Fragment 中使用 ViewBinding 需要注意在 Fragment#onDestroyView() 里置空绑定类对象避免内存泄漏。但这会带来很多重复编写样板代码,使用属性委托可以收敛模板代码,保证调用方代码干净清爽。

角度 findViewById ButterKnife Kotlin Synthetics DataBinding ViewBinding ViewBindingProperty
简洁性
编译期检查
编译速度
支持 Kotlin & Java
收敛模板代码

参考资料


创作不易,你的「三连」是丑丑最大的动力,我们下次见!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342