Android DataBinding:采用ViewModel代替Presenter
最近一段時間MVP模式已經成為Android應用開發UI層架構設計的主流趨勢。類似TED MOSBY,nucleus和mortar之類的框架都引入了Presenters來幫助我們搭建簡潔的app架構。它們也(在不同的程度上)幫助我們處理Android平臺上臭名昭著的設備旋轉和狀態持久化等問題。MVP模式也有助于隔離樣板代碼,雖然這并不是MVP模式的設計初衷。
在Google I/O 2015上,伴隨著Android M預覽版發布的Data Binding兼容函數庫改變了這一切。
根據維基百科上關于MVP的詞條描述,Presenter作用如下:
Presenter作用于model和view,它從倉庫(Model)中獲取數據,并格式化后讓view進行顯示。
Data Binding框架將會接管Presenter的主要職責(作用于model和view上),Presenter的其他剩余職責(從倉庫中獲取數據并進行 格式化處理)則由ViewModel(一個增強版的Model)接管。ViewModel是一個獨立的Java類,它的唯一職責是表示一個View后面的 數據。它可以合并來自多個數據源(Models)的數據,并將這些數據加工后用于展示。我之前寫過一篇關于ViewModel的短文,講述了它與Data Model或者Transport Model之間的區別。
我們今天要講述的架構是MVVM(Model-View-ViewModel),它最初是在2005年(不要嚇到哦)由微軟提出的一個被證明可用的概念。下面我將舉例說明從MVP到MVVM的改變,容我盜用下Hanne Dorfmann在他介紹TED MOSBY框架的文章中的插圖。


可以看到對view中數據的所有綁定和更新操作都是通過Data Binding框架實現的。通過ObservableField類,View 在model發生變化時會作出反應,在XML文件中對屬性的引用使得框架在用戶操作View時可以將變化推送給對應的ViewModel。我們也可以通過 代碼訂閱屬性的變化,這樣可以實現例如當CheckBox被點擊后,TextView被禁用這樣的功能。像這樣使用標準Java類來表示View的視覺狀 態的一個很大優勢是明顯的:你可以很容易對這種視覺行為進行單元測試。
上面關于MVP的插圖中有一個名為Presenter.loadUsers()的方法,這是一個命令。在MVVM中這些方法定義在ViewModel中。從維基百科文章中可以看到:
view model是一個抽象的view,它對外暴露公有的屬性和命令。
因此這可能跟你以前熟悉的東西有些不同。在MVP模式中models很可能只是純粹用于保存數據的“啞”類。對于把業務邏輯放到Models或者View Models中的行為不要感到害怕。這是面向對象編程的 核心準則。回到Presenter.loadUsers()函數,現在它是一個放在ViewModel中的函數,它可能被View的后置代碼 (code-behind)調用,或者被位于View的XML文件中的數據綁定命令調用。如果android-developer-preview問題跟 蹤里面這個issue描述的問題得到支持的話。如果我們沒能得到數據綁定到命令功能的支持,那就只能使用以前的android:onClick語法,或者手動在view中添加監聽器了。
代碼后置(code-behind),微軟的一個概念,經常與早期的ASP.NET或者WinForms聯系在一起。我想 它也可以作為Android上的一個描述術語,View由兩個元素組成:View的布局文件(XML)和后置代碼(Java),這通常是指 Fragments,Activities或者繼承自View.java的其他類。
處理系統調用
View的后置代碼還需要完成一系列用例-初始化系統,打開對話框的函數,或者任何需要引用Android Context對象的調用。但不要把這樣的代碼調用放到ViewModel中。如果ViewModel包含
import android.content.Context;
這段代碼,說明你用錯了,千萬不要這么做,好奇害死貓。
我還沒有完全決定解決這個問題的最好辦法,不過這是因為有幾個好的選擇。一個方法是通過在ViewModel中持有View的一個引用來保存 Mosby中的presenter元素。這個方案不會降低可測試性。但跟在Mosby中持有一個單獨的Presenter類不同,我堅持認為將View作 為接口的具體實現可以起到簡化代碼的作用。另一個方法可能是使用Square的Otto之類的事件總線機制來初始化類似
new ShowToastMessage("hello world")
的命令。這將會很好的分離view和viewmodel,不過這是一件好事嗎?
我們不需要框架了嗎?
那么Data Binding框架已經接管了類似Mosby或者Mortar等框架的工作了嗎?只是一部分。我希望看到的是這些框架進化或者新增分支變成MVVM類型的 框架,這樣我們在充分利用Data Binding的同時,可以最低限度依賴第三方框架,并保持框架的小而美。雖然Presenter的時代可能已經結束了,但這些框架在管理聲明周期和 view(或者ViewModel)的狀態持久化方面還在發揮作用,這一點并沒有改變。(如果Google引入一個LifeCycleAffected接 口讓Fragment, Activity 和 View進行實現,那將是多么酷的一件事!這個接口由一個名為addOnPauseListener()和addOnResumeListener()的 函數,在我們例子中如何使用這個接口將留給你來實現。)
更新:最近了解到AndroidViewModel框架,它實際上可能很適合MVVM和Android的Data Binding。不過我還沒有時間試用它。
總結
當我首次聽說Android M致力于改進SDK并重點關注開發者時,我真的很激動。當我聽說他們引入了Data Binding,我被震驚了。在其他平臺如WinForms, WPF, Silverlight 和 Windows Phone上面我已經用了好幾年Data Binding技術。我知道這可以幫助我們寫出簡潔的架構和更少的樣板代碼。這個框架是站在開發者這邊的,而不是阻礙我們的,很久以前我就感受到這一點 了。
但Data Binding不是銀彈,它也有缺點。在XML文件中定義綁定本身就是一個問題。XML不會被編譯,它也不能進行單元測試。因此你將會經常在運行時才發現 錯誤,而不是在編譯期間。忘記將屬性綁定到View了?很不幸。但工具可以發揮很大的幫助-這是為什么我希望Google能夠盡量讓Android Studio最大程度支持Data Binding。XML綁定的語法和引用檢查,自動完成和導航支持。XML字段的重命名支持。從我測試Android Studio 1.3 beta來看,我至少可以肯定他們有在考慮這件事情。某些功能已經支持了,但還有很多沒有支持,不過1.3版本仍然處于beta階段,我們可以有更多的期 待。
代碼示例
接下來我將給出一個示例,演示從MVP架構遷移到MVVM架構的結果。在MVP版本工程中,我使用Mosby框架并使用Butterknife實現 視圖注入。在MVVM例子中我使用Android M Data Binding并移除工程中對Mosby和Butterknife的依賴。結果是Presenter可以丟掉了,Fragment中代碼減少了,不過 ViewModel接管了很多代碼。
在這個例子中我直接引用View來生成toast消息。這也許不是我以后提倡的一種方法, 但理論上這么做沒什么問題。使用Robolectric和Mockito來對Fragment進行mock,這樣是可測試的,而且不會泄露內存,除非你錯 誤的引用了ViewModels。
下面這個app只是起一個演示的作用,它具有一個簡單的登陸頁面,后臺會加載一些異步數據,views之間會有一些依賴。

如果你希望在Android Studio中閱讀代碼,可以到Github上分別檢出MVP和MVVM的標簽。
下面準備好接受代碼轟炸吧??
MVP – VIEW – XML
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivityFragment"> <TextView android:text="..." android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:id="@+id/loggedInUserCount"/> <TextView android:text="# logged in users:" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="false" android:layout_toLeftOf="@+id/loggedInUserCount"/> <RadioGroup android:layout_marginTop="40dp" android:id="@+id/existingOrNewUser" android:gravity="center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:orientation="horizontal"> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Returning user" android:id="@+id/returningUserRb"/> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="New user" android:id="@+id/newUserRb" /> </RadioGroup> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/username_block" android:layout_below="@+id/existingOrNewUser"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Username:" android:id="@+id/textView" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/username" android:minWidth="200dp"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentStart="false" android:id="@+id/password_block" android:layout_below="@+id/username_block"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Password:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textPassword" android:ems="10" android:id="@+id/password"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/password_block" android:id="@+id/email_block"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Email:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:ems="10" android:id="@+id/email"/> </LinearLayout> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Log in" android:id="@+id/loginOrCreateButton" android:layout_below="@+id/email_block" android:layout_centerHorizontal="true"/> </RelativeLayout>
MVP – VIEW – JAVA
package com.nilzor.presenterexample; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.CompoundButton; import android.widget.RadioButton; import android.widget.TextView; import android.widget.Toast; import com.hannesdorfmann.mosby.mvp.MvpFragment; import com.hannesdorfmann.mosby.mvp.MvpView; import butterknife.InjectView; import butterknife.OnClick; public class MainActivityFragment extends MvpFragment implements MvpView { @InjectView(R.id.username) TextView mUsername; @InjectView(R.id.password) TextView mPassword; @InjectView(R.id.newUserRb) RadioButton mNewUserRb; @InjectView(R.id.returningUserRb) RadioButton mReturningUserRb; @InjectView(R.id.loginOrCreateButton) Button mLoginOrCreateButton; @InjectView(R.id.email_block) ViewGroup mEmailBlock; @InjectView(R.id.loggedInUserCount) TextView mLoggedInUserCount; public MainActivityFragment() { } @Override public MainPresenter createPresenter() { return new MainPresenter(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_main, container, false); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); attachEventListeners(); } private void attachEventListeners() { mNewUserRb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { updateDependentViews(); } }); mReturningUserRb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { updateDependentViews(); } }); } /** Prepares the initial state of the view upon startup */ public void setInitialState() { mReturningUserRb.setChecked(true); updateDependentViews(); } /** Shows/hides email field and sets correct text of login button depending on state of radio buttons */ public void updateDependentViews() { if (mReturningUserRb.isChecked()) { mEmailBlock.setVisibility(View.GONE); mLoginOrCreateButton.setText(R.string.log_in); } else { mEmailBlock.setVisibility(View.VISIBLE); mLoginOrCreateButton.setText(R.string.create_user); } } public void setNumberOfLoggedIn(int numberOfLoggedIn) { mLoggedInUserCount.setText("" + numberOfLoggedIn); } @OnClick(R.id.loginOrCreateButton) public void loginOrCreate() { if (mNewUserRb.isChecked()) { Toast.makeText(getActivity(), "Please enter a valid email address", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(getActivity(), "Invalid username or password", Toast.LENGTH_SHORT).show(); } } }
MVP – PRESENTER
package com.nilzor.presenterexample; import android.os.Handler; import android.os.Message; import com.hannesdorfmann.mosby.mvp.MvpPresenter; public class MainPresenter implements MvpPresenter { MainModel mModel; private MainActivityFragment mView; public MainPresenter() { mModel = new MainModel(); } @Override public void attachView(MainActivityFragment view) { mView = view; view.setInitialState(); updateViewFromModel(); ensureModelDataIsLoaded(); } @Override public void detachView(boolean retainInstance) { mView = null; } private void ensureModelDataIsLoaded() { if (!mModel.isLoaded()) { mModel.loadAsync(new Handler.Callback() { @Override public boolean handleMessage(Message msg) { updateViewFromModel(); return true; } }); } } /** Notifies the views of the current value of "numberOfUsersLoggedIn", if any */ private void updateViewFromModel() { if (mView != null && mModel.isLoaded()) { mView.setNumberOfLoggedIn(mModel.numberOfUsersLoggedIn); } } }
MVP – MODEL
package com.nilzor.presenterexample; import android.os.AsyncTask; import android.os.Handler; import java.util.Random; public class MainModel { public Integer numberOfUsersLoggedIn; private boolean mIsLoaded; public boolean isLoaded() { return mIsLoaded; } public void loadAsync(final Handler.Callback onDoneCallback) { new AsyncTask() { @Override protected Void doInBackground(Void... params) { // Simulating some asynchronous task fetching data from a remote server try {Thread.sleep(2000);} catch (Exception ex) {}; numberOfUsersLoggedIn = new Random().nextInt(1000); mIsLoaded = true; return null; } @Override protected void onPostExecute(Void aVoid) { onDoneCallback.handleMessage(null); } }.execute((Void) null); } }
MVVM – VIEW – XML
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="data" type="com.nilzor.presenterexample.MainModel"/> </data> <RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivityFragment"> <TextView android:text="@{data.numberOfUsersLoggedIn}" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:id="@+id/loggedInUserCount"/> <TextView android:text="# logged in users:" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="false" android:layout_toLeftOf="@+id/loggedInUserCount"/> <RadioGroup android:layout_marginTop="40dp" android:id="@+id/existingOrNewUser" android:gravity="center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:orientation="horizontal"> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Returning user" android:checked="@{data.isExistingUserChecked}" android:id="@+id/returningUserRb"/> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="New user" android:id="@+id/newUserRb" /> </RadioGroup> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/username_block" android:layout_below="@+id/existingOrNewUser"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Username:" android:id="@+id/textView" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/username" android:minWidth="200dp"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentStart="false" android:id="@+id/password_block" android:layout_below="@+id/username_block"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Password:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textPassword" android:ems="10" android:id="@+id/password"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/password_block" android:id="@+id/email_block" android:visibility="@{data.emailBlockVisibility}"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Email:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:ems="10" android:id="@+id/email"/> </LinearLayout> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{data.loginOrCreateButtonText}" android:id="@+id/loginOrCreateButton" android:layout_below="@+id/email_block" android:layout_centerHorizontal="true"/> </RelativeLayout> </layout>
MVVM – VIEW – JAVA
package com.nilzor.presenterexample; import android.app.Fragment; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.CompoundButton; import android.widget.Toast; import com.nilzor.presenterexample.databinding.FragmentMainBinding; public class MainActivityFragment extends Fragment { private FragmentMainBinding mBinding; private MainModel mViewModel; public MainActivityFragment() { } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_main, container, false); mBinding = FragmentMainBinding.bind(view); mViewModel = new MainModel(this, getResources()); mBinding.setData(mViewModel); attachButtonListener(); return view; } private void attachButtonListener() { mBinding.loginOrCreateButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mViewModel.logInClicked(); } }); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { ensureModelDataIsLodaded(); } private void ensureModelDataIsLodaded() { if (!mViewModel.isLoaded()) { mViewModel.loadAsync(); } } public void showShortToast(String text) { Toast.makeText(getActivity(), text, Toast.LENGTH_SHORT).show(); } }
MVVM – VIEWMODEL
package com.nilzor.presenterexample; import android.content.res.Resources; import android.databinding.ObservableField; import android.os.AsyncTask; import android.view.View; import java.util.Random; public class MainModel { public ObservableField numberOfUsersLoggedIn = new ObservableField(); public ObservableField isExistingUserChecked = new ObservableField(); public ObservableField emailBlockVisibility = new ObservableField(); public ObservableField loginOrCreateButtonText = new ObservableField(); private boolean mIsLoaded; private MainActivityFragment mView; private Resources mResources; public MainModel(MainActivityFragment view, Resources resources) { mView = view; mResources = resources; // You might want to abstract this for testability setInitialState(); updateDependentViews(); hookUpDependencies(); } public boolean isLoaded() { return mIsLoaded; } private void setInitialState() { numberOfUsersLoggedIn.set("..."); isExistingUserChecked.set(true); } private void hookUpDependencies() { isExistingUserChecked.addOnPropertyChangedCallback(new android.databinding.Observable.OnPropertyChangedCallback() { @Override public void onPropertyChanged(android.databinding.Observable sender, int propertyId) { updateDependentViews(); } }); } public void updateDependentViews() { if (isExistingUserChecked.get()) { emailBlockVisibility.set(View.GONE); loginOrCreateButtonText.set(mResources.getString(R.string.log_in)); } else { emailBlockVisibility.set(View.VISIBLE); loginOrCreateButtonText.set(mResources.getString(R.string.create_user)); } } public void loadAsync() { new AsyncTask() { @Override protected Void doInBackground(Void... params) { // Simulating some asynchronous task fetching data from a remote server try {Thread.sleep(2000);} catch (Exception ex) {}; numberOfUsersLoggedIn.set("" + new Random().nextInt(1000)); mIsLoaded = true; return null; } }.execute((Void) null); } public void logInClicked() { // Illustrating the need for calling back to the view though testable interfaces. if (isExistingUserChecked.get()) { mView.showShortToast("Invalid username or password"); } else { mView.showShortToast("Please enter a valid email address"); } } }
文末攝影鑒賞