DataBinding 库是 Google 公司 Android Framework UI 工具团队开发出来的一款 Android 库。DataBinding 库增强了 Android 项目中的布局文件对 UI 的控制能力,以前对 UI 进行控制和响应 UI 发出的命令的代码逻辑,现在就可以直接放在布局文件中使用 DataBinding 表达式 来表达,和 Java 代码中的数据直接关联。通过这种声明式编程,可以大大减少 UI 和 逻辑代码之间的“胶水代码”,比如以前 Activity/Fragment 中的 UI 控制逻辑;从而增加了 layout xml 文件的表现力,当然也增加了 layout xml 文件的复杂程度,控制好 layout xml 文件中 bingding 表达式的复杂程度,把所有业务逻辑仍然放在 java 代码中,是使用好 DataBinding 库的关键。负责动画控制的逻辑建议仍然放在 java 代码中。
使用DataBinding库以后你至少可以得到以下好处:
- 不用写 setOnClickListener 之类的响应 UI 命令的代码(响应 view 命令)
- 不用写 setText() 之类的控制 view 属性的代码(控制 view)
- 不用写 findviewbyid 代码(2 的附加产物)
简单一句话,DataBinding 让你可以在布局文件中写 java 表达式,所以可以省略掉中间层。可以说 DataBinding 库是减少甚至完全代替 view 和 业务逻辑 之间中间层
stupid code
的利器。
目录
1. 搭建构建环境
2. Data Binding 中的布局文件入门
2.1. 编写 data binding 表达式
2.2. 数据对象
2.3. 绑定数据
2.4. 事件处理
2.4.1. 方法引用绑定
2.4.2. Lisenter 绑定
2.4.3. 避免复杂侦听器
3. Data Binding 中的布局文件详解
3.1. import
3.2. 变量
3.3. 自定义 Binding 类名
3.4. Includes
3.5. 表达式语言
4. 数据对象
4.1. Observable 对象
4.2. Observable 属性
4.3. Observable 容器类
4.3. 双向绑定
5. 生成绑定
5.1. 创建绑定
5.2. 带有 ID 的 View
5.3. 变量
5.4. ViewStub
5.5. 高级绑定
5.5.1. 动态绑定变量
5.5.2. 立即 binding
5.5.3. 后台线程问题
6. 属性 Setter
6.1. 自动 Setter
6.2. 重命名 Setter
6.3. 自定义 Setter
7. 转换器
7.1. 对象转换
7.2. 自定义转换
8. Android Studio 对 Data binding 的支持
这篇文档介绍了如何使用 Data Binding Library 来编写声明式的布局,这样可以减少应用中逻辑代码和布局之间所需要的“胶水代码”。
Data Binding Library 提供了非常好的灵活性与兼容性 - 它是一个 support library,可以在 Android 2.1(API level 7+)及其以上的平台使用。
要使用 data binding,Android 的 Gradle 插件必须是 1.5.0-alpha1 或更高版本。
1. 搭建构建环境
- 在 Android SDK Manager 中下载最新的 Support Library。
- 在 app module 的 build.gradle 文件中添加 dataBinding 元素,如下:
android {
....
dataBinding {
enabled = true
}
}
这样DataBinding插件就会在你的项目内添加编译和运行时必需的依赖配置。
如果你的 app module 依赖了一个使用 data binding 的库,那么你的 app module 的 build.gradle 也必须配置 data binding
此外,您使用的 Android Studio 还要支持 DataBinding 特性才行。在 Android Studio 1.3 以及之后的版本提供了 data binding 的支持,详见 Android Studio Support for Data Binding。
总结起来使用 DataBinding 环境要求如下:
- Android 2.1(API level 7+) 以上
- Android Gradle 插件 1.5.0-alpha1 以上
- 最新 Support Library
- Android Studio 1.3 以上
2. Data Binding 中的布局文件入门
2.1. 编写 data binding 表达式
2.1.1 DataBinding 的布局文件与以前的布局文件有一点不同。它以一个 layout 标签作为根节点,里面包含一个 data 标签与 view 标签。view 标签的内容就是不使用 data 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.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<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>
2.1.2 在 data 标签中定义的 user 变量,可以在布局中当作属性来使用,用来写一些和java代码中表达式类似的"databinding表达式"
<variable name="user" type="com.example.User"/>
2.1.3 在布局文件中属性值里使用 “@{}” 的语法,来表示"databinding表达式"。结合2.2,这里 TextView 的文本被设置为 user 中的 firstName 属性。其中,user.firstName
和 java 中的表达式含义类似。
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}"/>
2.2. 数据对象
刚刚在布局文件中我们使用com.example.User
类定义了一个 user 变量,现在我们假设 User 类是一个 plain-old Java object(POJO)。
public class User {
public final String firstName;
public final String lastName;
public User(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
因为成员变量都是final的,所以上面这个User类型的对象拥有不可改变的数据(immutable)。在应用中,这种写一次之后永不变动数据的对象很常见。
这里也可以使用 JavaBeans 类:
public class User {
private final String firstName;
private final String lastName;
public User(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getFirstName() {
return this.firstName;
}
public String getLastName() {
return this.lastName;
}
}
从 data binding 的角度看,这两个类是等价的。TextView 的android:text
属性的表达式@{user.firstName}
,对于 POJO 对象这个表达式会读取 firstName 字段的值,对于 JavaBeans 对象会调用 getFirstName() 方法。此外,如果 user 中有 firstName() 方法存在,@{user.firstName}
表达式也可以表示对firstName() 方法的调用。
2.3. 数据绑定
上面工作完成后,数据绑定工具在编译时会基于布局文件生成一个 Binding 类。默认情况下,这个类的名字是基于布局文件的名字产生的,先把布局文件的名字转换成帕斯卡命名形式,然后在名字后面接上”Binding”。例如,上面的那个布局文件叫 main_activity.xml,所以会生成一个 MainActivityBinding 类。这个类中包含了布局文件中所有的绑定关系,并且会根据绑定表达式给布局文件中的 View 属性赋值(user变量和user表达式,view绑定,view数据绑定,view命令绑定)。编译时产生Binding类主要完成了2个事情,1.解析layout文件,根据data
标签定义成员变量;2.解析layout文件,根据"databinding表达式"产生绑定代码。Binding 创建好之后还需要,创建 Binding 类的对象,并和view绑定。
2.3.1 在 Activity inflate 一个布局的时候创建 binding,例子如下:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MainActivityBinding binding = DataBindingUtil.setContentView(this, R.layout.main_activity);
User user = new User("Test", "User");
binding.setUser(user);
}
就这么简单!运行应用,你会发现测试信息已经显示在界面中了。
DataBindingUtil.setContentView(this, R.layout.main_activity);
这句代码主要做了3件事情,1.把布局设置给Activity,填充为view树;2.创建Binding类对象;3.把view保存在Binding类的成员中,绑定view。这种方式只适合用于Activity中。
2.3.2 也可以通过下面这种方式绑定view:
MainActivityBinding binding = MainActivityBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
MainActivityBinding.inflate() 会填充 MainActivityBinding 对应的布局,并创建 MainActivityBinding 对象,把布局和MainActivityBinding对象绑定起来。
2.3.3 如果在 ListView 或者 RecyclerView 的 adapter 中使用 data binding,可以这样写:
ListItemBinding binding = ListItemBinding.inflate(layoutInflater, viewGroup, false);
//or
ListItemBinding binding = DataBindingUtil.inflate(layoutInflater, R.layout.list_item, viewGroup, false);
2.4. 命令绑定
上面演示了数据绑定,下面演示命令绑定,databinding 还允许你编写表达式来处理view分发的事件(比如 onClick)。事件属性名字取决于监听器方法名字,例如View.OnLongClickListener有onLongClick()的方法,因此这个事件的属性是android:onLongClick。
处理事件有两种方法:
- 方法绑定:在您的表达式中,您可以引用符合监听器方法签名的方法。当表达式的值为方法引用时,Data Binding会创建一个监听器,并封装方法引用和方法所有者对象,然后在目标视图上设置该监听器。如果表达式的值为null,DataBinding 则不会创建侦听器,而是设置一个空侦听器。
- Lisenter 绑定:如果事件处理表达式中包含lambda表达式。DataBinding 会创建一个监听器,设置给视图。当事件分发时,侦听器才会计算lambda表达式的值。
2.4.1. 方法绑定
事件可以直接绑定到事件处理器的方法上,类似于android:onClick
可以分配一个 Activity 中的方法。与View#onClick
属性相比,方法绑定的主要优点是 DataBinding 表达式在编译时就执行过了,因此如果该方法不存在或其签名不正确,您会收到一个编译时错误。
方法绑定和监听器绑定之间的主要区别是,包裹方法引用的监听器实现是在数据绑定时创建的,监听器绑定是在触发事件时创建的。如果您喜欢在事件发生时执行表达式,则应使用监听器绑定。
如果想要将事件处理直接分配给处理程序,那就使用方法绑定表达式,该表达式值是要调用的方法名称。例如,数据对象有如下方法:
public class MyHandlers {
public void onClickFriend(View view) { ... }
}
绑定表达式就可以像下面这样为视图分配一个点击监听器:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="handlers" type="com.example.Handlers"/>
<variable name="user" type="com.example.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}"
android:onClick="@{handlers::onClickFriend}"/>
</LinearLayout>
</layout>
注意,
@{handlers::onClickFriend}
表达式中onClickFriend
的方法签名必须与android:onClick
监听器对象中的方法签名完全匹配。
2.4.2. Lisenter 绑定
Lisenter 绑定是在程序运行中事件发生时才绑定表达式。它和方法绑定类似,但 Listener 绑定允许运行时绑定的任意的数据表达式。此功能适用于版本2.0及更高版本的Android Gradle插件。 在方法绑定中,方法的参数必须与事件侦听器的参数匹配。在 Listener 绑定中,只要返回值与 Lisenter 预期的返回值匹配就行(除非它期望void)。
2.4.2.1 例如,您有一个 presenter 类,它具有以下方法:
public class Presenter {
public void onSaveClick(Task task){}
}
然后,通过lambda表达式您可以将点击事件绑定到您的类中,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="task" type="com.android.example.Task" />
<variable name="presenter" type="com.android.example.Presenter" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{() -> presenter.onSaveClick(task)}" />
</LinearLayout>
</layout>
侦听器只允许 DataBinding 表达式的根元素是lambda表达式。当在表达式中使用回调时,数据绑定自动创建必要的侦听器并且为事件注册。当视图触发事件时,数据绑定才执行给定的表达式。与在正则绑定表达式中一样,在执行这些侦听器表达式时,DataBinding 已经做好了空值和线程安全性的处理。
2.4.2.2 在上面的示例中,我们没有在lambda表达式中定义传递给 onClick(android.view.View)
方法的视图参数。侦听器绑定为侦听器参数提供两个选择:1.忽略方法的所有参数;2.命名所有参数。
如果您喜欢命名参数,可以在表达式中使用它们。例如,上面的表达式可以写成:
android:onClick="@{(view) -> presenter.onSaveClick(task)}"
如果你想要使用表达式中的参数,可以在这样使用:
public class Presenter {
public void onSaveClick(View view, Task task){}
}
android:onClick="@{(theView) -> presenter.onSaveClick(theView, task)}"
您也可以使用具有多个参数的lambda表达式:
public class Presenter {
public void onCompletedChanged(Task task, boolean completed){}
}
<CheckBox
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onCheckedChanged="@{(cb, isChecked) -> presenter.completeChanged(task, isChecked)}" />
如果正在侦听的事件返回类型不是void类型的值,表达式也必须返回相同类型的值。例如,如果你想监听长点击事件,你的表达式应该返回布尔值。
public class Presenter {
public boolean onLongClick(View view, Task task){}
}
android:onLongClick="@{(theView) -> presenter.onLongClick(theView, task)}"
如果由于空对象而无法计算表达式,数据绑定将返回该类型的默认Java值。例如,引用类型为null,int为0,boolean为false等。
2.4.2.3 如果需要使用带谓词(例如三元)的表达式,则可以使用void作为符号。
android:onClick="@{(v) -> v.isVisible() ? doSomething() : void}"
方法绑定和Lisenter绑定的区别.png
总结起来方法绑定和Listener绑定的区别如下:
- 方法引用绑定不能是表达式,Lisenter 绑定可以是表达式;
- 方法引用绑定在绑定的时候会执行 DataBinding 表达式,可以自动处理空指针问题,Listener 绑定在事件触发的时候才会执行 lambda 表达式;
- 方法引用绑定会限制绑定的方法参数列表,返回值必须和监听器中的方法一致,Listener 绑定只限制 lambda 表达式中语句的返回值和监听器中的方法一致;
- 方法引用不止能在
android:onClick=
这种命令绑定属性中使用,在其他数据绑定属性中也可以使用,而且可以使用表达式作为参数。
<TextView
android:id="@+id/context_demo"
android:text="@{user.load(context, @id/context_demo)}" />
public String load(Context context, int field) {
return context.getResources().getString(R.string.app_name);
}
事件处理除了上述两种方法,还可以直接以数据绑定的形式绑定一个监听器对象(属性setter小节讲解)。
2.4.3. 避免复杂监听器
Listener表达式非常强大,可以使代码非常容易阅读。另一方面,包含复杂表达式的 Listener 又会使布局难以阅读和难以维护。这些表达式应该保持简单,比如只用来从UI传递可用数据到回调方法一样简单,任何业务逻辑还是应该在从侦听器表达式调用的回调方法中实现。
为了避免冲突,有些点击事件处理程序他们需要一个专门的属性,它们不是android:onClick
。databinding已通过@BindingMethods
注解(属性setter小节讲解)创建以下属性来避免此类冲突。
Class | Listener Setter | Attribute |
---|---|---|
SearchView | setOnSearchClickListener(View.OnClickListener) | android:onSearchClick |
ZoomControls | setOnZoomInClickListener(View.OnClickListener) | android:onZoomIn |
ZoomControls | setOnZoomOutClickListener(View.OnClickListener) | android:onZoomOut |
3. Data Binding 中的布局文件详解
3.1. import
3.1.1 data标签内可以有0个或者多个 import 标签。这样你可以在布局文件中像在 Java 代码中一样引用这些类。
<data>
<import type="android.view.View"/>
</data>
导入类以后,在databinding表达式中这个类就可以像在 java 代码中一样使用,比如用来定义变量,比如访问类中的静态方法和属性。例如,现在 View 可以在表达式中如下面这样引用:
<TextView
android:text="@{user.lastName}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{user.isAdult ? View.VISIBLE : View.GONE}"/>
3.1.2 当类名发生冲突时,还可以使用 alias 重命名:
<import type="android.view.View"/>
<import type="com.example.real.estate.View"
alias="Vista"/>
现在,Vista 可以用来在布局文件中引用 com.example.real.estate.View
,同时 View 也能被使用,用来引用android.view.View
。
3.1.3 导入的类型可以用于变量的类型引用和表达式中:
<data>
<import type="com.example.User"/>
<import type="java.util.List"/>
<variable name="user" type="User"/>
<variable name="userList" type="List<User>"/>
</data>
注意:目前 Android Studio 还没有对导入提供自动补全的支持,但你的应用仍然可以正常被编译。你也可以在变量定义中使用完整的包名来解决这个问题。
<TextView
android:text="@{((User)(user.connection)).lastName}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
3.1.4 导入的类后,就可以在表达式中使用类的静态属性/方法:
<data>
<import type="com.example.MyStringUtils"/>
<variable name="user" type="com.example.User"/>
</data>
…
<TextView
android:text="@{MyStringUtils.capitalize(user.lastName)}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
3.1.5 和 Java 一样,java.lang.* 会被自动导入。
3.2. 变量
data 标签中可以有任意数量的 variable 标签。每个 variable 标签描述了会在 binding 表达式中使用的属性。
<data>
<import type="android.graphics.drawable.Drawable"/>
<variable name="user" type="com.example.User"/>
<variable name="image" type="Drawable"/>
<variable name="note" type="String"/>
</data>
在编译时会检查变量的类型,所以如果变量的类型实现了 Observable
接口或者是一个可观察容器类,这些可以被反射检查到。如果变量的类型是没有实现 Observable
接口的类或接口,变量的变动不会引起 UI 的变化!
对于不同配置有不同的布局文件时(比如横屏竖屏的布局),这些布局文件中定义的变量会被合并,所以这些不同配置的布局文件之间不能有冲突的变量定义。
自动生成的 binding 类会为每一个变量产生 getter/setter 函数。这些变量会使用 Java 的默认值,直到 setter 函数被调用。默认值有 null,0(int),false(boolean)等。
binding 类还会生一个命名为 context 的特殊变量,这个变量可以被用于 binding 表达式中。context 变量其实是 rootView 的 getContext()
的返回值。如果在布局文件中自己定义 context 变量,默认的 context 变量会被自己定义的显式 context 变量覆盖。
3.3. 自定义 Binding 类名
默认情况下,binding 类的名称取决于布局文件的命名,以大写字母开头,移除下划线,后续字母大写并追加 “Binding” 结尾。这个类会被放置在 databinding 包中。举个例子,布局文件 contact_item.xml 会生成 ContactItemBinding 类。如果 module 包名为 com.example.my.app,binding 类会被放在 com.example.my.app.databinding 中。
3.3.1 通过设置 data 标签中的 class 属性,可以修改 Binding 类的命名与位置。比如:
<data class="ContactItem">
...
</data>
会在 databinding 包中生成名为 ContactItem 的 binding 类。
3.3.2 如果需要放置在应用程序的包名下,可以在前面加 “.”,比如:
<data class=".ContactItem">
...
</data>
ContactItem 会直接生成在 module 包下。
3.3.3 如果提供完整的包名,binding 类可以放置在任何包名中,比如:
<data class="com.example.ContactItem">
...
</data>
3.4. Includes
通过使用应用程序命名空间+变量名命名的属性,布局中的变量可以从包含include
标签的布局中传递到 include 的子布局中使用。比如:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:bind="http://schemas.android.com/apk/res-auto">
<data>
<variable name="user" type="com.example.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/name"
bind:user="@{user}"/>
<include layout="@layout/contact"
bind:user="@{user}"/>
</LinearLayout>
</layout>
xmlns:bind="http://schemas.android.com/apk/res-auto"
bind 是应用程序命名空间,bind:user
user 是变量名。另外需要注意,name.xml 与 contact.xml 中也都需要声明 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.User"/>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="@android:color/holo_red_dark"
android:text="@{`include : ` + user.firstName}"/>
</LinearLayout>
</layout>
上面布局中如果 user.firstName 值为 "zhao",那么 TextView 中就会显示include:zhao
。
Data binding 不支持 merge 直接包含 include 节点。比如下面代码就不能正常运行:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:bind="http://schemas.android.com/apk/res-auto">
<data>
<variable name="user" type="com.example.User"/>
</data>
<merge>
<include layout="@layout/name"
bind:user="@{user}"/>
<include layout="@layout/contact"
bind:user="@{user}"/>
</merge>
</layout>
3.5. DataBinding表达式语言
3.5.1 通用特性
bingding 表达式语言与 Java 表达式有很多相似之处。下面是相同之处:
- 数学运算
+ - / * %
- 字符串连接
+
- 逻辑运算
&& ||
- 二进制运算
& | ^
- 一元运算符
+ - ! ~
- 位移运算
>> >>> <<
- 比较运算
== > < >= <=
instanceof
- 组
()
- 字面量 - 字符,字符串,数字,
null
- 类型转换
- 函数调用
- 字段存取
- 数组存取
[]
- 三元运算符
?:
例如:
android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age < 13 ? View.GONE : View.VISIBLE}"
android:transitionName='@{"image_" + id}'
3.5.2 缺失的操作符
一些 Java 中的操作符在表达式语法中不能使用
- this
- super
- new
- 显式泛型调用 <T>
3.5.3 Null合并运算符
Null合并运算符(??)会在非 null 的时候选择左边的操作,反之选择右边。
android:text="@{user.displayName ?? user.lastName}"
等同于
android:text="@{user.displayName != null ? user.displayName : user.lastName}"
3.5.4 属性引用
正如上面“编写 data binding 表达式”中所讨论的 JavaBean 的引用。在 DataBinding 表达式引用了一个对象的属性时,它和访问字段,getter,或者 ObservableFields使用相同的格式。
android:text="@{user.lastName}"
3.5.5 避免NullPointerException
自动生成的 data binding 代码会自动检查和避免 null pointer exceptions。举个例子,在表达式 @{user.name} 中,如果 user 是 null,user.name 会赋予默认值 null。如果你引用了 user.age,因为 age 是 int 类型,所以默认赋值为 0。
3.5.6 容器类
通用的容器类:数组,lists,sparse lists,和 map,可以用 [] 操作符来存取集合中的元素
<data>
<import type="android.util.SparseArray"/>
<import type="java.util.Map"/>
<import type="java.util.List"/>
<variable name="list" type="List<String>"/>
<variable name="sparse" type="SparseArray<String>"/>
<variable name="map" type="Map<String, String>"/>
<variable name="index" type="int"/>
<variable name="key" type="String"/>
</data>
…
android:text="@{list[index]}"
…
android:text="@{sparse[index]}"
…
android:text="@{map[key]}"
3.5.7 字符串字面量
当使用单引号包裹属性时,就可以很简单地在表达式中使用双引号:
android:text='@{map["firstName"]}'
当使用双引号包裹属性时,字符串字面量就可以用双引号转义符号"
或者单引号'
或者反引号(`) 来包裹
android:text="@{map[`firstName`}"
android:text="@{map['firstName']}"
android:text="@{map["firstName"]}"
3.5.8 资源
可以在 DataBinding 表达式中使用普通的语法来引用资源:
android:padding="@{large? @dimen/largePadding : @dimen/smallPadding}"
字符串格式化和复数形式字符串可以按下面这样使用:
string.xml
<string name="firstname">FirstName: %1$s</string>
<string name="lastname">LastName: %1$s</string>
<plurals name="banana">
<item quantity="zero">zero</item>
<item quantity="one">one</item>
<item quantity="other">other</item>
</plurals>
layout.xml
android:text="@{@string/nameFormat(firstName, lastName)}"
android:text="@{@plurals/banana(bananaCount)}"
当复数形式并且有多个参数时,这样使用:
strings.xml
<plurals name="numbers">
<item quantity="one">Have an number</item>
<item quantity="other">Have %1$d numbers</item>
</plurals>
layout.xml
android:text="@{@plurals/orange(orangeCount, orangeCount)}"
一些资源需要显示类型调用
Type | Normal Reference | Expression Reference |
---|---|---|
String[] | @array | @stringArray |
int[] | @array | @intArray |
TypedArray | @array | @typedArray |
Animator | @animator | @animator |
StateListAnimator | @animator | @stateListAnimator |
color int | @color | @color |
ColorStateList | @color | @colorStateList |
4. 数据对象
任何 POJO 都能用在 data binding 中,但是更改 POJO 并不会同步更新 UI。DataBinding 的真正强大之处在于它可以让你的数据对象拥有更新通知的能力。DataBinding 提供了三种的数据改变通知机制,Observable 对象
,observable 字段
,与 observable 容器类
。
当上面的 observable 对象绑定在 UI 上,对象的属性数据发生变化时,UI 就会同步更新。
4.1. Observable 对象
当一个类实现了 Observable 接口时,data binding 会设置一个 listener 绑定到的对象上,以便监听对象字段的变动。
Observable 接口有添加/移除 listener 的机制,但发出通知还需要开发者来做。为了方便开发者,我们创建了一个基类 BaseObservable,它已经实现 listener 注册机制,开发者只需要实现字段值变化的通知就行。
开发者只需要像下面例子一样,1.在 getter 上使用 Bindable 注解,2.在 setter 中发出通知:
private static class User extends BaseObservable {
private String firstName;
private String lastName;
@Bindable
public String getFirstName() {
return this.firstName;
}
@Bindable
public String getLastName() {
return this.lastName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
notifyPropertyChanged(BR.firstName);
}
public void setLastName(String lastName) {
this.lastName = lastName;
notifyPropertyChanged(BR.lastName);
}
}
Bindable 注解在编译时会在 BR 类内生成一个元素。而 BR 类会生成在 module 的 package 下。如果数据对象的基类不可修改,那么使用 Observable 接口 + PropertyChangeRegistry 可以方便实现注册 listener 并且通知数据改变。比如 BaseObservable 的实现:
public class BaseObservable implements Observable {
private transient PropertyChangeRegistry mCallbacks;
public BaseObservable() {
}
@Override
public synchronized void addOnPropertyChangedCallback(OnPropertyChangedCallback callback) {
if (mCallbacks == null) {
mCallbacks = new PropertyChangeRegistry();
}
mCallbacks.add(callback);
}
@Override
public synchronized void removeOnPropertyChangedCallback(OnPropertyChangedCallback callback) {
if (mCallbacks != null) {
mCallbacks.remove(callback);
}
}
/**
* Notifies listeners that all properties of this instance have changed.
*/
public synchronized void notifyChange() {
if (mCallbacks != null) {
mCallbacks.notifyCallbacks(this, 0, null);
}
}
/**
* Notifies listeners that a specific property has changed. The getter for the property
* that changes should be marked with {@link Bindable} to generate a field in
* <code>BR</code> to be used as <code>fieldId</code>.
*
* @param fieldId The generated BR id for the Bindable field.
*/
public void notifyPropertyChanged(int fieldId) {
if (mCallbacks != null) {
mCallbacks.notifyCallbacks(this, fieldId, null);
}
}
}
4.2. Observable 属性
创建 Observable 类还是需要花费一点时间的,如果开发者想要省时,或者数据类的字段很少的话,可以使用
- ObservableField
- ObservableBoolean
- ObservableByte
- ObservableChar
- ObservableShort
- ObservableInt
- ObservableLong
- ObservableFloat
- ObservableDouble
- ObservableParcelable
ObservableFields 是包含一个字段的自包含 observable 对象。原始版本避免了在存取过程中做打包/解包操作。使用它的话,应该在数据类中创建一个 public final 字段:
private static class User {
public final ObservableField<String> firstName =
new ObservableField<>();
public final ObservableField<String> lastName =
new ObservableField<>();
public final ObservableInt age = new ObservableInt();
}
就这么简单!要存取数据,只需要使用 get set 方法:
user.firstName.set("Google");
int age = user.age.get();
4.3. Observable 容器类
一些应用会使用更加灵活的结构来保存数据。Observable 容器类允许使用 key 来获取这类数据。
4.3.1 当 key 是类似 String 的引用类型时,使用 ObservableArrayMap 会非常方便。
ObservableArrayMap<String, Object> user = new ObservableArrayMap<>();
user.put("firstName", "Google");
user.put("lastName", "Inc.");
user.put("age", 17);
在布局中,可以用 String key 来获取 map 中的数据:
<data>
<import type="android.databinding.ObservableMap"/>
<variable name="user" type="ObservableMap<String, Object>"/>
</data>
…
<TextView
android:text='@{user["lastName"]}'
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:text='@{String.valueOf(1 + (Integer)user["age"])}'
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
4.3.2 当 key 是整数类型时,可以使用 ObservableArrayList:
ObservableArrayList<Object> user = new ObservableArrayList<>();
user.add("Google");
user.add("Inc.");
user.add(17);
在布局文件中,使用下标获取列表数据:
<data>
<import type="android.databinding.ObservableList"/>
<import type="com.example.my.app.Fields"/>
<variable name="user" type="ObservableList<Object>"/>
</data>
…
<TextView
android:text='@{user[Fields.LAST_NAME]}'
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:text='@{String.valueOf(1 + (Integer)user[Fields.AGE])}'
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
4.3. 双向绑定
双向绑定是指当 View 属性值修改的时候,也能够修改绑定的 model 的数据。双向绑定用法很简单,在要使用双向绑定的地方,使用 “@={}” 语法即可。
<EditText android:text="@={user.firstName}" />
注意,这里的 firstName 必须是 ObservableField <T> 类型。
双向绑定只适用于那些某个属性绑定监听事件的控件,如
- TextView/EditView/Button (android:text, TextWatcher)
- CheckBox (android:checked, OnCheckedChangeListener)
- DatePicker(android:year, android:month, android:day, OnDateChangedListener)
- TimePicker(android:hour, android:minute, OnTimeChangedListener)
- RatingBar(android:rating, OnRatingBarChangeListener)
- …
大部分控件都能满足双向绑定的需求,实在不行就自定义满足该要求的控件吧。
5. 生成绑定
生成的 binding 类会将布局中的 View 与变量绑定在一起。如前所述,binding 类的类名和包名可以自定义,binding 类也会继承 ViewDataBinding。
5.1. 创建绑定
binding 类对象应该在 inflate 之后立马创建,来确保 View 的层次结构不会在绑定前被干扰。绑定布局的方式有好几种。最常见的是使用 binding 类中的静态方法。
5.1.1 Binding 类中的 inflate 函数会 inflate View 树并将 View 绑定到 binding 对象上,一气呵成。inflate 有一个非常简单的版本,只需要一个 LayoutInflater 或一个 ViewGroup:
MyLayoutBinding binding = MyLayoutBinding.inflate(layoutInflater);
MyLayoutBinding binding = MyLayoutBinding.inflate(layoutInflater, viewGroup, false);
5.1.2 如果布局使用不同的机制来 inflate,下面方法则可以独立来做绑定操作:
MyLayoutBinding binding = MyLayoutBinding.bind(viewRoot);
5.1.3 有时绑定关系是不能提前确定的,这时可以使用 DataBindingUtil :
ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater, layoutId,
parent, attachToParent);
ViewDataBinding binding = DataBindingUtil.bindTo(viewRoot, layoutId);
它只需要一个布局文件id,会根据布局文件id自动找到 Binding 类。
5.2. 带有 ID 的 View
布局中每一个带有 ID 的 View,在 Binding 类中都会生成一个 public final 字段。binding过程会做一个简单的赋值,在 binding 类中保存对应 ID 的 View。这种机制相比调用 findViewById 效率更高。例如:
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="user" type="com.example.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}"
android:id="@+id/firstName"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.lastName}"
android:id="@+id/lastName"/>
</LinearLayout>
</layout>
将会在 binding 类内生成:
public final TextView firstName;
public final TextView lastName;
ID 在 data binding 中并不是必需的,但是在某些情况下还是有必要在 java 代码中对 View 进行操作,这时就需要 id,在 java 代码中可以通过 binding 对象直接操作 view 对象。
5.3. 变量
每一个变量会有相应的存取函数:
<data>
<import type="android.graphics.drawable.Drawable"/>
<variable name="user" type="com.example.User"/>
<variable name="image" type="Drawable"/>
<variable name="note" type="String"/>
</data>
会在 binding 类中生成对应的 getter setter:
public abstract com.example.User getUser();
public abstract void setUser(com.example.User user);
public abstract Drawable getImage();
public abstract void setImage(Drawable image);
public abstract String getNote();
public abstract void setNote(String note);
5.4. ViewStub
ViewStub 相比普通 View 有一些不同。ViewStub 一开始是不可见的,当它们被设置为可见,或者调用 inflate 方法时,ViewStub 会被替换成另外一个布局。
因为 ViewStub 实际上不存在于 View 结构中,binding 类中的 View 对象也得移除掉,以便系统回收。因为 binding 类中的 View 都是 final 的,所以我们使用一个叫 ViewStubProxy 的类来代替 ViewStub。开发者可以使用它来操作 ViewStub,获取 ViewStub inflate 时得到的视图。
但 inflate 一个新的布局时,必须为新的布局创建一个 binding。因此,ViewStubProxy 必须监听 ViewStub 的 ViewStub.OnInflateListener,并及时建立 binding。由于 ViewStub 只能有一个 OnInflateListener,你可以将你自己的 listener 设置在 ViewStubProxy 上,在 binding 建立之后, listener 就会被触发。
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:orientation="vertical"
tools:context="com.connorlin.databinding.context.ViewStubActivity">
<Button
android:text="@string/inflate_viewstub"
android:onClick="inflate"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<ViewStub
android:id="@+id/view_stub"
android:layout="@layout/include"
android:layout_width="match_parent"
android:layout_weight="1"
android:layout_gravity="center"
android:layout_height="wrap_content" />
</LinearLayout>
</layout>
像下面这样在 OnInflateListener 中建立绑定:
public class ViewStubActivity extends BaseActivity {
private ActivityViewStubBinding mActivityViewStubBinding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mActivityViewStubBinding = DataBindingUtil.setContentView(this, R.layout.activity_view_stub);
mActivityViewStubBinding.viewStub.setOnInflateListener(new ViewStub.OnInflateListener() {
@Override
public void onInflate(ViewStub stub, View inflated) {
// 填充完成开始创建 binding
IncludeBinding viewStubBinding = DataBindingUtil.bind(inflated);
User user = new User("Connor", "Lin", 28);
viewStubBinding.setUser(user);
}
});
}
public void inflate(View view) {
if (!mActivityViewStubBinding.viewStub.isInflated()) {
// 触发 viewstub 填充
mActivityViewStubBinding.viewStub.getViewStub().inflate();
}
}
}
当点击 Button 的时候会上面的调用 inflate 方法,此时触发 viewstub 开始填充 view,并且给 viewstub 设置了监听器,当 view 填充完成,开始创建 binding。
5.5. 高级绑定
5.5.1. 动态绑定变量
有时候,我们不知道 binding 类要绑定哪一个变量。例如,RecyclerView.Adapter 可以用来处理不同布局的时候,它就不知道应该使用 binding 类绑定那一个变量。而是在 onBindViewHolder(VH, int)) 的方法中,binding 类重新被赋值,来更新 item view 的数据。
在下面例子中,RecyclerView 中的所有布局都内置了一个 item 变量。BindingHolder 有一个 getBinding 方法,返回一个 ViewDataBinding 基类。
@Override
public BindingHolder onCreateViewHolder(ViewGroup parent, int viewType) {
RecyclerItemBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.recycler_item, parent, false);
Presenter presenter = new Presenter();
binding.setPresenter(presenter);
BindingHolder holder = new BindingHolder(binding.getRoot());
holder.setBinding(binding);
return holder;
}
@Override
public void onBindViewHolder(BindingHolder holder, int position) {
// 动态绑定变量
holder.getBinding().setVariable(BR.item, mRecyclerItemList.get(position));
holder.getBinding().executePendingBindings();
}
@Override
public int getItemCount() {
return mRecyclerItemList.size();
}
public class BindingHolder extends RecyclerView.ViewHolder {
private RecyclerItemBinding binding;
public BindingHolder(View itemView) {
super(itemView);
}
public RecyclerItemBinding getBinding() {
return binding;
}
public void setBinding(RecyclerItemBinding binding) {
this.binding = binding;
}
}
5.5.2. 立即 binding
当变量或者 observable 发生变动时,会在下一帧触发 binding。有时候 binding 需要马上执行,这时候可以使用 executePendingBindings())。
5.5.3. 后台线程问题
只要数据不是容器类,你可以直接在后台线程做数据变动。DataBinding 会将变量/字段转为局部量,避免同步问题。
6. 属性 Setter
当绑定数据发生变动时,生成的 binding 类必须根据 binding 表达式调用 View 的 setter 函数来修改 View 的属性。Data binding 框架内置了几种自定义赋值给 view 的方法。
6.1. 自动 Setter
对一个 attribute 来说,data binding 会自动尝试寻找对应的 setAttribute 函数。属性的命名空间不会对这个过程产生影响,只有属性的命名才是决定因素。
举个例子,针对一个与 TextView 的 android:text 绑定的表达式,data binding会自动寻找 setText(String) 函数。如果表达式返回值为 int 类型, data binding则会寻找 setText(int) 函数。所以需要小心处理函数的返回值类型,必要的时候使用强制类型转换。需要注意的是,data binding 在对应名称的属性不存在的时候也能继续工作。你可以轻而易举地使用 data binding 为任何 setter “创建” 属性。举个例子,support 库中的 DrawerLayout 并没有任何属性,但是有很多 setter,所以你可以使用自动 setter 的特性来调用这些函数。
<android.support.v4.widget.DrawerLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:scrimColor="@{@color/scrim}"
app:drawerListener="@{fragment.drawerListener}"/>
6.2. 重命名 Setter
一些属性的命名与 setter 不对应。针对这些函数,可以用 BindingMethods 注解来将属性与 setter 绑定在一起。例如,android:tint 属性可以像下面这样与 setImageTintList(ColorStateList)) 绑定,而不是 setTint,这个注解标注在任何类上面都有效果:
@BindingMethods({
@BindingMethod(type = "android.widget.ImageView",
attribute = "android:tint",
method = "setImageTintList"),
})
Android 框架中的 setter 重命名已经在库中实现了,开发者只需要专注于自己的 setter。比如:
@BindingMethods({
@BindingMethod(type = ZoomControls.class, attribute = "android:onZoomIn", method = "setOnZoomInClickListener"),
@BindingMethod(type = ZoomControls.class, attribute = "android:onZoomOut", method = "setOnZoomOutClickListener"),
})
public class ZoomControlsBindingAdapter {
}
6.3. 自定义 Setter
6.3.1 一些属性需要自定义 setter 逻辑。例如,目前没有与 android:paddingLeft 对应的 setter,只有一个 setPadding(left, top, right, bottom) 函数。使用BindingAdapter注解的标记静态方法,允许开发人员自定义属性对应的 setter 方法。
Android 属性已经内置一些 BindingAdapter。例如,这是一个 paddingLeft 的自定义 setter:
@BindingAdapter("android:paddingLeft")
public static void setPaddingLeft(View view, int padding) {
view.setPadding(padding,
view.getPaddingTop(),
view.getPaddingRight(),
view.getPaddingBottom());
}
BindingAdapter 在其他自定义类型上也很好用。比如,一个 loader 可以在非主线程加载图片。
当存在冲突时,开发者创建的 binding adapter 方法会覆盖 data binding 的默认 adapter 方法。
6.3.2 你也可以创建有多个参数的 Adapter 方法:
@BindingAdapter({"app:imageUrl", "app:error"})
public static void loadImage(ImageView view, String url, Drawable error) {
Picasso.with(view.getContext()).load(url).error(error).into(view);
}
<ImageView
app:imageUrl=“@{venue.imageUrl}”
app:error=“@{@drawable/venueError}”/>
当 imageUrl 与 error 属性都存在时,并且 imageUrl 是一个 String,error 是一个 Drawable,这个 adapter 会被调用。
- 属性与自定义 setter 在匹配时,自定义命名空间会被忽略
- 也可以为 android 命名空间编写 adapter
6.3.3 BindingAdapter 注解标记的 setter 方法可以获取属性旧的赋值。要使用旧值,只需要将旧值放置在前,新值放置在后:
@BindingAdapter("android:paddingLeft")
public static void setPaddingLeft(View view, int oldPadding, int newPadding) {
if (oldPadding != newPadding) {
view.setPadding(newPadding,
view.getPaddingTop(),
view.getPaddingRight(),
view.getPaddingBottom());
}
}
6.3.4 事件处理程序只能与一个抽象方法的接口或抽象类一起使用。例如:
@BindingAdapter("android:onLayoutChange")
public static void setOnLayoutChangeListener(View view, View.OnLayoutChangeListener oldValue,
View.OnLayoutChangeListener newValue) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
if (oldValue != null) {
view.removeOnLayoutChangeListener(oldValue);
}
if (newValue != null) {
view.addOnLayoutChangeListener(newValue);
}
}
}
6.3.5 当 listener 内置多个函数时,必须分割成多个 listener 和对应的多个属性。例如,View.OnAttachStateChangeListener 内置两个函数:onViewAttachedToWindow()) 与 onViewDetachedFromWindow())。在这里必须为两个不同的属性创建不同的接口。
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
public interface OnViewDetachedFromWindow {
void onViewDetachedFromWindow(View v);
}
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
public interface OnViewAttachedToWindow {
void onViewAttachedToWindow(View v);
}
因为改变一个 listener 会影响到另外一个,我们必须编写三个不同的 adapter,包括修改一个属性的,和修改两个属性的。
@BindingAdapter("android:onViewAttachedToWindow")
public static void setListener(View view, OnViewAttachedToWindow attached) {
setListener(view, null, attached);
}
@BindingAdapter("android:onViewDetachedFromWindow")
public static void setListener(View view, OnViewDetachedFromWindow detached) {
setListener(view, detached, null);
}
@BindingAdapter({"android:onViewDetachedFromWindow", "android:onViewAttachedToWindow"})
public static void setListener(View view, final OnViewDetachedFromWindow detach,
final OnViewAttachedToWindow attach) {
if (VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB_MR1) {
final OnAttachStateChangeListener newListener;
if (detach == null && attach == null) {
newListener = null;
} else {
newListener = new OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
if (attach != null) {
attach.onViewAttachedToWindow(v);
}
}
@Override
public void onViewDetachedFromWindow(View v) {
if (detach != null) {
detach.onViewDetachedFromWindow(v);
}
}
};
}
final OnAttachStateChangeListener oldListener = ListenerUtil.trackListener(view,
newListener, R.id.onAttachStateChangeListener);
if (oldListener != null) {
view.removeOnAttachStateChangeListener(oldListener);
}
if (newListener != null) {
view.addOnAttachStateChangeListener(newListener);
}
}
}
上面的例子比普通情况下复杂,因为 View 是 add/remove View.OnAttachStateChangeListener 而不是 set。可以使用 android.databinding.adapters.ListenerUtil 来辅助跟踪旧的 listener 并移除它。
对于 addOnAttachStateChangeListener(View.OnAttachStateChangeListener)) 支持的 api 版本,通过向 OnViewDetachedFromWindow 和 OnViewAttachedToWindow 添加 @TargetApi(VERSION_CODES.HONEYCHOMB_MR1) 注解,data binding 代码生成器会知道这些 listener 只会在 Honeycomb MR1 或更新的设备上使用。
通过自定义 setter 可以通过在 xml 中的表达式唤起任意 view 中的方法,并且在调用 view 方法之前添加我们自己的 view 控制逻辑。
比如用于给 TextView 添加 xml 字体设置属性:
<TextView app:font="@{`Source-Sans-Pro-Regular.ttf`}"/>
public class AppAdapters {
@BindingAdapter({"font"})
public static void setFont(TextView textView, String fontName){
AssetManager assetManager = textView.getContext().getAssets();
String path = "fonts/" + fontName;
Typeface typeface = sCache.get(path);
if (typeface == null) {
typeface = Typeface.createFromAsset(assetManager, path);
sCache.put(path, typeface);
}
textView.setTypeface(typeface);
}
}
7. 转换器
7.1. 对象转换
当 binding 表达式返回对象时,会选择一个 setter(自动 Setter,重命名 Setter,自定义 Setter),将返回对象强制转换成 setter 需要的类型。
下面是一个使用 ObservableMap 保存数据的例子:
<TextView
android:text='@{userMap["lastName"]}'
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
在这里,userMap 会返回 Object 类型的值,而返回值会被自动转换成 setText(CharSequence) 所需要的类型。当对参数类型存在疑惑时,开发者需要手动做类型转换。
7.2. 自定义转换
有时候会自动在特定类型直接做类型转换。例如,当设置背景的时候:
<View
android:background="@{isError ? @color/red : @color/white}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
在这里,背景需要的是 Drawable,但是 color 是一个整数。当需要 Drawable 却返回了一个整数时,int 会自动转换成 ColorDrawable。这个转换是在一个 BindingConversation 注解的静态函数中实现:
@BindingConversion public static ColorDrawable convertColorToDrawable(int color) { return new ColorDrawable(color); }
需要注意的是,这个转换只能在 setter 阶段生效,所以不允许混合类型:
<View
android:background="@{isError ? @drawable/error : @color/white}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
8. Android Studio 对 Data binding 的支持
Android Studio 支持许多用于数据绑定代码的代码编辑功能。例如,它支持数据绑定表达式的以下功能:
- 语法高亮显示
- 标记表达式语言语法错误
- XML代码完成
- 引用,包括导航(如导航到声明)和快速文档
注意:数组和泛型类型(如Observable类)可能在没有错误时显示错误。
在“预览”窗格可以显示数据绑定表达式的默认值。下面是一个设置默认值的例子,TextView 的 text 默认值为 PLACEHOLDER。
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName, default=PLACEHOLDER}"/>
如果需要在项目的设计阶段显示默认值,还可以使用tools
属性代替表达式默认值,详见设计阶段布局属性
参考资料:
推荐拓展阅读:
Demo:
网友评论