Android MVVM框架 DataBinding

開篇廢話

公司走了一個人,那個人寫的程序使用到了DataBinding,既然這樣,我就必須學習DataBinding,盡快接手這個項目。
DataBinding解決了Android UI編程中的一個痛點,官方原生支持MVVM模型可以讓我們在不改變既有代碼框架的前提下,非常容易地使用這些新特性。

MVVM的介紹

MVVM是Model-View-ViewModel的簡寫,這個模式提供對View和View Model的雙向數據綁定,使得View Model的狀態改變可以自動傳遞給View。

  • Model:數據層,負責處理數據的加載或者存儲。
  • View:視圖層,負責界面數據的展示,與用戶進行交互。
  • ViewModel:負責完成View于Model間的交互,負責業務邏輯。

MVVM的模型關系圖:

MVVM的模型關系圖

準備工作

DataBinding是一個support library,所以它可以支持所有的android sdk,最低可以到android2.1(API7)。

如果是Android studio的版本在2.1以上,Android studio內置就支持了 DataBiding。如果是2.1之前的版本最好是升級一下,然后只需要在對應的Module的build.gradle中添加這么一句話即可。

dataBinding {
    enabled=true
}

dataBinding.enabled=true

如果Android studio的版本不在2.1以上,那么就需要使用以下方面了,如果高于2.1,直接跳到基礎操作看。

使用DataBinding需要Android Gradle插件的支持,版本至少在1.5以上,需要的Android studio的版本在1.3以上。用如下方法導入。

AS 2.1以下修改build.gradle

再次提示一下,如果Android studio的版本在2.1以下,那么就需要使用以下方面了,如果高于2.1,直接跳到基礎操作看。
修改 Project 的build.gradle,為 build script 添加一條依賴,Gradle 版本為 1.2.3。

classpath 'com.android.tools.build:gradle:1.2.3'
classpath 'com.android.databinding:dataBinder:1.0-rc0'

為用到 DataBinding 的模塊添加插件,修改對應的build.gradle。

apply plugin: 'com.android.databinding'
注意

如果 Module 用到的 buildToolsVersion 高于 22.0.1,比如 23 rc1,那 com.android.databinding:dataBinder 的版本要改為 1.3.0-beta1,否則會出現如下錯誤:

基礎操作

工程創建完成后,我們通過一個最簡單的例子來說明 DataBinding 的基本用法。

布局文件

使用 DataBinding 之后,xml的布局文件就不再單純地展示 UI 元素,還需要定義 UI 元素用到的變量。所以,它的根節點不再是一個ViewGroup,而是變成了layout,并且新增了一個節點data。

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
    </data>
    <!--原先的根節點(Root Element)-->
    <LinearLayout>
    ....
    </LinearLayout>
</layout>

要實現 MVVM 的ViewModel 就需要把數據與UI進行綁定,data節點就為此提供了一個橋梁,我們先在data 中聲明一個variable,這個變量會為UI 元素提供數據(例如 TextView 的 android:text),然后在Java代碼中把”后臺”數據與這個variable進行綁定。

如果要用一個表格來展示用戶的基本信息,用 DataBinding 應該怎么實現呢?

數據對象

添加一個 POJO類 - User,非常簡單,四個屬性以及他們的getter和setter。

public class User {
    private final String firstName;
    private final String lastName;
    private String displayName;
    private int age;
 
    public User(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
 
    public User(String firstName, String lastName, int age) {
        this(firstName, lastName);
        this.age = age;
    }
 
    public int getAge() {
        return age;
    }
 
    public String getFirstName() {
        return firstName;
    }
 
    public String getLastName() {
        return lastName;
    }
 
    public String getDisplayName() {
        return firstName + " " + lastName;
    }
 
    public boolean isAdult() {
        return age >= 18;
    }
}

稍后,我們會新建一個User類型的變量,然后把它跟布局文件中聲明的變量進行綁定。

定義Variable

再回到布局文件,在data節點中聲明一個變量user。

<data>
    <variable name="user" type="com.cc.databinding.User" />
</data>

其中type屬性就是我們在Java文件中定義的User類。

當然,data節點也支持import,所以上面的代碼可以換一種形式來寫。

<data>
    <import type="com.cc.databinding.User" />
    <variable name="user" type="User" />
</data>

然后我們剛才在 build.gradle 中添加的那個插件 - com.android.databinding會根據xml文件的名稱Generate一個繼承自ViewDataBinding的類。

例如,這里xml的文件名叫activity_basic.xml,那么生成的類就是ActivityBasicBinding。

注意

java.lang.*包中的類會被自動導入,可以直接使用,例如要定義一個String類型的變量:

<variable name="firstName" type="String" />

綁定Variable

Activity

修改BasicActivity的onCreate方法,用DatabindingUtil.setContentView()來替換掉setContentView(),然后創建一個user對象,通過binding.setUser(user)與variable進行綁定。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ActivityBasicBinding binding = DataBindingUtil.setContentView(
            this, R.layout.activity_basic);
    User user = new User("guo", "cc");
    binding.setUser(user);
}
Fragment

所幸DataBinding庫還提供了另外一個初始化布局的方法:DataBindingUtil.inflate()。

@Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        ViewDataBinding binding = DataBindingUtil.inflate(inflater,R.layout.fragment_blank,container,false);
        return binding.getRoot();
    }
注意

ActivityBasicBinding類是自動生成的,所有的set方法也是根據variable名稱生成的。例如,我們定義了兩個變量。

<data>
    <variable name="firstName" type="String" />
    <variable name="lastName" type="String" />
</data>

那么就會生成對應的兩個 set 方法。

setFirstName(String firstName);
setLastName(String lastName);

使用 Variable

數據與 Variable 綁定之后,xml 的 UI 元素就可以直接使用了。

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

至此,一個簡單的數據綁定就完成了。

高級用法

綁定非Activity的onClick寫法

android:onClick="@{(view)->user.show(view)}"

使用類方法

首先為類添加一個靜態方法。

public class MyStringUtils {
    public static String capitalize(final String word) {
        if (word.length() > 1) {
            return String.valueOf(word.charAt(0)).toUpperCase() + word.substring(1);
        }
        return word;
    }
}

然后在xml的data節點中導入:

<import type="com.cc.databinding.MyStringUtils" />

使用方法與 Java 語法一樣:

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@{MyStringUtils.capitalize(user.firstName)}" />

類型別名

如果我們在data節點了導入了兩個同名的類怎么辦?

<import type="com.example.home.data.User" />
<import type="com.examle.detail.data.User" />
<variable name="user" type="User" />

這樣一來出現了兩個User類,那user變量要用哪一個呢?不用擔心,import還有一個alias屬性。

<import type="com.example.home.data.User" />
<import type="com.examle.detail.data.User" alias="DetailUser" />
<variable name="user" type="DetailUser" />

Null Coalescing 運算符

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

就等價于

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

屬性值

通過@{}可以直接把Java中定義的屬性值賦值給xml屬性。

<TextView
   android:text="@{user.lastName}"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:visibility="@{user.isAdult ? View.VISIBLE : View.GONE}"/>

別忘了導包,否則View.VISIBLE和View.GONE不可以使用。

<import type="android.view.View"/>

使用資源數據

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

完整版的布局文件如下:

<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- 記得在前面加個“.” -->
    <data class=".ResourceBinding">
        <variable name="large" type="boolean" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:padding="@{large? @dimen/largePadding : @dimen/smallPadding}"
            android:background="@android:color/black"
            android:textColor="@android:color/white"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/hello_world" />
    </LinearLayout>
</layout>

largePadding和smallPadding都是定義在dimens.xml文件中的資源數據。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="largePadding">20dp</dimen>
    <dimen name="smallPadding">5dp</dimen>
</resources>

在Java代碼中與綁定large變量,并賦值為ture。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // ResourceBinding不需要導包,導包就錯了
    ResourceBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_resource);
    binding.setLarge(true);
} 

這樣做是沒有什么問題的,但是有老版本的可能會出問題。
如果在Run工程的時候,出現錯誤,報錯信息如下:

cannot find the setter for attribute 'android:padding' on android.widget.TextView with parameter type float.

看來像是DataBinder把@dimen/largePadding解析成了float類型,可以試一下類型轉換:

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

雙向綁定

在舊版本只支持單向綁定,建議升級為最新版。
在正向綁定中,我們在Layout里面的綁定表達式是這樣的:

<layout ...>
  <data>
    <variable type="com.example.myapp.User" name="user"/>
  </data>
  <RelativeLayout ...>
    <TextView android:text="@{user.name}" .../>
  </RelativeLayout>
</layout>

當user.name的數據改動時,我們的TextView都會同步改變文字。

現在假設一種情況,當你更換成EditText時,如果你的用戶名User.name已經綁定到EditText中,當用戶輸入文字的時候,你原來的user.name數據并沒有同步改動,因此我們需要修改成:

<layout ...>
  <data>
    <variable type="com.example.myapp.User" name="user"/>
  </data>
  <RelativeLayout ...>
    <EditText android:text="@={user.name}" .../>
  </RelativeLayout>
</layout>

看出微小的差別了嗎?對,就是"@{}"改成了"@={}",是不是很簡單?

開啟雙向綁定,需要在項目的build.gradle中設置:

classpath 'com.android.tools.build:gradle:2.1.0-alpha3'

我們剛才的例子里面只顯示了系統自帶的應用,那么如果是自定義控件,或者是我們更細顆粒度的Observable呢?等下就揭曉如何自定義自己的雙向綁定,我們來看看目前Android支持的控件:

  • AbsListView android:selectedItemPosition
  • CalendarView android:date
  • CompoundButton android:checked
  • DatePicker android:year, android:month, android:day
  • NumberPicker android:value
  • RadioGroup android:checkedButton
  • RatingBar android:rating
  • SeekBar android:progress
  • TabHost android:currentTab (估計沒人用)
  • TextView android:text
  • TimePicker android:hour, android:minute

自定義雙向綁定

設想一下我們使用了下拉刷新SwipeRefreshLayout控件,這個時候我們希望在加載數據的時候能控制refreshing的狀態,所以我們加入了ObservableBoolean的變量swipeRefreshViewRefreshing來正向綁定數據,并且能夠在用戶手動下拉刷新的時候同步更新swipeRefreshViewRefreshing數據:

// SwipeRefreshLayout.java

public class SwipeRefreshLayout extends View {
    private boolean isRefreshing;
    public void setRefreshing() {/* ... */}
    public boolean isRefreshing() {/* ... */}
    public void setOnRefreshListener(OnRefreshListener listener) {
        /* ... */
    }
    public interface OnRefreshListener {
        void onRefresh();
    }
}

接下來我們需要告訴框架,我們需要將SwipeRefreshLayout的isRefreshing的值反向綁定到swipeRefreshViewRefreshing:

@InverseBindingMethods({
        @InverseBindingMethod(
                type = android.support.v4.widget.SwipeRefreshLayout.class,
                attribute = "refreshing",
                event = "refreshingAttrChanged",
                method = "isRefreshing")})

這是一種簡單的定義,其中event和method都不是必須的,因為系統會自動生成,寫出來是為了更好地了解如何綁定的,可以參考官方文檔InverseBindingMethod

當然你也可以使用另外一種寫法,并且如果你的值并不是直接對應Observable
的值的時候,就可以在這里進行轉換:

@InverseBindingAdapter(attribute = "refreshing", event = "refreshingAttrChanged")
public static boolean isRefreshing(SwipeRefreshLayout view) {
    return view.isRefreshing();
}

上面的event同樣也不是必須的。以上的定義都是為了讓我們能夠在布局文件中使用"@={}"這個雙向綁定的特性。接下來你需要告訴框架如何處理refreshingAttrChanged事件,就像處理一般的監聽事件一樣:

@BindingAdapter("refreshingAttrChanged")
public static void setOnRefreshListener(final SwipeRefreshLayout view,
    final InverseBindingListener refreshingAttrChanged) {

    if (refreshingAttrChanged == null) {
        view.setOnRefreshListener(null);
    } else {
        view.setOnRefreshListener(new OnRefreshListener() {
            @Override
            public void onRefresh() {
                colorChange.onChange();
            }
        });
    }
}

一般情況下,我們都需要設置正常的OnRefreshListener,所以我們可以合并寫成:

@BindingAdapter(value = {"onRefreshListener", "refreshingAttrChanged"}, requireAll = false)
public static void setOnRefreshListener(final SwipeRefreshLayout view,
    final OnRefreshListener listener,
    final InverseBindingListener refreshingAttrChanged) {

    OnRefreshListener newValue = new OnRefreshListener() {
        @Override
        public void onRefresh() {
            if (listener != null) {
                listener.onRefresh();
            }
            if (refreshingAttrChanged != null) {
                refreshingAttrChanged.onChange();
            }
        }
    };

    OnRefreshListener oldValue = ListenerUtil.trackListener(view, newValue, R.id.onRefreshListener);
    if (oldValue != null) {
        view.setOnRefreshListener(null);
    }
    view.setOnRefreshListener(newValue);
}

現在我們終于可以使用雙向綁定的技術啦。但是要注意,需要設置requireAll = false,否則系統將識別不了refreshingAttrChanged屬性,前文提到的文章例子里并沒有設置這個。

在ViewModel中,我們的數據是這樣的:

// MyViewModel.java

public final ObservableBoolean swipeRefreshViewRefreshing = new ObservableBoolean(false);

public void load() {
    swipeRefreshViewRefreshing.set(true);

    // 網絡請求
    ....

    swipeRefreshViewRefreshing.set(false);
}

public SwipeRefreshLayout.OnRefreshListener onRefreshListener() {
    return new SwipeRefreshLayout.OnRefreshListener() {
        @Override
        public void onRefresh() {
            // Do something you need
        }
    };
}

在布局文件中是這樣設置的:

<android.support.v4.widget.SwipeRefreshLayout
    android:id="@+id/swipe_refresh_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:onRefreshListener="@{viewModel.onRefreshListener}"
    app:refreshing="@={viewModel.swipeRefreshViewRefreshing}">

    ...
</android.support.v4.widget.SwipeRefreshLayout>

最后我們還有一個小問題,就是雙向綁定有可能會出現死循環,因為當你通過Listener反向設置數據時,數據也會再次發送事件給View。所以我們需要在設置一下避免死循環:

@BindingAdapter("refreshing")
public static void setRefreshing(SwipeRefreshLayout view, boolean refreshing) {
    if (refreshing != view.isRefreshing()) {
        view.setRefreshing(refreshing);
    }
}

這樣就沒問題啦。

帶ID的View

DataBinding有效降低了代碼的冗余性,甚至完全沒有必要再去獲取一個View實例,但是情況不是絕對的,萬一我們真的就需要了呢?不用擔心,只要給View定義一個 ID,DataBinding就會為我們生成一個對應的final變量。

<TextView
    android:id="@+id/firstName"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

上面代碼中定義了一個ID為firstName*的TextView,那么它對應的變量就是。

public final TextView firstName;

使用的時候用。

binding.firstName.setText("cc");

ViewStubs

xml中的ViewStub經過 binding 之后會轉換成 ViewStubProxy。

簡單用代碼說明一下,xml文件與之前的代碼一樣,根節點改為layout,在LinearLayout中添加一個ViewStub,添加ID。

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <LinearLayout
        ...>
        <ViewStub
            android:id="@+id/view_stub"
            android:layout="@layout/view_stub"
            ... />
    </LinearLayout>
</layout>

在Java代碼中獲取binding實例,為ViewStubProy注冊ViewStub.OnInflateListener事件,搞定!

binding = DataBindingUtil.setContentView(this, R.layout.activity_view_stub);
binding.viewStub.setOnInflateListener(new ViewStub.OnInflateListener() {
    @Override
    public void onInflate(ViewStub stub, View inflated) {
        ViewStubBinding binding = DataBindingUtil.bind(inflated);
        User user = new User("fee", "lang");
        binding.setUser(user);
    }
});

Dynamic Variables

以RecyclerView為例,Adapter的DataBinding需要動態生成,因此我們可以在onCreateViewHolder的時候創建這個DataBinding,然后在onBindViewHolder中獲取這個DataBinding。

public static class BindingHolder extends RecyclerView.ViewHolder {
    private ViewDataBinding binding;
    public BindingHolder(View itemView) {
        super(itemView);
    }
    public ViewDataBinding getBinding() {
        return binding;
    }
    public void setBinding(ViewDataBinding binding) {
        this.binding = binding;
    }
}
@Override
public BindingHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
    ViewDataBinding binding = DataBindingUtil.inflate(
            LayoutInflater.from(viewGroup.getContext()),
            R.layout.list_item,
            viewGroup,
            false);
    BindingHolder holder = new BindingHolder(binding.getRoot());
    holder.setBinding(binding);
    return holder;
}
@Override
public void onBindViewHolder(BindingHolder holder, int position) {
    User user = users.get(position);
    holder.getBinding().setVariable(BR.user, user);
    holder.getBinding().executePendingBindings();
}

注意此處DataBindingUtil的用法:

ViewDataBinding binding = DataBindingUtil.inflate(
    LayoutInflater.from(viewGroup.getContext()),
    R.layout.list_item,
    viewGroup,
    false);

Attribute setters

當一個被綁定的數據的值發生改變時,Binding類會自動尋找該view上的綁定表達式上的方法去改變view,通過google數據綁定框架我們可以去自定義這些方法。

對于一個xml的attribute,DataBinding會去尋找setAttribute方法,xml屬性的命名空間是沒有關系的。比如TextView上的一個屬性android:text,會去尋找setText(String)。如果表達式返回的是int則會去尋找setText(int),所以必須確保xml中表達式返回正確的數據類型,必要時需要數據轉換。

我們可以比較容易地為任何屬性創造出setter去使用dataBinding。比如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}"/>
自定義setters

一些xml屬性需要自己去定義并實現邏輯,比如android:paddingLeft。但是setPadding(left,top,right,bottom)是存在的,那么我們可以同BindingAdapter注解去自定義個自己的setter:

@BindingAdapter("android:paddingLeft")
public static void setPaddingLeft(View view, int padding) {
   view.setPadding(padding,
                   view.getPaddingTop(),
                   view.getPaddingRight(),
                   view.getPaddingBottom());
}

Note:開發者自定義的BindingAdapter和android自帶的發生沖突時,data bingding會優先采用開發者自定義的。

多參數的BindingAdapter

@BindingAdapter({"bind:imageUrl", "bind:error"})
public static void loadImage(ImageView view, String url, Drawable error) {
   Picasso.with(view.getContext()).load(url).error(error).into(view);
}

BindingAdpater方法可以對屬性的舊值和新值進行處理

@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());
   }
}

事件處理的列子

@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);
        }
    }
}

轉換器 (Converters)

有時候我們想這樣寫xml屬性。

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

但是xml屬性的setter是一個drawable,我們可以定義一個標記了@BindingConversion的靜態方法即可。

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

關于DataBinding的一些個人看法

DataBinding使用心得

  • 使用xml進行view布局
  • 采用符合Java Bean規范的數據原型
  • 規范的自定義View
  • 禁止在BindingAdapter中的setter方法中改變數據或者做數據處理
  • 不建議用BindingConversion處理數據轉換
  • 不建議在xml布局中處理view事件
  • 不建議在xml中使用復雜的表達式

DataBinding使用的一些思考

DataBinding的不足之處:

  • DataBinding在xml提供了豐富的操作符,但是由于Android studio天生的xml語法檢查的貧弱,xml布局中的表達式邏輯錯誤,不能準確定位,導致debug難度增加,事實上一些BindingAdapter的錯誤在build的時候也會被提示xml錯誤。
  • 對自定義view的要求比較高,需要自定義綁定方法,如BindingAdapter等。
  • 可能由于java8移除apt,采用了新的API的緣故,所以即使Android Studio2.2已經開始支持java8特性,但是需要開啟jack編譯鏈,DataBinding與之沖突,導致在代碼中不能使用lambda表達式等java8特性。值得欣慰的是,這一問題將在Android Studio2.4中得到解決。

PS:數據綁定的應用軟件開發的一種趨勢,使用DataBinding的優點顯而易見,但是使用的時候我們也需要小心。

更多內容戳這里(整理好的各種文集)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容