引子
几年前,数据绑定在便已在前端界风生水起,Angular.js、React.js、vue.js等热门前端框架都具备这种能力。
数据绑定简单来说,就是通过某种机制,把代码中的数据和xml(UI)绑定起来,双方都能对数据进行操作,并且在数据发生变化的时候,自动刷新数据。
数据绑定分单向绑定和双向绑定两种。
单向绑定上,数据的流向是单方面的,只能从代码流向UI;双向绑定的数据流向是双向的,当业务代码中的数据改变时,UI上的数据能够得到刷新;当用户通过UI交互编辑了数据时,数据的变化也能自动的更新到业务代码中的数据上。
Android DataBinding Framework
在2015年的谷歌IO大会上,Android UI Toolkit团队发布了DataBinding 框架,将数据绑定引入了Android开发,当时还只支持单向绑定,而且需要作为第三方依赖引入,时隔一年,双向绑定这个特性也得到了支持,同时纳入了Android Gradle Plugin(1.5.0+)中,只需要在gradle配置文件里添加短短的三行,就能用上数据绑定。
数据绑定框架
使用数据绑定的优点
1. 能有效提高开发效率,减少大量需要手动编写的胶水代码(如findViewById
,setOnClickListener
);
2. 高性能(绝大部分的工作在编译期完成,避免运行时使用反射);
3. 使用灵活(可以使用表达式在布局里进行一定的逻辑运算);
4. 具有IDE支持(语法高亮、自动补全,语法错误标记)。
举个简单的例子
需求:界面上有两个控件,EditText用于获取用户输入,TextView用于把用户输入展示出来。
传统实现:用传统的方式来实现,我们需要定义一个布局,设置好这两个控件,然后在代码中引用这个布局,把这两个控件找出来,然后添加监听器到EditText上,在输入发生改变的时候,获取输入,然后更新到TextView上。
而使用数据绑定,我们的代码会是这样:
可以看到,使用了数据绑定,我们的代码逻辑结构变得清晰,手动编写的胶水代码得到了简化(由数据绑定框架替我们生成),数据绑定框架帮我们做了控件的数据变化监听,并将数据同步更新到控件上。
数据绑定的使用
布局文件的改造
使用数据绑定的布局文件以<layout>
标签作为根节点,表明这是个数据绑定的布局,修改后数据绑定框架会生成对应的*Binding类,如content_main.xml
会生成ContentMainBinding
类,即默认规则是:单词首字母大写,移除下划线,并在最后添加上Binding。
数据的声明和辅助类导入
在<layout>
标签内部添加<data>
标签,即可声明数据。给<data>
标签添加class
属性可以改变生成的*Binding类的名字,如使用<data class="ContentMain">
将其改为ContentMain
。
数据标签内部通过<variable>
标签声明变量,通过<import>
标签导入辅助类,为了避免同名冲突,可以使用alias
属性指定一个别名。
数据绑定的使用
变量声明之后,就可以在布局中使用了,使用的方式和使用Java类似,当表达式使用一个对象内的属性时,会分别尝试直接调用、getter、ObservableField.get(),具体的使用这里就不赘述了。
值得一提的是,数据绑定内支持表达式,可以使用表达式来进行一些基本的逻辑运算。
常用的操作有:
1. 数学计算符:+、-、*、/、%
2. 字符串拼接:+
3. 逻辑运算符:&&、||
4. 比较运算符:==、>、<、>=、<=
5. 函数调用
6. 类型转换
7. 数据存取[]
,对容器类的操作支持使用这种方式来存取
8. Null合并运算符:??
,合并运算符会在变量非空的时候使用左边的操作,反之使用右边的,如data ?? data.defaultVal
事件绑定
严格意义上来说,事件绑定也属于数据绑定的一种。之前我们常在布局内进行的android:onClick="onBtnClick"
就可以视作是一种数据绑定。但通过使用数据绑定框架,允许我们做更多事情。
可以通过数据绑定,传入一个变量,调用该变量上的方法用于事件的处理,跟原有的方式比,数据绑定允许我们将处理事件的逻辑和布局所关联的类解耦,可以方便的替换不同的处理逻辑。
也可以通过表达式,在布局内直接执行一些代码,不需要我们切换回Java代码中去实现,对于一些不需要外部处理,仅仅是布局内相关的逻辑来说,这种特性允许我们把UI相关的逻辑进行内聚。
数据绑定框架的另一个特性,在进行数据相关的操作前,会检查变量是否为空,倘若没有传入对应的变量,或者控件为空,在布局上进行的操作并不会执行,因此,假如上述例子中,我们没有传入对应的presenter对象,点击按钮并不会引发Crash。
还有,由于编译期会进行检查,假如对应的数据类型上没有实现对应的方法,或方法签名不对(参数类型应为View),那么编译的时候就会报错,代码的稳定性也因此得到了保障。
数据模型
虽然数据绑定支持的POJO(Pure Old Java Object,普通Java类,指仅具有一部分getter/setter方法的类),但对POJO对象的数据更新并不会同步更新UI。为了实现自动更新,可以选择:
1. 继承自BaseObservable
,给getter
加上@Bindable
注解,并在setter
中实现域的变动通知。
2. 如果数据类无法继承BaseObservable
,变动通知可以用PropertyChangeRegistry
来实现。
3. 最后一种是使用Observable域
,对数据存取通过ObservableField<T>
的get
、set
方法调用实现。ObservableField<T>
是泛型类,对于基础类型,有对应的ObservableInt
、ObservableLong
、ObservableShort
等可供使用;另外对于容器,每次只会更新其中的一个项,而不是整个更新,因此还有对应的ObservableArrayList
、ObservableArrayMap
可供使用。
从使用上来说,第三种方式更加直观和便捷,需要人工介入的地方更少,更不容易出错,推荐使用。
关于数据绑定的使用,还有很多地方可以说,比如资源的引用、变量动态设置、Lambda表达式的支持等等,限于篇幅,这里就不再多说了,关于数据绑定的详细介绍和使用,可以查看参考资料中的Data Binding 指南进一步学习。
数据绑定的原理
数据绑定的运行机制是怎样的呢?我稍微修改了布局文件,加了几个控件,使用了表达式,最终代码在这:传送门
数据绑定相关类的初始化
首先我们需要找一个切入点,最显而易见的切入点便是ContentMainBinding.inflate
,这个类是数据绑定框架生成的,生成的文件位于build/intermediates/classes/debug/<package_name>/databinding/
目录下。
方法的实现调用了另一个inflate
方法,经过几次辗转,最终调用到了ContentMainBinding.bind
方法。
这个方法首先检查这个view是否是数据绑定相关的布局,不是则会抛出异常,是的话则实例化ContentMainBinding
。
ContentMainBinding
是怎么实例化的呢?看下生成的代码。
构造函数内首先调用mapBindings
把root
中所有的view找出来,数字8指的是布局中总共有8个view,然后还传入sIncludes
和sViewsWithIds
,前者是布局中include进来的布局的索引,后者是布局中包含id的索引。
这两个参数是静态变量,看下它们是怎么初始化的:
由于Demo中的布局不包含include,因此sIncludes
被值为null,而布局内有一个id为R.id.fullName
的控件,因此他被加入到sViewsWithIds
中,7表示它在bindings
中的索引。
再回到构造函数,mapBindings
查找到的View都放置在bindings
这个数组中,并通过生成代码的方式,将它们一一取出来,转化为对应的数据类型,有设置id的控件,会以id作为变量名,没有设置id的控件,则以mboundView + 数字
的方式依次赋值。然后将这个Binding和root关联起来(通过将Binding设为rootView的tag的方式)。
还实例化了一个OnClickListener
,用于绑定事件响应。
mapBindings
的方法实现在ViewDataBinding
这个类里,主要是把root内所有的view给查找出来,并放置到bindings
对应的索引内,这个索引如何确定呢?原来,数据绑定在处理布局的时候,生成了辅助信息在view的tag里,通过解析这个tag,就能知道对应的索引了。所以,为了避免自己inflate布局文件后,不小心操作了view的tag对解析产生干扰,尽量使用数据绑定来得到inflate之后的view。处理过的布局片段如下,生成位置为app/build/intermediates/data-binding-layout-out/<build-type>/layout/
目录。
mapBindings
方法比较长,里面针对不同情况进行了处理,这里就不贴出源码了,有兴趣的读者可以自行阅读。另外,虽然这个方法看似使用到了递归,但实际上是通过这种方式实现对root下所有的控件的遍历,因此整个方法的时间复杂度是O(n),通过一次遍历,找到所有的控件,整体性能比使用findViewById
还优秀。
实例化的OnClickListener
接受两个参数,一个是OnClickListener.Listener
,ContentMainBinding
实现了这个接口,所以第一个参数传的值是ContentMainBinding
,另一个是标识这个listener作用的控件的sourceId
。这个OnClickListener
干的事情很简单,就是把点击事件,附加上sourceId
,回传给了ContentMainBinding
的_internalCallbackOnClick
处理,也就是最后我们所有跟布局相关的操作逻辑最终还是内聚到了ContentMainBinding
这个类中来。
从实现可以看到,这里仅仅实现了我们在布局中写下的内部处理逻辑()-> fullName.setText(firstName +
·+ lastName)
,由于布局中这样的处理逻辑仅有一处,所以这里sourceId没有使用到。如果有多于2处的逻辑,这里会生成一个switch
块,通过sourceId执行不同的指令。从实现还可以看到,框架生成的代码使用本地变量来持有成员变量,以保证对变量的访问是线程安全的。同样的,在对访问控件之前,会进行是否为空的检查,避免空指针错误。这也是使用数据绑定的带来的好处:通过框架自动生成的代码中的为空检查,避免手工编码容易导致的空指针错误。
但是,细心的朋友肯定发现了,构造函数里仅仅是创建了监听器,但并没有将它set
到对应的控件中去,那么这一步是在哪里进行的呢?
数据绑定的Rebind机制
在构造函数的最后,调用了方法invalidateAll
。
invalidateAll
方法的实现很简单,将脏标记位mDirtyFlags
标记为0x10L
,即在二进制表示上,第5位的值为1,这个脏标记位是一个long的值,也就是最多有64个位可供使用。由于mDirtyFlags
这个变量是成员变量,且多处会对其进行写操作,所以对它的写操作都是同步进行的。更新完了这个值,紧接着就调用了requestRebind
方法,请求执行rebind操作。
这个方法的实现在ContentMainBinding
的基类ViewDataBinding
中。
如果此前没请求执行rebind操作,那么会将mPendingRebind
置为true
,API等级16及以上,会往mChoreographer
发一个mFrameCallback
,在系统刷新界面(doFrame
)的时候执行rebind操作,API等级16以下,则是往UI线程post一个mRebindRunnable
任务。mFrameCallback
的内部实际上调用的是mRebindRunnable
的run
方法,因此这两个任务除了调用时机,干的事情其实没什么不同。
而如果此前请求过执行rebind操作,即已经post了一个任务到队列去,而且这个任务还未获得执行,此时mPendingRebind
的值为true
,那么requestRebind
将直接返回,避免重复、频繁执行rebind操作带来的性能损耗。
任务执行的时候干了什么:
当任务获得执行时,立即将mPendingRebind
设为false
,以便后续其他requestRebind
能往主线程发起rebind的任务。再API 19及以上的版本,检查下UI控件是否附加到了窗口上,如果没有附到窗口上,则设置监听器,以便在UI附加到窗口上的时候立即执行rebind操作,然后返回。当符合执行条件(API 19以下或UI控件已经附加到窗口上)的时候,则调用executePendingBindings
执行binding逻辑。
然而这里实际上还没执行具体的binding操作,这里在执行前进行一些判定:
1. 如果已经开始执行绑定操作了,即这段代码正在执行,那么调用一次requestRebind
,然后返回。
2. 如果当前没有需要进行刷新UI的需要,即脏标记为0,那么直接返回。
3. 接下来在执行具体的executeBindings
操作前,调用下mRebindCallbacks.notifyCallbacks
,通知所有回调说即将开始rebind操作,回调可以在执行的过程中,将mRebindHalted
置为true
,阻止executeBindings
的运行,拦截成功同样通过回调进行通知。
4. 如果没有被拦截,executeBindings
方法便得以运行,运行结束后,同样通过回调进行通知。
executeBindings
是个抽象方法,具体的实现在子类中,这样我们又一次回到了我们的ContentMainBinding
类中来。意即跟content_main.xml
相关的逻辑依旧内聚到了ContentMainBinding
中。
executeBindings
的实现也是数据绑定框架在编译期生成的,代码如下:
实现中,首先把脏标记位存到本地变量中,然后将脏标记位置为0,开始批量处理之前的改动。如何知道需要进行哪些处理呢?根据脏标记位和相关的值进行位与运算来判断。在构造函数的最后,脏标记位被设为0x10L,即第5位为1,在这种情况下,上述代码中的每一个分支都为真,都会被执行,即进行了一次全量的绑定操作。
这里做了:
1. 创建并设置回调,如android:onClick="@{presenter::saveUserName}
这种表达式,会在presenter
不为空的情况下,创建对应的回调,并设置到mboundView4
上;
2. 将数据模型上的值更新到UI上,如将firstName
设置到mboundView1
上,lastName
设置到mboundView2
上。可以看到,每一个<variable>
标签声明的变量都有一个专属的标记位,当改变量的值被更新时,对应的脏标记位就会置为1,executeBindings
的时候变回将这些变动更新到对应的控件。
3. 在设置了双向绑定的控件上,为其添加对应的监听器,监听其变动,如:EditText
上设置TextWatcher
。具体的设置逻辑放置到了TextViewBindingAdapter.setTextWatcher
里。源码如下,也就是创建了一个新的TextWatcher
,将我们传进来的监听器包裹在其中。在这里看到了@BindingAdapter
注解,这个注解实现了控件属性和代码内的方法调用的映射,编译期,数据绑定框架通过这种方式,为对应的控件生成对应的方法调用。如果需要让自定义控件支持数据绑定,可以参考实现。
为了监听代码改动我们传入的监听器是什么呢?
是一个InverseBindingListener,对应TextViewBindingAdapter.setTextWatcher
的第四个参数,当数据发生变化的时候,TextWatch
在回调onTextChanged
的最后,会通过InverseBindingListener
发送通知,InverseBindingListener
的实现中,会去对应的View中取得控件中最新的值,并检查*Binding
类是否为空,非空的话则调用对应的方法更新数据。这样的实现方式,在保证了允许业务自定义监听器的同时,也保证了数据变动监听的功能实现。
上面是更新数据的代码,如之前所属,更新数据之后,将脏标记位对应的位设置为1,这里是0x8L,即第四位,然后发起一次rebind请求。
回看上面的executeBindings
实现,可以看到,在下面这个分支里,完成了UI的数据更新:
具体的更新UI的实现放到了TextViewBindingAdapter.setText
里:
实现中会比对新旧数据是否一致,不一致的情况下才进行更新,这样也避免了:设置数据 -> 触发数据变动回调 -> 更新数据 -> 再次触发数据变动回调 -> ...
引起的死循环问题。
方法数的问题
data binding框架的jar包有两个,一个是adapter,一个是baseLibrary,前者方法数为415,后者方法数为502,整体增加的方法数不到一千个。生成的类方法数方面demo中大约是每个布局20个方法,具体跟布局内的变量数量(每个变量对应一个get、set方法)、双向绑定的数量(每个会多一个InverseBindingListener
匿名类)有关,会根据这几个因素有所浮动。
小结
通过上面的一波源码分析,将数据绑定在应用内的运行机制大致分析了一遍,总结下:
1. 通过对root view进行一次遍历,将view中所有的控件查找出来并进行绑定,查找效率比使用findViewById
更加高效。
2. 查找过程依赖于view的tag标记,尽量避免使用tag标记,以免跟干涉到框架的正常运行
3. 对UI的操作都在主线程;对数据的操作可以在任意线程;
4. 对数据的操作并不会即时的反应到UI上,通过脏标记,往主线程发起rebind任务,在主线程下次回调的时候批量刷新,避免频繁操作UI;
5. 使用数据绑定操作UI更加安全,操作集中在主线线程,并在操作前进行为空检查,避免空指针。
6. 绝大部分的逻辑在生成的*Binding
类中,即数据绑定框架在编译期帮我们做了大量的工作,生成模板代码,实现绑定逻辑,是否为空检查,生成代理类,代码的可靠性也是由编译期的处理程序保证,有效的降低了人为出错的可能性。
一些想法
1. 使用数据绑定,实现了数据和表现的分离,结合响应式编程框架RxJava
、RxAndroid
,编码体验和效率能还能进一步提高。
2. 由于数据绑定实现了数据和表现的分离,由Data Binding框架对接UI,可以通过自定义Adapter,干预某些属性的属性读取和设置,比如拦截图片资源的加载(换肤)、动态替换字符(翻译)等功能。
3. 方便UI复用,Android上进行UI组件化的时候,可以在布局的层次上进行复用,业务无关的UI逻辑也能一起打包,同时保持对外接口(数据模型)简单,学习接入成本更小。