Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

卻把清梅嗅發表於2019-01-15

爭取打造 Android Jetpack 講解的最好的部落格系列

Android Jetpack 實戰篇

前言

本文是 Android官方架構元件 系列的番外篇,因為目前國內關於DataBinding雙向繫結的部落格,講的實在是五花八門,很多文章看完之後仍然一頭霧水,特此專門寫一篇文章進行總結。

此外,前幾天在CSDN上看到 貌似掉線 老師釋出了一篇文章《我為什麼放棄在專案中使用Data Binding》,裡面針對性指出了目前DataBinding的使用中一些痛點,很多地方我感同身受,但鑑於 事物的存在必然存在兩面性 ,特此也在 本文的末尾 寫了一些我個人的理解, 闡述了為什麼我個人 還在堅持使用DataBinding , 希望對讀者能有所裨益。

本文預設讀者對DataBinding的使用有了初步的瞭解。

什麼是雙向繫結?

DataBinding的本身是對View層狀態的一種觀察者模式的實現,通過讓ViewViewModel層可觀察的物件(比如LiveData)進行繫結,當ViewModel層資料發生變化,View層也會自動進行UI的更新。

上述我講的是DataBinding最基礎的用法,即 單向繫結 ,其優勢在於,將View層抽象為一個純Java的可觀察者——這意味著ViewModel層相關程式碼是完全可直接用於進行 單元測試

但實際的開發中,單向繫結並非是足夠的,在一些特定的場景,我們也需要用到 雙向繫結

比如說,對於一個TextView的內容展示,一般情況下,我們只是用來通過將一個String型別的資料對其進行渲染:

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

顯而易見,資料的流向是單向的,換句話說,我們認為TextViewDataSource只進行了 操作——如果此時進行了網路請求,我們需要用到DataSource某個屬性作為引數,我們依然可以毫無顧忌從DataSource取值。

但是換一個場景,如果我們把TextView換成一個EditText,接下來我們需要面對的則截然不同,比如登入介面:

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

這似乎沒有什麼問題,我們依然通過一個LiveDataEditText進行了單向繫結:

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

問題發生了,當我們對 輸入框 進行編輯,EditText的UI發生了變更,但是LiveData內的資料卻沒有更新,當我們想要在ViewModel層請求登入的API介面時,我們就必須要去通過editText.getText()才能獲取使用者輸入的密碼。

於是我們希望,即使是EditText的內容發生了變更,但是LiveData內的資料也能和EditText保持內容的同步——這樣我們就不需要讓ViewModel層持有View層的引用,在請求介面時,直接從LiveData中取值即可:

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

這就是雙向繫結的意義。

使用場景是什麼

什麼適合使用 雙向繫結 呢,還記得上文中的一句話嗎:

對於單向繫結來說,資料的流向是單向的,換句話說,我們認為TextViewDataSource只進行了 操作。

現在我們定義,當 不確定的操作發生時 ——通常,這種操作代表著使用者對UI控制元件的互動,這時UI的變化需要影響到ViewModel層的資料狀態(除了 資料驅動檢視 之外,檢視也在驅動資料,以方便作為引數將來進行網路請求等等操作),這時 雙向繫結 就可以大展身手了。

顯然上文中的EditText的是 雙向繫結 經典的使用場景之一,此外,雙向繫結的使用場景非常常見,比如CheckBox

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

當使用者選中了CheckBox,我們當然希望ViewModel層的LiveData<Boolean>狀態進行對應的更新,以便將來我們直接從LiveData中取值作為引數進行網路請求。

而如果沒有雙向繫結,使用者操作了UI,我們就需要 手動新增程式碼保證狀態的同步——比如checkBox.setOnCheckChangedListener(),否則,就會在接下來的操作中得到與預期不同的結果。

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

聽起來好像很麻煩,那麼究竟如何使用呢?

幸運的是,Android原生控制元件中,絕大多數的雙向繫結使用場景,DataBinding都已經幫我們實現好了:

Android官方架構元件DataBinding雙向繫結篇: 觀察者模式的殊途同歸

這意味著我們並不需要去手動實現複雜的雙向繫結,以上文的EditText為例,我們只需要通過@={表示式}進行雙向的繫結:

<EditText
	android:id="@+id/etPassword"
	android:layout_width="match_parent"
	android:layout_height="wrap_content"
	android:text="@={ fragment.viewModel.password }" />
複製程式碼

相比單向繫結,只需要多一個=符號,就能保證View層和ViewModel層的 狀態同步 了。

難點在哪?

雙向繫結定義好之後,使用起來很簡單,但定義卻稍微比單向繫結麻煩一些,即使原生的控制元件DataBinding已經幫助我們實現好了,對於三方的控制元件或者自定義控制元件,還需要我們自己實現

本文以SwipeRefreshLayout為例,讓我們來看看其 雙向繫結 實現的方式:

object SwipeRefreshLayoutBinding {

    @JvmStatic
    @BindingAdapter("app:bind_swipeRefreshLayout_refreshing")
    fun setSwipeRefreshLayoutRefreshing(
            swipeRefreshLayout: SwipeRefreshLayout,
            newValue: Boolean
    ) {
        if (swipeRefreshLayout.isRefreshing != newValue)
            swipeRefreshLayout.isRefreshing = newValue
    }

    @JvmStatic
    @InverseBindingAdapter(
            attribute = "app:bind_swipeRefreshLayout_refreshing",
            event = "app:bind_swipeRefreshLayout_refreshingAttrChanged"
    )
    fun isSwipeRefreshLayoutRefreshing(swipeRefreshLayout: SwipeRefreshLayout): Boolean =
            swipeRefreshLayout.isRefreshing

    @JvmStatic
    @BindingAdapter(
            "app:bind_swipeRefreshLayout_refreshingAttrChanged",
            requireAll = false
    )
    fun setOnRefreshListener(
            swipeRefreshLayout: SwipeRefreshLayout,
            bindingListener: InverseBindingListener?
    ) {
        if (bindingListener != null)
            swipeRefreshLayout.setOnRefreshListener {
                bindingListener.onChange()
            }
    }
}
複製程式碼

有點晦澀,是不是?我們先不要糾結於細節的實現,先來看看程式碼中是如何使用的吧:

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
		android:layout_width="match_parent"
		android:layout_height="match_parent"
		app:bind_swipeRefreshLayout_refreshing="@={ fragment.viewModel.refreshing }">

            <androidx.recyclerview.widget.RecyclerView/>

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
複製程式碼

refreshing實際就只是一個LiveData

val refreshing: MutableLiveData<Boolean> = MutableLiveData()
複製程式碼

這裡的雙向繫結,意義在於,當我們為LiveData手動設定值時,SwipeRefreshLayout的UI也會發生對應的變更;同理,當使用者手動下拉執行重新整理操作時,LiveData的值也會對應的變成為true(代表重新整理中的狀態)。

相比於其它的方式,雙向繫結將SwipeRefreshLayout的重新整理狀態抽象成為了一個LiveData<Boolean> ——我們只需要在xml中定義好,之後就可以在ViewModel中圍繞這個狀態進行程式碼的編寫,不同於view.setOnRefreshListener()的方式,這種程式碼是純Java的,我們可以針對每一行程式碼進行純JVM的單元測試。

本小節的所有程式碼你都可以在 這裡 獲取。

整理思路,按部就班實現雙向繫結

說了這麼多,但是我們一行程式碼都還沒有實現,不著急,因為編碼只是其中的一個步驟,最重要的是 整理一個流暢的思路,這樣,在接下來的編碼階段,你會如有神助。

1.實現單向繫結

我們知道,雙向繫結的前提是單向繫結,因此,我們先配置好對應單向繫結的介面:

@JvmStatic
@BindingAdapter("app:bind_swipeRefreshLayout_refreshing")
fun setSwipeRefreshLayoutRefreshing(
        swipeRefreshLayout: SwipeRefreshLayout,
        newValue: Boolean
) {
        swipeRefreshLayout.isRefreshing = newValue
}
複製程式碼

我們通過將LiveData的值和DataBinding繫結在一起,每當LiveData的狀態發生了變更,SwipeRefreshLayout的重新整理狀態也會發生對應的更新。

我們實現了資料驅動檢視的效果,接下來我們需要思考的是,我們如何才能知道使用者會執行下拉操作呢?

2.觀察View層的狀態變更

只有觀察到View層的狀態變更,我們才能驅動LiveData進行對應的更新,其實很簡單,通過swipeRefreshlayout.setOnRefreshListener()即可:

@JvmStatic
@BindingAdapter(
        "app:bind_swipeRefreshLayout_refreshingAttrChanged",
        requireAll = false
)
fun setOnRefreshListener(
        swipeRefreshLayout: SwipeRefreshLayout,
        bindingListener: InverseBindingListener?
) {
    if (bindingListener != null)
        swipeRefreshLayout.setOnRefreshListener {
            bindingListener.onChange()   // 1
        }
}
複製程式碼

注意我註釋了 //1的地方,每當swipeRefreshLayout重新整理狀態被使用者的操作改變,我們都能夠在這裡監聽到,並交給InverseBindingListener這個 信使 去通知DataBinding

嗨!View層的狀態發生了變更,你快去通知LiveData也進行對應資料的更新呀!

新的問題來了,現在DataBinding已經知道需要去通知LiveData進行對應資料的更新了,關鍵是——

3. 我要把什麼資料交給LiveData?

是的,即使LiveData需要進行更新,但是它並不知道要新的狀態是什麼。

LiveData: 老哥,你倒是把資料給我啊!

我們急需將SwipeRefreshLayout最新狀態告訴LiveData,因此我們通過InverseBindingAdapter註解和 步驟二 中去進行對接:

@JvmStatic
@InverseBindingAdapter(
        attribute = "app:bind_swipeRefreshLayout_refreshing",
        event = "app:bind_swipeRefreshLayout_refreshingAttrChanged"   // 2 【注意!】
)
fun isSwipeRefreshLayoutRefreshing(swipeRefreshLayout: SwipeRefreshLayout): Boolean =
        swipeRefreshLayout.isRefreshing
複製程式碼

注意到 //2 註釋的那行程式碼沒有,我們通過相同的tag(即app:bind_swipeRefreshLayout_refreshingAttrChanged這個字串,步驟二中我們也宣告瞭相同的字串),和 步驟二 中的程式碼塊形成了繫結對接。

現在,LiveData知道如何進行反向的資料更新了:

每當使用者下拉重新整理,InverseBindingListener通知DataBinding,LiveData就會從swipeRefreshLayout.isRefreshing得知最新的狀態,並進行資料的同步更新。

4.不要忘了防止死迴圈!

細心的你多少已經感覺到了不對勁的地方,現在的雙向繫結有一個致命的問題,那就是無限迴圈會導致的ANR異常。

View層UI狀態被改變,ViewModel對應發生更新,同時,這個更新又回通知View層去重新整理UI,這個重新整理UI的操作又會通知ViewModel去更新.......

因此,為了保證不會無限的死迴圈導致App的ANR異常的發生,我們需要在最初的程式碼塊中加一個判斷,保證,只有View狀態發生了變更,才會去更新UI:

@JvmStatic
@BindingAdapter("app:bind_swipeRefreshLayout_refreshing")
fun setSwipeRefreshLayoutRefreshing(
        swipeRefreshLayout: SwipeRefreshLayout,
        newValue: Boolean
) {
    if (swipeRefreshLayout.isRefreshing != newValue)   // 只有新老狀態不同才更新UI
        swipeRefreshLayout.isRefreshing = newValue
}
複製程式碼

小結:我為什麼還在堅守DataBinding

本文的初始計劃中,還有一個模組是關於 雙向繫結的原始碼分析,寫到後來又覺得沒有必要了,因為即使是 原始碼,也只是將上文中實現的思路囉嗦複述了一遍而已。

雙向繫結本身是一個極具爭議的功能;事實上,DataBinding本身也極具爭議——DataBinding的好用與否,用或者不用都不重要,重要的是我們需要去正視它展現出來的思想:即如何將一個 難以測試,狀態多變 的View, 通過程式碼抽象為 易於維護和測試 的純Java的狀態?

DataBinding將煩不勝煩的View層程式碼抽象為了易於維護的資料狀態,同時極大減少了View層向ViewModel層抽象的 膠水程式碼,這就是最大的優勢。

當然,DataBinding並不一定就是正解,事實上,RxBinding就是另外一個優秀的解決方案,同樣以SwipeRefreshLayout為例,我依然可以將其抽象為一個可觀察的Observable<Boolean>——前者通過在xml中對資料進行繫結和觀察,後者通過RxJava對View的狀態抽象為一個流,但最終,兩者在思想上殊途同歸。

關於我

Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github

如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章