Jetpack组件之DataBinding

Jetpack组件之DataBinding

八归少年 3,141 2021-03-31

Android Jetpack组件系列文章:
Android Jetpack组件(一)LifeCycle
Android Jetpack组件(二)Navigation
Android Jetpack组件(三)ViewModel
Android Jetpack组件(四)LiveData
Android Jetpack组件(五)Room
Android JetPack组件(六)DataBinding
Android Jetpack组件(七)Paging
Android Jetpack组件(八)WorkManager
Android Jetpack组件(九)DataStore
Android Jetpack组件(十)App Startup
Android Jetpack组件(十一)Compose

首语

Android 布局文件通常只负责UI的布局工作,页面通过setContentView()关联布局文件,再通过UI控件的id找到控件,接着在页面中通过代码对控件进行操作,因此,页面承担了很大的工作量.为了减轻页面的工作量,Google推出了DataBinding,使得页面和布局之间的耦合度降低。

优势

  1. 项目更加简介,代码可读性更高。
  2. 不再需要findViewById()
  3. 布局文件可以包含简单的业务逻辑。

DataBinding是我第一个使用的Jetpack的组件,用起来是真的舒服。之前为了繁杂的findViewById(),一直使用ButterKnife(参考之前文章)来代替这些工作。现在官方已经不推荐使用它了,且停止维护。因此,使用DataBinding来代替它。

简单配置

要想使用DataBinding,首先需要在app.gradle中启用它。

android {
    .....
    dataBinding{
        enabled=true
    }
}

接着修改布局文件,需要在布局外层添加<layout></layout>标签,将鼠标移动至布局文件根目录的位置,使用快捷键(alt+enter),选择“Convert to data binding layout”选项,就会自动生成DataBinding布局文件。

<?xml version="1.0" encoding="utf-8"?>
<layout 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">
     <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/text_home"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:textAlignment="center"
            android:textSize="20sp"
            android:text="0"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
        
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

使用

经过简单配置以后,就可以实例化布局文件了,我们删除掉传统的setContentView(),通过 DataBindingUtil.setContentView()来实例化文件。实例化返回的布局文件对象,名字和布局文字名字一致,遵循大驼峰命名规则,后面加上Binding。然后通过binding对象得到控件,控件命名遵循小驼峰规则。

ActivityMainBinding binding=DataBindingUtil.setContentView(this,R.layout.activity_main);
binding.textHome.setText("hello databinding!");

数据绑定

如何将数据传递到布局文件中呢?首先,在布局文件中定义布局变量<variable/>,指定对象的名字和类型,当然数据的操作在<data></data>标签里。data标签里用于放在布局文件中各个UI控件所需要的数据,这些数据类型可以是自定义类型,也可以是基本类型。

<data>

        <variable
            name="book"
            type="com.yhj.jetpackstudy.Book" />
        <variable
            name="number"
            type="Integer" />

</data>
public class Book {

    private int id;
    private String title;
    private String author;

}

有时我们需要在布局文件中引入一些Java工具类或静态类,处理一些简单的逻辑在布局中,我们可以使用<import />标签导入。使用alias,当类名有冲突时,其中一个类可使用别名重命名。默认导入java.lang.*

<data>    
      <import type="com.yhj.jetpackstudy.ui.home.Constants"alias="reName"/>
</data>

布局中的数据绑定使用“@{}”语法写入属性中,通过布局表达式的形式设置TextView的text。

<TextView android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:text="@{book.title}" />
          
<TextView android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:text="@{Constants.APP_ID}" />
          

DataBinding为了方便使用,对布局变量提供了Setter类,因此,在Activity中,通过setBook(),将Book对象传递给布局变量。

Book book = new Book(0, "android", "yhj");
//BR类似于Android中的R类,由DataBinding自动生成,用于存放所有布局变量的id。
//DataBinding为了方便使用提供了Setter类,直接使用setXxx()
//binding.setVariable(BR.book,book);
binding.setBook(book);

绑定后,就不需要再Activity中设置内容了,实现了布局与页面的解耦。
DataBinding具有Null校验,如果绑定值为null,则分配默认值null,如果类型为int,默认值为0。

表达式语言

在布局中可以包含简单的数据逻辑,可以使用以下运算符和关键字。

  • 算术运算符 + - / * %
  • 字符串连接运算符 +
  • 逻辑运算符 && ||
  • 二元运算符 & | ^
  • 一元运算符 + - ! ~
  • 移位运算符 >> >>> <<
  • 比较运算符 == > < >= <=(请注意,< 需要转义为 <)
  • instanceof
  • 分组运算符 ()
  • 字面量运算符 - 字符、字符串、数字、null
  • 类型转换
  • 方法调用
  • 字段访问
  • 数组访问 []
  • 三元运算符 ?:
  • Null 合并运算符
  • 视图引用
    android:text="@{String.valueOf(index + 1)}"
    android:visibility="@{age > 13 ? View.GONE : View.VISIBLE}"
    android:transitionName='@{"image_" + id}'	
    <!--Null 合并运算符-->
    android:text="@{user.displayName ?? user.lastName}"	
    <!--集合-->
 	android:text="@{list[index]}"
 	<!--字符串字面量,两种均可-->
 	android:text="@{map[`firstName`]}"
 	android:text='@{map["firstName"]}'
 	<!--资源-->
 	android:padding="@{large? @dimen/largePadding : @dimen/smallPadding}"
    <!--TextView视图引用同一布局中的EditText视图-->
    <EditText
        android:id="@+id/example_text"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"/>
    <TextView
        android:id="@+id/example_output"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{exampleText.text}"/>

事件响应

DataBinding在布局文件中除了绑定数据外,还能够响应用户事件。
首先创建一个事件类,用于接收和响应onClick()事件。

public class HandleListener {
    private Context context;

    public HandleListener(Context context) {
        this.context = context;
    }

    public void onClicked(View view) {
        Toast.makeText(context, "the button was clicked!", Toast.LENGTH_SHORT).show();
    }
}

<data>标签中定义布局变量。通过布局表达式,调用onClicked().

 <variable
            name="handler"
            type="com.yhj.jetpackstudy.ui.home.HandleListener" />
           
  android:onClick="@{handler::onClicked}"

最后在Activity中将Activity与布局绑定,实例化HandleListener类,传入布局文件。

 binding.setHandler(new HandleListener(this));

二级页面的绑定

对于布局层次结构复杂的页面,我们会将部分布局独立成一个单独的布局文件,通过<include />标签去引用单独的布局文件,也被称为二级页面。
我们在一级页面中绑定数据后,如何将数据传递到二级页面呢?

 <!--自定义的命名空间-->
 <!--xmlns:bind="http://schemas.android.com/apk/res-auto"-->
 <!--bind:book="@{book}"-->
 <data>
        <variable
            name="book"
            type="com.yhj.jetpackstudy.Book" />

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.home.HomeFragment">

        <include
            app:book="@{book}"
            android:id="@+id/include"
            layout="@layout/fragment_dashboard"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

命名空间app名字可以自定义,布局变量book也是命名空间xmlns:app的一个属性。一级页面正是通过命名空间xmlns:app引用布局变量book,将数据传递给二级页面的。
需要注意的是,数据绑定不支持include作为merge元素的直接子布局。merge是用来帮助在视图树中减少重复布局的。
在二级页面中,我们需要定义一个和一级页面相同的布局变量,用于接收传递过来的数据。然后就可以使用book进行数据绑定了。

 <data>
        <variable
            name="book"
            type="com.yhj.jetpackstudy.Book" />

 </data>
  <TextView
            android:id="@+id/text_home"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{book.title}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

BindingAdapter的原理

DataBinding为我们生成数据绑定需要的各种类,其中包含了大量的静态方法,这些静态方法都有@BindingAdapter注解,在注解中的别名对应UI控件在布局文件中的属性。

public class ViewBindingAdapter {
   ....
    @BindingAdapter({"android:padding"})
    public static void setPadding(View view, float paddingFloat) {
        final int padding = pixelsToDimensionPixelSize(paddingFloat);
        view.setPadding(padding, padding, padding, padding);
    }
}
@RestrictTo(RestrictTo.Scope.LIBRARY)
@BindingMethods({
        @BindingMethod(type = android.widget.ImageView.class, attribute = "android:tint", method = "setImageTintList"),
        @BindingMethod(type = android.widget.ImageView.class, attribute = "android:tintMode", method = "setImageTintMode"),
})
public class ImageViewBindingAdapter {
    @BindingAdapter("android:src")
    public static void setImageUri(ImageView view, String imageUri) {
        if (imageUri == null) {
            view.setImageURI(null);
        } else {
            view.setImageURI(Uri.parse(imageUri));
        }
    }

    @BindingAdapter("android:src")
    public static void setImageUri(ImageView view, Uri imageUri) {
        view.setImageURI(imageUri);
    }

    @BindingAdapter("android:src")
    public static void setImageDrawable(ImageView view, Drawable drawable) {
        view.setImageDrawable(drawable);
    }
}

DataBinding以静态方法的形式为UI控件各个属性绑定了相应的代码逻辑,如果在UI控件中的属性使用了布局表达式,那么当布局文件渲染时,绑定它的静态方法自动被调用。

自定义BindingAdapter

在项目开发中,经常使用ImageView来加载网络图片,但是在布局文件中不能设置图片url,我们可以使用BindingAdapter来解决这个问题。

public class CustomImageView extends AppCompatImageView {
    public CustomImageView(Context context) {
        super(context);
    }

    public CustomImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    /**
     * @param view 本身
     * @param imageUrl 图片地址
     */
    @BindingAdapter(value = {"image_url"})
    public static void setImageUrl(CustomImageView view, String imageUrl) {
	    //网络图片加载框架选择Glide
        Glide.with(view).load(imageUrl).into(view);
    }
}

通过自定义ImageView的方式添加静态方法,并给静态方法添加@BindingAdapter的注解,设置别名为image_url,布局文件通过别名来调用该方法。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="imageUrl"
            type="String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">


        <com.yhj.jetpackstudy.ui.home.CustomImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:image_url="@{imageUrl}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

最后给布局变量赋值。

//Fragment中通过 inflater.inflate,相应的DataBinding通过DataBindingUtil.inflate
//View root = inflater.inflate(R.layout.fragment_notifications, container, false);
FragmentNotificationsBinding binding= DataBindingUtil.inflate(inflater,R.layout.fragment_notifications,null,false);
binding.setImageUrl("https://www.yanghujun.top/web_image/0d9e080e3da409db7d0e0bae3e88bc88.jpg");
多参数重载

在项目开发中除了设置网络图片外,还有如设置图片圆角,圆形图片等需求,通过BindingAdapter都可以实现。

	/**
     * @param view     本身
     * @param imageUrl 图片地址
     */
    @BindingAdapter(value = {"image_url"})
    public static void setImageUrl(CustomImageView view, String imageUrl) {
        setImageUrl(view, imageUrl, false);
    }

    /**
     * @param isCircle 是否圆形图片
     */
    @BindingAdapter(value = {"image_url", "isCircle"})
    public static void setImageUrl(CustomImageView view, String imageUrl, boolean isCircle) {
        setImageUrl(view, imageUrl, isCircle, 0);
    }

    /**
     * @param radius 设置图片圆角
     */
    @BindingAdapter(value = {"image_url", "isCircle", "radius"}, requireAll = false)
    public static void setImageUrl(CustomImageView view, String imageUrl, boolean isCircle, int radius) {
        SecureRandom secureRandom = new SecureRandom();
        int i = secureRandom.nextInt(16);
        RequestOptions mRequestOptions = null;
        RequestBuilder<Drawable> builder = Glide.with(view).load(imageUrl).placeholder(VERTICAL_IMAGES_BG[i]);
        if (isCircle) {
            mRequestOptions = RequestOptions.circleCropTransform();
        } else if (radius > 0) {
            //设置图片圆角角度
            builder.transform(new RoundedCorners(PixUtils.dp2px(radius)));
        }
        if (mRequestOptions != null) {
            builder.apply(mRequestOptions);
        }
        ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
        if (layoutParams != null && layoutParams.width > 0 && layoutParams.height > 0) {
            builder.override(layoutParams.width, layoutParams.height);
        }
        builder.into(view);
    }

@BindingAdapter注解中,参数以value={"",""}的形式存在,变量requireAll设置参数是否必须赋值,默认为true,同时配合Glide设置图片的圆角、展位图和尺寸等。
最后在布局文件中根据需求来调用静态方法。

   <com.yhj.jetpackstudy.ui.home.CustomImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:image_url="@{imageUrl}"
            app:isCircle="@{true}"
            app:radius="@{10}"/>

双向绑定

之前都是使用单向绑定来传递数据,对于一些与用户产生交互的控件,随着字段的变化能更新控件的内容,用户交互时也可以自动得到更新。这就是双向绑定。

使用

项目开发中登录页面必不可少,我们希望用户名字段内容变化时,EditText自动更新,当用户修改EditText的内容时,用户名字段同步得到更改。
首先创建一个LoginModel类,让LoginModel类的用户名字段和EditText双向绑定。

public class LoginModel extends BaseObservable {

    public String username;

    public LoginModel() {
        this.username = "yhj";
    }

    @Bindable
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
	   //判断解决循环调用的问题
        if (username != null && !username.equals(this.username)) {
            this.username = username;
            //通知观察者,数据已经更新
            notifyPropertyChanged(BR.userName);
        }
    }
}

在构造器中设置字段初始值,并写了getter()setter(),在getter()设置@Bindable注解,告诉编译器,对这个字段进行绑定,setter()在用户编辑EditText内容时自动调用。需要进行手动更新。
完成双向绑定只需要将布局表达式中的@{}变为@={}即可。username字段会随着EditText内容的变化而变化。

 <EditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@={viewModel.username}" />

优化

上面的做法有一些弊端,我们的类必须继承BaseObservablegetter()添加@Bindable注解,setter()还需要手动刷新。
DataBinding提供了ObservableField<T>,它能将普通对象包装成一个可观察对象。 ObservableField可以包装各种基本类型、集合数组类型及自定义类型数据。将代码修改可以实现同样的效果。

public class LoginModel  {

    public final ObservableField<String> username=new ObservableField<>();

    public LoginModel() {
        this.username.set("yhj");
    }
}

需要注意的是,此类的字段应声明为final,因为绑定仅检测字段值的变化,而不检测字段本身的变化。此类是可拆分和可序列化的,但是在对对象进行拆分/序列化时,将忽略回调,具体说明可参考源码。
其实,DatBinding将基本类型、集合数组、自定义类型进行了封装,提供了诸如ObservableIntObservableDoubleObservableArrayListObservableParcelable等特定的可观察类。
observable field

 public final ObservableInt age = new ObservableInt();
 public ObservableArrayMap<String, Object> user = new ObservableArrayMap<>();
 public ObservableArrayList<Object> list = new ObservableArrayList<>();

与LiveData和ViewModel

ObservableField和LiveData作用相似,两者可替换使用,区别在于LiveData与生命周期相关,通常在ViewModel中使用,需要通过observe()对变化监听。
和ViewModel使用时,可以把对控件的赋值、状态等在布局中进行处理,耦合度更低。

notificationsViewModel = new ViewModelProvider(this).get(NotificationsViewModel.class);
notificationsViewModel.getText().observe(getViewLifecycleOwner(), new Observer<String>() {
         @Override
         public void onChanged(@Nullable String s) {
            //binding.textview.setText("yanghujun");
            binding.setModel(notificationsViewModel);
         }
});
public class NotificationsViewModel extends ViewModel {

    public MutableLiveData<String> mText = new MutableLiveData<>();
    
    public NotificationsViewModel() {
        mText.setValue("yhj");
    }

    public LiveData<String> getText() {
        return mText;
    }
}

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://www.yanghujun.com/archives/databinding