Android视图绑定Google出品的ViewBinding详解

1,741 阅读9分钟

几种绑定视图方式对比

一、第一种,传统方式绑定视图(findViewById)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_height="match_parent"
        android:layout_width="match_parent"
        android:gravity="center">
        
    	<Button
            android:id="@+id/btn_login"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="登录"/>
            
</LinearLayout>
   private lateinit var mLoginBtn:Button

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mLoginBtn = findViewById(R.id.btn_login) as Button
        mLoginBtn.setOnClickListener {
            Toast.makeText(this@MainActivity,"登录",Toast.LENGTH_SHORT).show()
        }
    }

在这种方式里面,一般情况下我们会定义一个成员变量来接收视图,同时使用findViewById并做一次类型转换。

二、第二种,框架注解绑定视图(ButterKnife)

/* 引入编译插件和依赖包。略过... */

@BindView(R.id.btn_login)
lateinit var mLoginBtn:Button

override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
     setContentView(R.layout.activity_main)

     ButterKnife.bind(this)

     mLoginBtn.setOnClickListener {
          Toast.makeText(this@MainActivity,"登录",Toast.LENGTH_SHORT).show()
     }
}

可以看到,使用ButterKnife,不但要引入它的编译插件和库文件,在绑定视图的过程中,依然需要定义成员变量,代码量并没有减少。

1、ButterKnife原理

ButterKnife通过最前沿的Java技术(最初的版本可能是反射,未加考究)--Java编译时注解处理器,在编译时自动生成findViewById的代码。例如,上边的例子通过ButterKnife会生成一个MainActivity_ViewBinding 类,在该类中通过findViewById为mLoginBtn赋值。这一操作省去了开发者手动编写findViewById的时间,大大简化了代码,同时提高了开发效率。在当时的开发者看来ButterKnife不得不说是一个神器,以至于到后来成了Android项目开发的标配。

2、绝境

随着Android Studio的诞生,Eclipse开发Android项目逐渐淡出历史舞台。Android Studio的出现,带来了全新的技术,模块化风靡一时。大概在这个时候,Google官方似乎就已经有了改造R类的想法。在Android项目的library模块中,生成R类中的成员变量就已经改为了非final修饰。同时,Google官方也不再建议在app模块的代码中使用像:switch(view.getId())这样的代码。

这一改变直接致使ButterKnife无法在Android项目的library模块中使用。而此时,ButterKnife正是如日中天,追随的开发者不计其数。为了能够让ButterKnife运行在library模块,ButterKnife的作者Jake Wharton大佬曲线救国,通过生成R2类让ButterKnife在library模块中复活,并且得以发展壮大。但不得不说,此时的ButterKnife就已经埋下了深深的隐患,并导致了其最终的溃败。

Google在Android Studio 3.6 Canary 11版本中正式推出视图绑定(View Binding),相对有findViewById或者ButterKnife等现有的视图访问方式更有优势,JakeWharton也因此宣布了Butter Knife的终结。

三、第三种,插件绑定视图(Kotlin-Android-Extensions)

Kotlin Android Extensions是Kotlin团队开发的一个插件,目的是让我们在开发过程中更少的编写代码。目前包括了视图绑定的功能。

1、KAE使用步骤

1.1、在module中的build.gradle文件添加插件配置
apply plugin: 'kotlin-android-extensions'
1.2、在需要绑定视图的Activity、Fragment、Adapter及自定义View中引入资源文件
import kotlinx.android.synthetic.main.布局文件.*
例如
import kotlinx.android.synthetic.main.activity_main.*
1.3、在使用的位置,直接使用xml中对应的id访问视图,完整代码如下:
import kotlinx.android.synthetic.main.activity_main.*
...........

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        btn_login.setOnClickListener {
            Toast.makeText(this@MainActivity,"登录",Toast.LENGTH_SHORT).show()
        }
    }
}
1.4、引入文件详细说明

import kotlinx.android.synthetic.main.activity_main.*

固定前缀:import kotlinx.android.synthetic.main

布局文件名称:activity_main

.*表示引入布局下所有视图。当然,也可以只引入需要的视图,把换成对应的id就行啦,如下:

import kotlinx.android.synthetic.main.activity_main.btn_login

2、KAE绑定视图范围

2.1、在Activity中使用

引入资源文件,直接使用id访问视图,代码如下:

import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        btn_login.setOnClickListener {
            Toast.makeText(this@MainActivity,"登录",Toast.LENGTH_SHORT).show()
        }
    }
}
2.2、在Fragment中使用

引入资源文件,直接使用id访问视图,有一点特别注意:在onCreateView中不直接访问视图,因为视图没有加载完成,容易引起空指针,需要在onViewCreated中访问视图,代码如下:

import kotlinx.android.synthetic.main.view_login.*

class LoginFragment:Fragment() {
    override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?, 
    						savedInstanceState: Bundle?): View? {
        super.onCreateView(inflater, container, savedInstanceState)
        return inflater?.inflate(R.layout.view_login, container, false)
    }

    override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        btn_login.setOnClickListener {
            Toast.makeText(context,"登录", Toast.LENGTH_SHORT).show()
        }
    }
}
2.3、在Adapter中使用

在Adapter和自定义View中引入,需要在布局文件名view_login后添加view节点,如下:

import kotlinx.android.synthetic.main.view_login.view.*

引入布局文件需要添加view节点,可使用ViewHolder中的itemView直接访问视图(当然,也可以在ViewHolder中做一次视图绑定,与传统ViewHolder类似),代码如下:

import kotlinx.android.synthetic.main.view_login.view.*

class LoginAdapter(var context: Context):RecyclerView.Adapter<LoginAdapter.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(context)
                .inflate(R.layout.view_login,parent,false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.itemView.btn_login.setOnClickListener {
            Toast.makeText(context,"登录", Toast.LENGTH_SHORT).show()
        }
    }

    override fun getItemCount(): Int {
        return 3
    }

    class ViewHolder(view: View) : RecyclerView.ViewHolder(view)
}
2.4、在自定义View中使用

引入布局文件需要添加view节点,在自定义视图中,可直接使用id访问视图,代码如下:

import kotlinx.android.synthetic.main.view_login.view.*

class LoginView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    init {
        View.inflate(context,R.layout.view_login,this)

        btn_login.setOnClickListener {
            Toast.makeText(context,"登录", Toast.LENGTH_SHORT).show()
        }
    }
}

3、KAE存在的问题

通过Kotlin的扩展插件来find view,无疑是一种优秀的方案。但这一方案并不是无懈可击。它存在以下几个缺点:

  • 类型安全:res下的任何id都可以被访问,有可能因访问了非当前Layout下的id而出错;
  • 空安全:这主要体现在Configuration中的对应布局不全时,运行时可能出现NPE;
  • 兼容性:只能在kotlin中使用,java不友好;
  • 局限性:不能跨module使用; 也正是这几个缺点导致了KAE的大溃败。随着Google对亲儿子ViewBinding的大力推广,KAE最终也招架不住,只能缴械投降---Jetbrains在官网宣布废弃KAE,并推荐开发者使用ViewBinding。 在Kotlin 1.4.20-M2中,JetBrains废弃了Kotlin Android Extensions编译插件,kotlin的github链接
类型安全

我们知道KAE可以将layout文件中的id映射为View对象直接在Activity或Fragment中使用,但是无法保证layout是Activity/Fragment的当前视图。

举例说明:

activity_main.xml和activity_second.xml代码如下:

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textAccount1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>


activity_second.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textAccount2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello Second!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity代码如下:

package com.example.firstkotlin

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.android.synthetic.main.activty_second.*

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        textAccount2.text = "123456789"
    }
}

程序会出现闪退:

Application will crash because "textAccount2" doesn't exist in "activity_main"

原因:

KAE会扫描res下所有layout中的id,作为代码自动补全的候选项,textAccount2的使用虽然可以通过编译,但不是当前layout中的id,所以运行时会出错。

四、第四种,Google的ViewBinding的使用详解

到这里,以上提到的多种findView方案都已经有被废弃的趋势,Google官方正在大力推广的ViewBinding组件。ViewBinding是Google在2019年I/O大会上公布的一款Android视图绑定工具。它的使用方式有点类似DataBinding,但相比DataBinding,ViewBinding是一个更轻量级、更纯粹的findViewById的替代方案。它具有以下几个优点:

  • 类型安全: ViewBinding会基于布局中的View生成类型正确的属性。比如,在布局中放入了一个 TextView ,视图绑定就会暴露出一个 TextView 类型的属性供开发中使用;
  • 空安全:ViewBinding会检测某个视图是不是只在一些配置下存在,并依据结果生成带有 @Nullable 注解的属性。所以即使在多种配置下定义的布局文件,视图绑定依然能够保证空安全;
  • ViewBinding生成的绑定类是一个Java类,并且添加了Kotlin的注解,可以很好的支持 Java 和 Kotlin 两种编程语言;

同时,Google官方还给出了一个ViewBinding、ButterKnife以及KAE的对比,如下图:

1、在 build.gradle 中开启视图绑定

开启视图绑定无须引入额外依赖,从 Android Studio 3.6 开始,视图绑定将会内建于 Android Gradle 插件中。需要打开视图绑定的话,只需要在 build.gradle 文件中配置 viewBinding 选项:

// 需要 Android Gradle Plugin 3.6.0
android {
    viewBinding {
        enabled = true
    }
}

在 Android Studio 4.0 中,viewBinding 变成属性被整合到了 buildFeatures 选项中,所以配置要改成:

// Android Studio 4.0
android {
    buildFeatures {
        viewBinding = true
    }
}

如果,你的项目存在多个模块,则需要在每个模块的gradle中添加上述配置。完成以上配置后ViewBinding会为该 Module 内所有布局文件自动生成对应的绑定类。且无须修改原有布局的 xml 文件,ViewBinding会根据现有的布局自动完成所有工作。

您可以在任何需要填充布局的地方使用绑定对象,比如 Fragment、Activity、甚至是 RecyclerView Adapter(或者说是 ViewHolder 中)。

2、在Activty中使用ViewBinding

activity_main.xml代码如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textAccount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity的代码如下:

package com.example.firstkotlin

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.firstkotlin.databinding.ActivityMainBinding

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

	//gradle插件会自动生成一个名为ActivityMainBinding的Java类,
        //通过ActivityMainBinding获取Binding实例,
        val binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)

        binding.textAccount.text = "123456789"
    }
}

在 Activity 中使用视图绑定时,无须再调用 findViewById 方法,只要直接调用绑定对象中的对应属性即可。布局的根视图(无论有没有 id)都会自动生成一个名为 root 的属性。在 Activity 的 onCreate 方法中,要将 root 传入 setContentView 方法,从而让 Activity 可以使用绑定对象中的布局。

一个常见的错误用法是:

在开启了视图绑定的同时,依然在 setContentView(...) 中传入布局的 id 而不是绑定对象。这将造成同一布局被填充两次,同时监听器也会被添加到错误的布局对象中。

解决方案:

在 Activity 中使用视图绑定时,一定要将绑定对象的 root 属性传入 setContentView() 方法中。

3、在include标签的情况下使用ViewBinding

前面已经讲过,视图绑定会为 module 下的每一个布局文件生成一个绑定对象,这个说法在布局文件被另一个布局文件使用 include 引入时依然适用。 activity_main.xml代码如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <include
        android:id="@+id/include"
        layout="@layout/activity_title" />

    <TextView
        android:id="@+id/textAccount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

activity_title.xml代码如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textTitle"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:text="title"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

上述两个布局文件会分别生成ActivityMainBinding与ActivityTitleBinding两个Java类,并且ActivityMainBinding类中通过组合依赖了ActivityTitleBinding类。因此,使用方式如下:

package com.example.firstkotlin

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.firstkotlin.databinding.ActivityMainBinding

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

        val binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)

        binding.textAccount.text = "123456789"

        // 从ActivityMainBinding中获取ActivityTitleBinding       
        val include = binding.include

        // 通过ActivityTitleBinding为TextView赋值        
        include.textTitle.text = "标题"
    }
}

如果layout_include.xml文件位于子模块,经实践与以上代码的使用方式并无任何差异,但一定要在子模块中开启ViewBinding才行。

注意:

include 标签必须有一个 id,才能生成对应的属性。

在使用引入布局的时候,视图绑定会创建一个被引入布局绑定对象的引用。注意 include>标签有一个 id: android:id="@+id/include"。这里的逻辑跟使用普通视图一样, include 标签也需要有一个 id 才能在绑定对象中生成对应的属性。

4、在Fragment中的使用ViewBinding

在Fragment中使用ViewBinding与Activity中有些差异,则Fragment的代码如下:

package com.example.firstkotlin

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.firstkotlin.databinding.ActivityMainBinding

class FragmentA:Fragment() {

    lateinit var binding: ActivityMainBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = ActivityMainBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.textAccount.text = "987654321"
    }

}

5、在RecyclerView#Adapter中的使用ViewBinding

item_test.xml代码如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textItem"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        tools:ignore="MissingConstraints" />

</androidx.constraintlayout.widget.ConstraintLayout>

TestAdapter代码如下:

package com.example.firstkotlin

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.firstkotlin.databinding.ItemTestBinding

class TestAdapter : RecyclerView.Adapter<TestAdapter.TestViewHolder>() {

    lateinit var binding: ItemTestBinding

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TestViewHolder {
        binding = ItemTestBinding.inflate(LayoutInflater.from(parent.context))
        return TestViewHolder(binding)
    }

    override fun onBindViewHolder(holder: TestViewHolder, position: Int) {
        holder.binding.textItem.text = "item"
    }

    override fun getItemCount(): Int {
        return 10
    }

    class TestViewHolder(var binding: ItemTestBinding) :
        RecyclerView.ViewHolder(binding.root)
}

五、总结

通过以上几个实例可以看到ViewBinding的使用是非常简单的。而ViewBinding的实现原理也并不难,Gradle插件会根据布局文件在项目的build目录下生成相应的ViewBinding类。

参考文章

zhuanlan.zhihu.com/p/31940588

juejin.cn/post/690022…

juejin.cn/post/684490…

blog.csdn.net/vitaviva/ar…