QQ 音乐 Android 团队分享 Android DataBinding 数据绑定

4,480 阅读17分钟
原文链接: mp.weixin.qq.com

引子

几年前,数据绑定在便已在前端界风生水起,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. 能有效提高开发效率,减少大量需要手动编写的胶水代码(如findViewByIdsetOnClickListener);

  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>getset方法调用实现。ObservableField<T>是泛型类,对于基础类型,有对应的ObservableIntObservableLongObservableShort等可供使用;另外对于容器,每次只会更新其中的一个项,而不是整个更新,因此还有对应的ObservableArrayListObservableArrayMap可供使用。

从使用上来说,第三种方式更加直观和便捷,需要人工介入的地方更少,更不容易出错,推荐使用。

关于数据绑定的使用,还有很多地方可以说,比如资源的引用、变量动态设置、Lambda表达式的支持等等,限于篇幅,这里就不再多说了,关于数据绑定的详细介绍和使用,可以查看参考资料中的Data Binding 指南进一步学习。

数据绑定的原理

数据绑定的运行机制是怎样的呢?我稍微修改了布局文件,加了几个控件,使用了表达式,最终代码在这:传送门

数据绑定相关类的初始化

首先我们需要找一个切入点,最显而易见的切入点便是ContentMainBinding.inflate,这个类是数据绑定框架生成的,生成的文件位于build/intermediates/classes/debug/<package_name>/databinding/目录下。

查看图片

方法的实现调用了另一个inflate方法,经过几次辗转,最终调用到了ContentMainBinding.bind方法。

查看图片

这个方法首先检查这个view是否是数据绑定相关的布局,不是则会抛出异常,是的话则实例化ContentMainBinding

ContentMainBinding是怎么实例化的呢?看下生成的代码。

查看图片

构造函数内首先调用mapBindingsroot中所有的view找出来,数字8指的是布局中总共有8个view,然后还传入sIncludessViewsWithIds,前者是布局中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.ListenerContentMainBinding实现了这个接口,所以第一个参数传的值是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的内部实际上调用的是mRebindRunnablerun方法,因此这两个任务除了调用时机,干的事情其实没什么不同。

而如果此前请求过执行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. 使用数据绑定,实现了数据和表现的分离,结合响应式编程框架RxJavaRxAndroid,编码体验和效率能还能进一步提高。

  2. 由于数据绑定实现了数据和表现的分离,由Data Binding框架对接UI,可以通过自定义Adapter,干预某些属性的属性读取和设置,比如拦截图片资源的加载(换肤)、动态替换字符(翻译)等功能。

  3. 方便UI复用,Android上进行UI组件化的时候,可以在布局的层次上进行复用,业务无关的UI逻辑也能一起打包,同时保持对外接口(数据模型)简单,学习接入成本更小。


参考资料

  1. Data Binding -Write App Faster(Google I/O 2015) www.youtube.com/watch?v=NBb…

  2. Advanced Data Binding(Google I/O 2016)  v.youku.com/v_show/id_X…

  3. Android Data Binding Library 官方介绍 developer.android.com/topic/libra…

  4. Data Binding 源码 android.googlesource.com/platform/fr…

  5. Data Binding(Google I/O 2015)的讲稿) realm.io/news/data-b…

  6. (译)Data Binding 指南 yanghui.name/blog/2016/0…

  7. MasteringAndroidDataBinding github.com/LyndonChin/…

  8. Data Binding 高级篇 blog.zhaiyifan.cn/2016/07/06/…