【Jetpack】学穿:DataBinding → 数据绑定 (使用篇)

3,549 阅读18分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

0x1、引言

放羊一个月,继续回来学穿Jetpack,带来第三个组件 DataBinding (数据绑定)。在前面的章节 《【Jetpack】学穿:ViewBinding → 视图绑定》 剥源码的时候就有看到 DataBinding 相关的代码。

ViewBinding(视图绑定) 的作用和原理一言以蔽之:

  • 作用代替findViewById 的同时,还能保证 空安全类型安全,且 支持Java
  • 原理 → AGP为模块中的每个XML生成绑定类,本质上还是findViewByid,只是自动生成控件实例,并一一对应;

可以把 ViewBinding 看做 DataBinding 功能的 子集,它有的DataBinding都有,而且还多了 数据绑定

何为数据绑定? 在维基百科中的定义如下:

是将 "提供器" 的数据源与 "消费者" 绑定并使其同步的一种通用技术。通常用两种不同语言的数据/信息源完成,如XML数据绑定。在UI数据绑定中,相同语言但不同逻辑功能的数据与信息对象被绑定在一起(例如Java UI元素到Java对象)。在数据绑定过程中,每个数据更改会由绑定到数据的元素自动反射。术语"数据绑定"也指一个外部数据表示随元素更改产生变化,并且底层数据自动更新以反映此更改。

又长又臭,举个简单例子就秒懂了:

一个存储数量的变量count,一个显示数量的TextView,两者绑定,当修改count的值时,TextView自动刷新。

数据源(Model)更新,绑定视图(View) 自动更新,不用开发仔再去手动setXxx(),道理就这么简单。

这种玩法又叫 单向绑定,还有一种 双向绑定,绑定视图发生改变时,数据源也跟着改变,比如:

点击显示数量的TextView,显示的数量自增1,存储数量的变量也自增1。

互相影响,这就是双向绑定。咳...都是些浅显的概念,具体怎么做?

观察者模式 实现,数据变量与View实例关联,数据变量有更新时,遍历回调关联View实例对应设置值的方法。

自己造轮子,可以,但Duck不必~

Jetpack库中的 DataBinding组件 已经封装好一套了,要做的就是熟读文档,然后大胆使用~

API变化日新月异,建议以官方文档为准《数据绑定库》,本文也是基于此文档展开的学习。


0x2、写个最简单的例子

通过一个超简单的例子来帮助大家了解DataBinding,先有基础认知,再往下学就容易多了。

① 启用DataBinding

DataBinding与AGP捆绑,无需声明这个库的依赖,在模块级别的 build.gradle 添加下述配置启用即可 ( 区分AS版本 )。

apply plugin: 'kotlin-kapt'

android {
    ...
    
    // AS 4.0以下
    dataBinding{
        enabled true
    }
    
    // AS 4.0及以上
    buildFeatures {
        dataBinding true
    }
    
    // 还可以这样写
    buildFeatures.dataBinding = true
}

② 用DataBinding之前

未使用DataBinding之前,先写布局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tv_test"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:gravity="center"
        android:text="计数器:0" />

    <Button
        android:id="@+id/bt_test"
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="加1" />

</LinearLayout>

再写Activity:

class TestActivity : AppCompatActivity() {
    private var mCount: Int = 0
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_test)
        findViewById<Button>(R.id.bt_test).setOnClickListener {
            findViewById<TextView>(R.id.tv_test).text = "计数器:${++mCount}"
        }
    }
}

运行效果如下(点击按钮,计数器自增1):

司空见惯的常规操作,代码中主动setText()去更新TextView的文本,接着换成DataBinding试试看。

③ 用DataBinding之后

来到布局xml文件,鼠标点到 根布局 LinearLayout,按 Alt + Enter,点击 Convert to data binding layout,自动生成一波DataBinding所需的布局。

生成后的文件内容:

多了两个标签,接着开始改造,data标签中添加属性,修改TextView的android:text指向属性:

接着到Activity:

运行后,点击加1按钮,计数+1,效果与setText()一致,修改属性值,绑定的TextView文本跟着自动刷新。

看着 灰常简单!接着系统过一波详细用法,读者按需查阅即可~


0x3、详细用法

① xml布局文件

先是必须遵守的铁律:

根结点必须为<layout>,只能存在一个<data>和一个直接子View结点。

1) variable (变量标签)

变量的 属性名name不能包含_下划线,否则再kt文件里会找不到变量,有时可能需要 指定自定义类型

<variable name="user" type="cn.coderpig.awayfornoise.entity.User"/>

<!-- 也可以先import,然后直接用简写类名 -->
<import type="cn.coderpig.awayfornoise.entity.User"/>
<variable name="user" type="User" />
    
<!-- 当需要使用两个同名但不同包名的类,可以使用alias别名属性 -->
<import type="com.example.User" />
<import type="cn.coderpig.awayfornoise.entity.User" alias="CpUser" />
<variable name="user" type="CpUser" />

2) data (数据标签)

它有个属性class,可以自定义DataBinding生成的类名及路径 (一般不需要):

<!--自定义类名-->
<data class="CustomDataBinding"></data>

<!--自定义生成路径以及类型,自动在包名下生成包以及类-->
<data class=".CustomDataBinding"></data>

<!-- 一般没必要自定义路径,生成位置直接全局搜 CustomDataBindingImpl -->

3) @{}表达式

支持下述运算符和关键字:

  • 算术运算符 + - / * %
  • 字符串连接运算符 +
  • 逻辑运算符 && ||
  • 二元运算符 & | ^
  • 一元运算符 + - ! ~
  • 移位运算符 >> >>> <<
  • 比较运算符 == > < >= <=(请注意,< 需要转义为 &lt;)
  • instanceof
  • 分组运算符 ()
  • 文字 - 字符、字符串、数字、null
  • 类型转换
  • 方法调用
  • 字段访问
  • 数组访问 []
  • 三元运算符 ?:

使用示例如下:

android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age > 13 ? View.GONE : View.VISIBLE}"
android:transitionName='@{"image_" + id}'

不支持关键字及操作:this、super、new、显式泛型调用。

null合并运算符(??):如果左边不为Null,取左边,否则取右边,示例如下:

android:text="@{user.displayName ?? user.lastName}"

<!-- 等价于 -->
android:text="@{user.displayName != null ? user.displayName : user.lastName}"

属性引用:表达式中可以引用类的属性,如:

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

空安全:DataBinding生成的代码会 自动检查null值并避免出现空指针异常

如user为null,会为user.lastName分配默认null值,如果引用user.age,age为int,分配默认值0。

View引用:可以通过ID引用布局中其他的View,会将ID转换为 驼峰式大小写,示例如下:

<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}"/>

集合:可以使用[]运算符访问集合,如Array、List、Map等,示例如下:

<data>
    <import type="android.util.SparseArray"/>
    <import type="java.util.Map"/>
    <import type="java.util.List"/>
    <variable name="list" type="List&lt;String>"/>
    <variable name="sparse" type="SparseArray&lt;String>"/>
    <variable name="map" type="Map&lt;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]}"

<!-- 也可以用object.key表示法在map中引用值 -->
android:text="@{map.key}"

:变量的元素类型type的值不能包含 '<' 字符,直接 List<String> 这样写会引起XML语法错误。需要对 '<' 做下 转义,即 &lt;

字符串

可以用 单引号('') 包裹特征值,这样就可以在表达式中使用双引号了,示例如下:

android:text='@{map["firstName"]}'

可以用双引号扩住特征值,然后用 反单引号(``) 将字符串括起来,示例如下:

android:text="@{map[`firstName`]}"

还支持用 + 号拼接字符串哦~

资源

表达式中引用应用资源,示例如下:

android:padding="@{large? @dimen/largePadding : @dimen/smallPadding}"

还支持格式化字符串及复数的参数传入,示例如下:

android:text="@{@string/nameFormat(firstName, lastName)}"
android:text="@{@plurals/banana(bananaCount)}"

还可以把属性引用和View引用作为资源参数进行传递,示例如下:

android:text="@{@string/example_resource(user.lastName, exampleText.text)}"

<!-- 当一个复数带有多个参数时,您必须传递所有参数 -->
android:text="@{@plurals/orange(orangeCount, orangeCount)}"

某些资源需要显式类型求值,如下表所示:

4) 事件处理

事件属性名一般由 监听器方法名称确定,如:View.OnClickListeneronClick()android:onClick。但存在特例,如下表:

另外,可以使用 方法引用监听器绑定 来进行事件处理,两者的代码示例如下:

// 方法引用
class MyHandlers {
    fun 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.MyHandlers"/>
       <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>

// 监听器绑定
class Presenter {
    fun onSaveClick(task: Task){}
}

<?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"
                // lambda表达式,事件分发后会对此表达式进行求值
                android:onClick="@{() -> presenter.onSaveClick(task)}" />
    </LinearLayout>
</layout>

不难看出区别,引用方法需要和监听器的参数一致,而监听器绑定更加灵活,可在运行时动态运行lambda表达式,参数无需一致。

上述忽略了onClick(View)的View参数,如果后面的lambda表达式有用到的话,可以定义 命名参数,示例如下:

class Presenter {
    fun onCompletedChanged(task: Task, completed: Boolean){}
}

<CheckBox 
    android:layout_width="wrap_content" 
    android:layout_height="wrap_content"
    android:onCheckedChanged="@{(cb, isChecked) -> presenter.completeChanged(task, isChecked)}" />

  • 如果监听事件的返回类型不为Void,lambda表达式也要返回相同类型的值
  • 监听器表达式这种写法功能强大,可以使代码更易阅读,但不建议写太复杂的表达式,本末倒置,反而使得布局难以阅读和维护。

变量

变量类型在编译时会进行检查,如果不同配置(如横向或纵向)有不同的布局文件,变量会合并到一起。所以这些布局文件的变量定义不要存在冲突!(如同样的变量类型不一致)

系统会根据需要生成名为 context 的特殊变量,用于绑定表达式,它的值是根视图的 getContext() 获取到的Context对象。如果另外定义了同名变量会覆盖。

包含

在include其他布局时,有时需要把变量值传递过去,可以通过 bind:变量名 进行传递,要求两个布局文件拥有同一个变量。示例如下:

<variable name="user" type="com.example.User"/>
...
<include layout="@layout/name"
   bind:user="@{user}"/>

<!-- include指向的布局 -->
<variable name="user" type="com.example.User"/>
...
android:text="@{user}"

注:不支持include作为merge元素的直接子元素,如这样的布局是编译不通过的:

<?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="cn.coderpig.awayfornoise.entity.User"/>
   </data>
   <merge><!-- Doesn't work -->
       <include layout="@layout/name"
           bind:user="@{user}"/>
       <include layout="@layout/contact"
           bind:user="@{user}"/>
   </merge>
</layout>

② 可观察的数据对象

DataBinding中可观察的数据对象有三种不同类型:字段集合对象,通过数据绑定,数据对象可在数据发生更改时通知其他对象,即监听器。

1) 可观察字段

示例如下:

class User {
    val firstName = ObservableField<String>()
    val lastName = ObservableField<String>()
    val age = ObservableInt()
}

// 访问字段值,使用set()、get() 访问器方法
user.firstName = "Google"
val age = user.age

除了ObservableInt类外还有这些基本类型:

ObservableBoolean、ObservableByte、ObservableChar、ObservableShort、ObservableInt、ObservableLong、ObservableFloat、ObservableDouble、ObservableParcelable

:AS 3.1及更高版本允许使用LiveData对象替换可观察字段。

2) 可观察集合

示例如下:

ObservableArrayMap<String, Any>().apply {
    put("firstName", "Google")
    put("lastName", "Inc.")
    put("age", 17)
}

// 布局中通过字符串key找到值
<data>
    <import type="android.databinding.ObservableMap"/>
    <variable name="user" type="ObservableMap<String, Object>"/>
</data>
<TextView android:text="@{user.lastName}" ... />

ObservableArrayList<Any>().apply {
    add("Google")
    add("Inc.")
    add(17)
}

// 布局中通过索引访问列表
<data>
    <import type="android.databinding.ObservableList"/>
    <variable name="user" type="ObservableList<Object>"/>
</data>
<TextView android:text="@{user[index]}" ... />

3) 可观察对象

可以自行实现 Observable 接口,但更建议使用DataBinding提供的 BaseObservable

实现Observable接口,线程安全,使用 PropertyChangeRegistry 来执行 OnPropertyChangedCallback

使用代码示例如下:

class User : BaseObservable() {

    @get:Bindable
    var firstName: String = ""
        set(value) {
            field = value
            notifyPropertyChanged(BR.firstName)
        }

    @get:Bindable
    var lastName: String = ""
        set(value) {
            field = value
            notifyPropertyChanged(BR.lastName)
        }
}

// 附:Java中的写法
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);
    }
}

流程:getter设置Bindable注解 + setter中调用notifyPropertyChanged()

问:上面的BR哪来的

DataBinding会在模块包中生成名为 BR 的类,该类包含数据绑定的资源ID。在编译期,Bindable 注释会在BR类文件中生成一个条目。如果数据类的父类没办法更改,Observable接口可以使用 PropertyChangeRegistry 对象实现。


③ 生成的绑定类

生成的绑定类都是继承的 ViewDataBinding,类名基于布局名称,采用 Pascal命名法 进行转换并添加Binding 后缀,如 activity_main.xmlActivityMainBinding

1) 创建绑定对象

直接点开 DataBindingUtil 类,可以看到里面提供的多种绑定相关的方法:

如果可以 预知绑定类型,如ActivityMainBinding,也可以直接用ActivityMainBinding.bind()来绑定~

2) 带ID的View

DataBinding会对布局中拥有ID的每个View在绑定类中创建不可变字段。

3) 变量

DataBinding会为布局中声明的每个变量生成getter、setter方法。

4) ViewStub

占位置,惰性加载,当ViewStub被inflate或setVisible可见,它会从视图层次结构消失,如果想绑定里面的View,需要在监听 OnInflateListener,在此完成绑定

5) 即时绑定

当可变或可观察对象发生更改时,绑定会按照计划在下一帧之前发生更改。如果需要立即执行绑定,强制执行,可 executePendingBindings(),但要注意,此方法必须运行在UI线程

6) 高级绑定

动态变量,有时系统并不知道特定的绑定类,但仍需指定绑定值,如RecyclerView.Adapter,示例如下:

class BindingHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
    lateinit var binding: ViewDataBinding
}

class MyAdapter(data: List<User>) : RecyclerView.Adapter<BindingHolder>() {
    private var mData = data

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder {
        // 核心代码
        val binding: ViewDataBinding = DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            R.layout.item_layout,
            parent,
            false
        )
        val holder = BindingHolder(binding.root)
        holder.binding = binding
        return holder
    }

    override fun onBindViewHolder(holder: BindingHolder, position: Int) {
        val user = mData[position]
        holder.binding.setVariable(BR.mUser, user)
    }

    override fun getItemCount() = mData.size
}

④ 绑定适配器

1) 自动选择方法

属性搜索对应方法,不会考虑命名空间,只考虑 属性名称 和 **类型**,如:

android:text="@{user.name}

如果user.getName()的返回值为String,查找接受String参数的setText()方法,所以表达式返回正确的类型很重要,必要时你还可以根据需要进行类型转换。

2) 指定自定义方法名

使用 @BindingMethods 注解一个类 (接口也可以),相当于一个容器,内部参数是一个 @BindingMethod 数组。一般用不到它,绝大部分的属性DataBinding都已经使用命名惯例实现了。用法示例如下:

@BindingMethods(value = [
    BindingMethod(type = ImageView::class, attribute = "android:tint", method = "setImageTintList"),
    BindingMethod(type = ImageView::class, attribute = "android:xxx", method = "setAaaXxx")
])
class ImageBindingAdapter

3) 提供自定义逻辑

有些属性需要自定义逻辑,可以使用 @BindingAdapter 注解来自定义setter的,如:android:paddingLeft没有关联的setter,而是提供了setPadding(left, top, right, bottom) 。示例如下:

@BindingAdapter("android:paddingLeft")
fun setPaddingLeft(view: View, padding: Int) {
    view.setPadding(padding,
            view.getPaddingTop(),
            view.getPaddingRight(),
            view.getPaddingBottom())
}

注意参数类型:与属性关联的View类型 + 与属性绑定表达式中接受的类型

还可以定义接收多个属性的适配器,示例如下:

@BindingAdapter("imageUrl", "error")
fun loadImage(view: ImageView, url: String, error: Drawable) {
    Picasso.get().load(url).error(error).into(view)
}

在布局中使用适配器,示例如下:

<ImageView app:imageUrl="@{venue.imageUrl}" app:error="@{@drawable/venueError}" />

如果ImageView同时使用了imageUrl、error,且前者是String,后者是Drawable,就会调用适配器。

如果你希望设置了任意属性就调用适配器,可以将适配器的 requireAll 设置为 false,示例如下:

@BindingAdapter(value = ["imageUrl", "placeholder"], requireAll = false)
fun setImageUrl(imageView: ImageView, url: String?, placeHolder: Drawable?) {
    if (url == null) {
        imageView.setImageDrawable(placeholder);
    } else {
        MyImageLoader.loadInto(imageView, url, placeholder);
    }
}

  • requireAll设置为false,没填写的属性将为null,需要做好非空判断!
  • 上述写法命名空间可以随意,写xxx:imageUrl也是可以的,但如果定义了如android:imageUrl就只能用这个命名空间;
  • 自定义的绑定适配器和默认数据绑定适配器冲突,会使用自定义的绑定适配器;
  • @BindingMethod属性名和@BindingAdapter定义的属性名相同会冲突报错;

官方文档还贴出了更复杂一点的示例,读者感兴趣自己看吧,懒得搬运了~

4) 对象转换

自动转换对象:绑定表达式返回Object时,会选择用于设置属性值的方法,会自动转换为所选方法的参数类型。

自定义转换:某些情况下,需要在特定类型间自定义转换,如 android:background 需要 Drawable 但指定color传入的值却是整数。

<View
   android:background="@{isError ? @color/red : @color/white}"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"/>

每当需要Drawable且返回整数时,int都应转换为ColorDrawable,可以使用 @BindingConversion 注解静态方法来完成这个转换。

@BindingConversion
fun convertColorToDrawable(color: Int) = ColorDrawable(color)

注:绑定表达式提供的值类型要保持一致,不能在同一个表达式中使用不同类型,如:

<View
   android:background="@{isError ? @drawable/error : @color/white}"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"/>

⑤ 双向数据绑定

单向数据绑定时,可为属性设置值,并在事件监听器中更新属性:

<CheckBox
    android:id="@+id/rememberMeCheckBox"
    android:checked="@{viewmodel.rememberMe}"
    android:onCheckedChanged="@{viewmodel.rememberMeChanged}"
/>

双向数据绑定为上述过程提供了一种快捷方式:

<CheckBox
    android:id="@+id/rememberMeCheckBox"
    android:checked="@={viewmodel.rememberMe}"
/>

相比起普通的@{}多了个**=**,可接收属性的数据更改并同时监听用户更新,对应属性还得做下更改:

class LoginViewModel : BaseObservable {
    // val data = ...

    @Bindable
    fun getRememberMe(): Boolean {
        return data.rememberMe
    }

    fun setRememberMe(value: Boolean) {
        // 避免死循环
        if (data.rememberMe != value) {
            data.rememberMe = value

            // 对变化做出反应
            saveData()

            // 更新观察者
            notifyPropertyChanged(BR.remember_me)
        }
    }
}

由于可绑定属性的 getter 方法称为 getRememberMe(),因此属性的相应 setter 方法会自动使用名称 setRememberMe()

双向绑定 存在一个很大的问题 死循环,数据变化触发视图变化,视图变化又会触发数据变化,一直循环,所以 需要对变化前后的数据进行判断,有变动才更新。

DataBinding中内置支持双向绑定的类如下图所示:

表中没有的属性,想用双向绑定,就得自己实现 @BindingAdapter 注解了。

1) 自定义属性的双向绑定

官方例子:对名为MyView的自定义View中,对其"time"属性启用双向绑定,流程如下:

// 1、使用@BindingAdapter修饰setter方法
@BindingAdapter("time")
@JvmStatic fun setTime(view: MyView, newValue: Time) {
    // 新旧值对比,避免死循环
    if (view.time != newValue) {
        view.time = newValue
    }
}

// 2、使用@InverseBindingAdapter修饰getter方法
@InverseBindingAdapter("time")
@JvmStatic fun getTime(view: MyView) : Time {
    return view.getTime()
}

DataBinding知道 数据更改时要执行的操作(@BindingAdapter注解修饰的方法),还知道 View属性发生改变时要调用的内容(InverseBindingListener),但不知道属性何时被修改,所以还要给View设置监听器,将@BindingAdapter注解也加到监听器方法上:

// 3、View上设置监听器,可以是自定义的,也可以是通用事件,如焦点丢失或文本修改
@BindingAdapter("app:timeAttrChanged")
@JvmStatic fun setListeners(
        view: MyView,
        attrChange: InverseBindingListener
) {
    // Set a listener for click, focus, touch, etc.
    attrChange.onChange() // 通知数据更新 
}

监听器中包含一个 InverseBindingListener 可用它告知DataBinding,属性已更改,可以开始调用 @InverseBindingAdapter 修饰的方法。

2) 转换器

绑定到View的变量需要设置格式、转换或更改后才能显示,可以定义转换器对象来设置格式。

如果使用到双向表达式,还得使用反向转换器,以告知DataBinding如何将用户提供的字符串转换回后备数据类型。示例如下:

object Converter {
    // 添加注解修饰反向转换器。
    @InverseMethod("stringToDate")
    @JvmStatic fun dateToString(
        view: EditText, oldValue: Long,
        value: Long
    ): String {
        // Converts long to String.
    }

    @JvmStatic fun stringToDate(
        view: EditText, oldValue: String,
        value: String
    ): Long {
        // Converts String to long.
    }
}

纸上得来终觉浅,绝知此事要躬行,大概的用法就过到这里,后续实践过程遇到问题再来补充 (如和其他Jetpack组件配合)。


0x4、妙用DataBinding——解决Drawable复用

Android日常开发中,有一项令我们头大的"小事" → drawable.xml文件的维护,怎么说?

  • 没有固定的设计规范,不同的设计师有不同的颜色、圆角大小倾向;
  • 祖传代码,每个接盘开发仔,都有自己一套命名规则,有些文件内容一样,就是名字不一样;

来来来,看看公司项目中drawable的这些命名:

谁看了不头皮发麻啊,还维护个XX,最好的维护就是不维护,上来就新建:

写个脚本扫描下项目中drawable.xml的文件个数 (基于Python):

import os

def search_all_drawable(path):
    global drawable_count
    os.chdir(path)
    items = os.listdir(os.curdir)
    for item in items:
        contact_path = os.path.join(path, item)
        // 判断文件夹、路径包含\drawable\的文件、不满足条件的文件
        if os.path.isdir(contact_path):
            print("[-]", contact_path)
            search_all_drawable(contact_path)
        elif contact_path.find(drawable_sep) != -1:
            if contact_path.endswith(".xml"):
                print("[+]", contact_path)
                drawable_count += 1
        else:
            print('[!]', contact_path)
            pass


if __name__ == '__main__':
    drawable_sep = os.path.sep + "drawable" + os.path.sep
    drawable_count = 0
    search_all_drawable(r"D:\Code\Android\项目路径")
    print("检索到drawable.xml文件共计:%d 个" % drawable_count)

运行结果如下:

817个,一个300多字节,算3个1KB好了,如果能全部干掉的话,能减少273KB的体积,APK瘦身新技能get√

① 笔者已知干掉drawable.xml的两种思路

  1. 自定义View

思路:将drawable.xml中的常用属性作为控件的自定义属性,在内部动态生成Drawable作为控件的背景。

实现示例Silhouette

就是对常用到drawable的控件进行自定义封装,可以,但侵入式太强了,不好应用到其他任意控件。有些第三方控件可能还得走drawable.xml的老路。

  1. 代码自动生成Drawable赋值给控件

一种实现方法:手动构建GradientDrawable,配合扩展函数、扩展属性等语法特性,动态设置。

实现示例《干掉shape,手动构建GradientDrwable》

通过下面这样的代码动态设置

mBinding.goMeetingBtn.shape = corner(17) +
    stroke(5, "#ff0000") +
    gradient(GradientDrawable.Orientation.RIGHT_LEFT, "#00ff00", "#0000ff")

还可以再折腾下,弄成更简洁的DSL形式,感兴趣可以参考 drawable.dsl 自行实现。

另一种实现方法:为LayoutInflater添加自定义LayoutInflater.Factory,解析添加的自定义属性,并生成系统提供的GradientDrawable、RippleDrawable、StateListDrawable。

实现示例BackgroundLibrary 原理解读《无需自定义View,彻底解放shape,selector吧》

第二种思路的实现相比第一种侵入性低多了,接着看看用DataBinding怎么做~

② 用DataBinding干掉drawable.xml的思路

上面说过,可以通过 @BindingAdapter 注解为属性提供自定义逻辑。

我们要做的就是抽取drawable.xml中的常用属性,定下属性命名规则,如:drawable_solidColor,然后编写Drawable创建及设置的逻辑,示例如下:

@BindingAdapter(value = {
        "drawable_solidColor",
        "drawable_radius",
}, requireAll = false)
public static void setViewBackground(View v, int color, int radius) {
    GradientDrawable drawable = new GradientDrawable();
    drawable.setColor(color);
    drawable.setCornerRadius(radius);
    view.setBackground(drawable);
}

接着就可以在符合DataBinding规则的xml中使用了:

<layout>
    <TextView
        drawable_radius="@{10}"
        drawable_solidColor="@{0xffff0000}"
        
        android:layout_width="60dp"
        android:layout_height="60dp" />
<layout/>

原理还是非常简单的,就是把常用的属性抠出来比较麻烦,有轮子直接扒:noDrawable

不想另外依赖库的话,直接Copy这两个文件就好:

还可以根据自己的需求添加属性,或进行其他扩展,这种方案也比较简单。不过也有局限性,需要对应的 页面用上DataBinding,否则不会生效。

这些方案对于新项目还好,旧项目的话,想一上来就干掉所有drawable.xml,不太现实,重复的工作量太大了。可以先保证新开发的页面不再使用drawable.xml,后续改动到的页面逐步去掉drawable.xml,当剩余drawable.xml量级比较少时再批量修改。没有最好的方案,只有最适合的方案。

2333,想批量干掉也不是不可以,写脚本就好,毕竟都是重复操作,BackgroundLibrary那个方案比较好入手,文件文本替换。大概的思路:定义一个类存每个Drawable对应属性的值,然后遍历所有drawable.xml,查找用到@drawable/xxx的xml文件,定位到对应标签,删掉原来的语句,补上BackgroundLibrary里定义的属性和值。另外,如果Java/Kotlin代码中动态用到了drawable.xml还得单独处理下。


0x5、小结

本节系统地过了一下DataBinding的用法,还送了一个DataBinding解决Drawable复用的案例,相信读者看完应该能够放心大胆地用上DataBinding了。

使用过程遇到的问题及解决方案欢迎在评论区反馈,笔者自己也会记录补充下,原理篇先欠着,后续有时间再填,就酱,谢谢~


参考文献