【翻译】 Android Data Binding系列

Table of Contents

在Medium上看到George Mount的Data Binding系列文章, 觉得很好. 故翻译之, 同时将系列文章发布于简书.

不需要findViewById了

Android应用开发有一个鲜为人知的特性:data binding. 我准备写一系列的文章来描述讲述data binding的许多令人兴奋的特性. 最基本的一个特性就是消除了"findViewById"的使用.

经常写这种代码是不是脖子会酸痛?

TextView hello = (TextView) findViewById(R.id.hello);

现在有许多工具来减少这种重复代码的编写. 从Android Studio1.5版本之后, 有了一个官方的方法.

  1. 首先, 编辑build.gradle, 加入如下代码:

    android {
        …
        dataBinding.enabled = true
    }
    
  2. 然后, 修改layout文件, 最外层的tag改为 <layout>.

    <layout xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:tools="http://schemas.android.com/tools">
        <RelativeLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:paddingLeft="@dimen/activity_horizontal_margin"
                android:paddingRight="@dimen/activity_horizontal_margin"
                android:paddingTop="@dimen/activity_vertical_margin"
                android:paddingBottom="@dimen/activity_vertical_margin"
                tools:context=".MainActivity">
    
            <TextView
                    android:id="@+id/hello"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"/>
    
        </RelativeLayout>
    </layout>
    

    layout 标签会告知Android Studio,这个layout文件在编译阶段需要额外处理, 寻找有趣的View并在下一阶段使用. 没有该标签的 layout文件则不会做额外处理. 所以你可以在你的应用程序里对这样的修改.

  3. 下一步是告知程序在运行时用另一种方法加载layout文件. 如下修改你的加载 代码. 例如, 对于Activity, 将下面的代码:

    setContentView(R.layout.hello_world);
    TextView hello = (TextView) findViewById(R.id.hello);
    hello.setText("Hello World"); // for example, but you'd use
                                  // resources, right?
    

    改为:

    HelloWorldBinding binding = 
        DataBindingUtil.setContentView(this, R.layout.hello_world);
    binding.hello.setText("Hello World"); // you should use resources!
    

    HelloWorldBinding 是在编译阶段基于hello_world.xml文件生成的, 并且 id 为"@+id/hello"的View被赋值给了一个final的成员变量hello以供使用. 没有类型转换, 没有findViewById.

使用这种机制来访问view不仅更简单, 而且更快! binding过程只遍历一次layout 文件里的所有View, 然后赋值给成员变量. 当使用findViewById时, 每次调用该函数 view树就会被遍历一次.

正如代码所示, 该过程会"驼峰化"你的变量名称(例如hello_world.xml变成了类 HelloWorldBinding), 所以, 如果你的id为"@+id/hello_text", 变量名则为 helloText.

当你为RecyclerView, ViewPager或其不设置Activity内容的组件渲染layout时, 你可能也想使用data binding生成的类. 这里有几个类似LayoutInflater的方法 供大家使用. 例如

HelloWorldBinding binding = HelloWorldBinding.inflate(
    getLayoutInflater(), container, attachToContainer);

如果你不想将渲染的view附属到包含它的ViewGroup上, 那么当你需要访问渲染后的view时, 可以使用getRoot()方法.

linearLayout.addView(binding.getRoot());

你可能会想, 如果我有一个layout文件包含不同的配置, 每个配置的view可能会不同怎么办? layout预处理和运行时渲染流程会小心处理这个过程, 把所有的带id的view添加到 生成的类中, 如果某些view并没有在被渲染时的layout里, 其在生成类中对应的变量会被置位null.

很神奇吗? 这个功能的一大好处就是没有反射或其他运行时"高成本"技术. 将它应用到你的应用上是很简单的事情, 这会让你的生活变得轻松, 而且, 你的layout会加载更快一点.

<include>标签

在上篇文章中, 讲述了在Android Studio1.5及之后的版本上, 使用data binding来替代findViewById是多么 简单的一件事. 我展示了如何使用Android Studio来基于layout文件生成一个类似ViewHolder的类. 但是如果layout中使用了"include"或"merge"标签怎么办?

Data Binding默认也支持这些标签, 但是每个layout文件都会被生成一个不同的类. 例如

hello_world.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

        <TextView
                android:id="@+id/hello"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        <include
                android:id="@+id/included"
                layout="@layout/included_layout"/>
    </LinearLayout>
</layout>
included_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/world"/>
</layout>

需要用下面的方式来访问不同的TextView:

HelloWorldBinding binding =
    HelloWorldBinding.inflate(getLayoutInflater());
binding.hello.setText(“Hello”);
binding.included.world.setText(“World”);

include标签的命名规则跟View的命名规则是一样的: "include"标签的id被 当作成员变量名. 被include的layout文件会生成一个自己的类, 里面的view 也会被赋值给成员变量. 开发者也可以很容易的分别共享的id, 例如如果你 include了一个layout两次:

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

        <TextView
                android:id="@+id/hello"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        <include
                android:id="@+id/world1"
                layout="@layout/included_layout"/>
        <include
                android:id="@+id/world2"
                layout="@layout/included_layout"/>
    </LinearLayout>
</layout>

同名的"world" TextView可以很容易被访问:

HelloWorldBinding binding =
    HelloWorldBinding.inflate(getLayoutInflater());
binding.hello.setText(“Hello”);
binding.world1.world.setText(“First World”);
binding.world2.world.setText(“Second World”);

记住要给include标签一个id, 否则不会为其生成公共成员变量. 同时,记住在外面 使用 <layout> 标签. 这回触发预处理过程, 生成类并绑定view.

如果你去查看生成的类, 会发现无论被include多少次, 他们使用的都是同一个类. 例如你有另外一个layout文件goodbye_world.xml也包含了included_layout.xml文件, 那么只会生成一个类.

让view id变得不必要

你是否看过别人的layout并想:这个值在哪里设置和获取.或者, 你觉得不需要findViewById了是很好的一个开始,但仍然存在很多样板代码. Android Data Binding让这个变得简单.

使用ViewHolder模式

假设我们需要在应用中显示用户信息. 在前面,我展示了使用Android Studio 通过如下的layout文件生成一个"View Holder"类:

user_info.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
        <ImageView
                android:id="@+id/userImage"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        <TextView
                android:id="@+id/userFirstName"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>

        <TextView
                android:id="@+id/userLastName"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
    </LinearLayout>
</layout>

然后给View设置数据:

private void setUser(User user, ViewGroup root) {
    UserInfoBinding binding =
        UserInfoBinding.inflate(getLayoutInflater(), root, true);
    binding.userFirstName.setText(user.firstName);
    binding.userLastName.setText(user.lastName);
    binding.userImage.setImageBitmap(user.image);
}

虽然这看上去要比findViewById好, 但仍然存在很多的样板代码! 可以通过在layout文件中使用data binding表达式来消除这些样板代码, 实现自动赋值.

给变量赋值

首先, 增加一个binding表达式需要的 data 标签以及一个相关变量. 然后, 对于layout中需要赋值的属性, 使用binding表达式.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="user"
            type="com.example.myapp.model.User"/>
    </data>
    <LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
        <ImageView
                android:src="@{user.image}"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        <TextView
                android:text="@{user.firstName}"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>

        <TextView
                android:text="@{user.lastName}"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
    </LinearLayout>
</layout>

标签中的binding表达式表示为 "@{…}" 格式. 上述表达式直接将用户的image,firstName,lastName赋值给view的source和text. 这样就不用再写样板代码了.但是仍然不知道需要使用那个用户所以需要做分配:

private void setUser(User user, ViewGroup root) {
    UserInfoBinding binding =
        UserInfoBinding.inflate(getLayoutInflater(), root, true);
    binding.setUser(user);
}

很简单!

从上述layout文件可以看到,View没有ID. 那我们在前面文章中试图生成的View Holder 呢? 因为数据直接被绑定到了view上, 所以这里就不需要再去访问view了! 只是简单的设置变量,所有事情就完成了.

而且犯错的几率也变小了.例如, 你在横屏模式下没有用户图片,那么就不需要检查ImageView 是否存在. 每个layout都会计算binding表达式, 如果没有ImageView,就不会执行更新代码.

这并不意味着View Holder就过时了. 还有很多时候你会需要直接访问view.只是这种情况比以前少了很多.

Include Layouts

那么, 包含的layout怎么办? 同样可以使用该功能, 就想View Holder模式一样. 例如, 假设展示用户名称的TextView在一个被包含的layout中:

user_name.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
                name="user"
                type="com.example.myapp.model.User"/>
    </data>

    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal">
        <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@{user.firstName}"/>

        <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@{user.lastName}"/>
    </LinearLayout>
</layout>

可以在外层的layout中用如下方式来赋值user变量:

<?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="user"
                type="com.example.myapp.model.User"/>
    </data>
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">
        <ImageView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:src="@{user.image}"/>
        <include
                layout="@layout/user_name"
                app:user="@{user}"/>
    </LinearLayout>
</layout>

当user被设置时(通过代码 binding.setUser(…)), 被包含的layout的user变量也会被设置, 因为设置了 *app:user="@{user}"*. 再次注意, 因为被包含的layout的view不需要被直接访问, (我甚至都没有设置id), 这里没有给include设置ID.

表达自己

在前面提到, 通过Android Studio, 你可以在layout文件里给变量赋值. 下面的例子把展示用户名的text的值设成如下表达式

android:text="@{user.firstName}"

用户类被定义成传统Java对象(POJO):

public class User {
    public String firstName;
    public String lastName;
    public Bitmap image;
}

你的大多数类都不是用public域(我认为), 而且为其提供访问函数. 但是,layout文件中的表达式应该简短而且易于阅读, 所以我们不希望 开发者不得不在表达式中添加类似getFirstName()或getLastName() 这样的函数.表达式解析器会自动尝试为属性去寻找Java Bean的访问函数 (getXxx()或isXxx()).当你的类有访问函数时,使用上面的(即变量名) 表达式也会工作.

public class User {
    private String firstName;
    private String lastName;
    private Bitmap image;

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public Bitmap getImage() { return image; }
}

如果解析器找不到类似于getXxx()的函数,那么它会寻找类似xxx()的函数. 所以你可以使用user.hasFriend来访问函数hasFriend().

Android Data Binding表达式语法同样支持类似Java的数组访问:

android:text="@{user.friends[0].firstName}"

所以可以使用中括号作为get函数的简写.

Data Binding同样支持几乎所有的java语言表达式,包括函数调用, 三元操作符及数学操作. 但不要太疯狂:

android:text='@{user.adult ? ((user.male ? "Mr. " : "Ms. ") + user.lastName) : (user.firstName instanceof String ? user.firstName : "kid") }'

上面的代码没人能读懂!而且使用硬编码字符串非常难维护. 把复杂的表达式放到模块中.

另外,Data Binding提供了"null-合并"操作符 ?? 来简化三元操作符.

android:text=”@{user.firstName ?? user.userName}”

相当于:

android:text=”@{user.firstName != null ? user.firstName : user.userName}”

binding表达式另一个更酷的事情就是可以使用资源:

android:padding=”@{@dim/textPadding + @dim/headerPadding}

这可以节省很多不必要的单独值定义.你是不是经常会只想添加或去除尺寸? 目前还不支持style.

你同样可以使用字符串,数量和精度格式, 只要符合资源方法getString, getQuantitiString和getFraction的语法即可. 直接想资源中传递参数即可.

android:text=”@{@string/nameFormat(user.firstName, user.lastName)}”

NullPointerException

Data Binding表达式一个很方便的地方就是执行时总是会检查null值. 这意思着对于如下表达式:

android:text=”@{user.firstName ?? user.userName}”

如果user是null,那么user.firstName和user.userName也会被解析成null. 不会触发NullPointerException.

但这并不意味着就不会产生NullPointerException.例如下面的表达式:

android:text=”@{com.example.StringUtils.capitalize(user.firstName)}”

public static String capitalize(String str) {
    return Character.toUpperCase(str.charAt(0)) + str.substring(1);
}

当传入的参数为null时,必然会产生NullPointerException.

导入

在上面的例子中, 表达式很长. 我们想导入类型使得表达式变短. 可以通过在data块中导入:

<data>
    <variable
        name="user"
        type="com.example.myapp.model.User"/>
    <import
        type="com.example.StringUtils"/>
</data>

表达式则简化为:

android:text=”@{StringUtils.capitalize(user.firstName)}”

事件监听

在前面的系列中,我介绍了怎么消除程序中的findViewById,甚至在某些情况下可以 消除view的ID. 但是我在这些文章中还没有明确提及怎么处理事件监听, 例如View的 OnClickListener和TextView的TextWatcher.

Android Data Binding提供了三种机制来在layout文件中设置事件监听器, 你可以选择最顺手的一种. 不像Android的标准的onClick属性, 这几种机制并没有 使用反射, 所以无论选择那种性能都很好.

监听对象

对于使用 set* 类方法(对应于 add* 类方法), 你可以直接在属性上绑定一个 监听对象. 例如:

<View android:onClickListener="@{callbacks.clickListener}" .../>

监听对象可以设置为公共域或者具有getter方法:

public class Callbacks {
    public View.OnClickListener clickListener;
}

或者也可以把"Listener"去掉, 使用简写的方法:

<View android:onClick="@{listeners.clickListener}" .../>

当程序已经在使用这些监听对象时, 可以使用绑定监听对象的方法. 但在大多数情况下,你会使用另外两种方法.

函数引用

使用函数引用, 你可以独立的将方法绑定到监听器上. 无论是静态方法或是实例方法, 只要其跟listenr方法具有相同的参数和返回值, 都可以使用. 例如:

<EditText
    android:afterTextChanged="@{callbacks::nameChanged}" .../>

public class Callbacks {
    public void nameChanged(Editable editable) {
        //...
    }
}

虽然不推荐, 但是你仍然可以在绑定中做一些逻辑判断:

<EditText android:afterTextChanged=
    "@{user.hasName?callbacks::nameChanged:callbacks::idChanged}"
    .../>

大多数情况下将逻辑放到被调函数中会更好. 尤其是当你需要向函数中传递额外信息时, (例如上面的user). 可以通过lambda表达式来这么做.

Lambda表达式

使用Lambda表达式可以传入你想传入的参数. 例如:

<EditText
 android:afterTextChanged="@{(e)->callbacks.textChanged(user, e)}"
 ... />

public class Callbacks {
    public void textChanged(User user, Editable editable) {
        if (user.hasName()) {
            //...
        } else {
            //...
        }
    }
}

如果不需要listener中参数, 也可以通过如下语法移除:

<EditText
 android:afterTextChanged="@{()->callbacks.textChanged(user)}"
 ... />

但是你不能只移除部分:要么使用所有,要么全不使用.

函数引用和Lambda表达式求值时间也不相同. 函数引用的表达式求值发生在绑定阶段. Lambda表达式求值发生在事件发生时. 如果,举例来说, callbacks变量没有被设置, 对于函数引用, 表达式求值为Null,所有不会给view设置任何listener. 对于Lambda 表达式,总是会设置一个listener, 当事件发生时, 表达式就会被求值. 一般这不会有什么影响, 但是对于有返回值的事件监听函数, 会返回一个默认值. 例如:

<View android:onLongClick=”@{()->callbacks.longClick()}” …/>

如果callbacks是null,会返回false. 这是不对的,因为longClick事件被消耗掉了. 所以你可以通过使用更长的表达式来返回正确的结果:

<View android:onLongClick=”@{()->callbacks == null ? true : callbacks.longClick()}” …/>

你通常应该通过确保不会传入null表达式求值的方法来避免上述情况. Lambda 表达式可以跟函数引用互换.

用哪一个?

最灵活的机制是lambda表达式, 与事件监听函数相比, 它允许你传递不同的参数.

大多数情况下, 你的回调会使用跟监听函数相同的参数. 这种情况下使用函数引用更简洁易读.

如果你正在现有程序中想Android Data Binding转换, 你可能已经给view设置了一些监听对象. 你可以将这些对象当作变量传给layout文件中相应的view属性.

Created At <2017-03-14 Tue> by Zhengchao Xu. Email: xuzhengchaojob@gmail.com