MVVM 架構與數據綁定庫
什麽是數據綁定?
數據綁定是一種把數據綁定到用戶界面元素(控件)的通用機制。通常,數據綁定會將數據從本地存儲或者網絡綁定到顯示層,其特征是數據的改變會自動在數據源和用戶界面之間同步。
數據綁定庫的好處
TextView textView = (TextView) findViewById(R.id.label); EditText editText = (EditText) findViewById(R.id.userinput); ProgressBar progressBar = (ProgressBar) findViewById(R.id.progress); editText.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void afterTextChanged(Editable s) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { model.setText(s.toString()); } }); textView.setText(model.getLabel()); progressBar.setVisibility(View.GONE);
如上述代碼所示,大量的 findViewById() 調用之後,又是一大堆 setter/listener 之類的調用。 即使使用 ButterKnife 註入庫也沒有使情況改善。而數據綁定庫就能很好地解決這個問題。
在編譯時創建一個綁定類,它為所有視圖提供一個 ID 字段,因此不再需要調用 findViewById() 方法。實際上,這種方式比調用 findViewById() 方法快數倍,因為數據綁定庫創建代碼僅需要遍歷視圖結構一次。
綁定類中也實現了視圖文件的綁定邏輯,因此所有 setter 會在綁定類中被調用,你無須為之操心。總之,它能讓你的代碼變得更簡潔。
如何設置數據綁定?
android { compileSdkVersion 25 buildToolsVersion "25.0.1" ... dataBinding { enabled = true } ... }
首先在 app 的 build.gradle 中添加 dataBinding { enabled = true }。之後構建系統會收到提示對數據綁定啟用附加處理,如,從布局文件創建綁定類。
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="vm" type="com.example.ui.main.MainViewModel" />
<import type="android.view.View" />
</data>
...
</layout>
接下來,在 <layout> 標簽中包裝下布局中的頂層元素,以便為此布局創建綁定類。綁定類具有和布局 xml 文件相同的名稱,只是在結尾添加 Binding,例如, Activity_main.xml 的綁定類名字是 ActivityMainBinding。 如上所示,命名空間的聲明也移到布局標記中。然後,在布局標記內聲明將需要綁定的數據作為變量,並設置好名稱和類型。示例中,唯一的變量是視圖模型,但後續變量會增加。你可以選擇導入類,以便能使用 View.VISIBLE 或靜態方法等常量。
如何綁定數據?
<TextView
android:id="@+id/my_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{vm.visible ? View.VISIBLE : View.GONE}">
android:padding="@{vm.bigPadding ? @dimen/paddingBig : @dimen/paddingNormal}"
android:text=‘@{vm.text ?? @string/defaultText + "Additional text."}‘ />
視圖屬性上的數據綁定指令以@開頭,以大括號結束。你可以使用任何變量在數據段中導入你之前聲明的變量。這些表達式基本支持你在代碼中的所有操作,例如算術運算符或字符串連接。
Visibility 屬性中還支持 if-then-else 三元運算符。還提供了合並運算符 ??,如果左邊的值為空,則返回右操作數。在上述代碼中,你可以像在正常布局中一樣訪問資源,因此你可以根據布爾變量的取值選擇不同的 dimension 資源,也可以使用 padding 屬性查看這些資源。
即使你在代碼中使用 getters 和 setters,你所聲明的變量的屬性也可以用字段訪問語法的形式訪問。你可以在 slide 上的文本屬性中看到此部分,其中 vm.text 調用視圖模型的 getText() 方法。最後,一些小的限制也適用,例如,不能創建新對象,但是數據綁定庫仍然非常強大。
哪些屬性是可以綁定的?
android:text="@{vm.text}"
android:visibility="@{vm.visibility}"
android:paddingLeft="@{vm.padding}"
android:layout_marginBottom="@{vm.margin}"
app:adapter="@{vm.adapter}"
實際上,標準視圖的大多數屬性已經被數據綁定庫支持。在數據綁定庫內部,當你使用數據綁定時,庫按照視圖類型查找屬性名稱的 setter。例如,當你把數據綁定到 text 屬性時,綁定庫會在視圖類中使用合適的參數類型查找 setText() 方法,上述示例是 String。
當沒有對應的布局屬性時,你也可以使用數據綁定的 setter。例如,你可以在 xml 布局中的 recycleler 視圖上使用 app:adapter 屬性,以利用數據綁定設置適配器參數。
對於標準屬性,不是所有的都在 View 上有對應的 setter 方法。例如,paddingLeft 情況下,數據綁定庫支持自定義的 setter,以便將綁定轉移到 padding 屬性上。但是,遇到 layout_marginBottom 的情況,當綁定庫沒有提供自定義 setter 時我們要怎麽處理呢?
自定義 Setter
@BindingAdapter("android:layout_marginBottom")
public static void setLayoutMarginBottom(View v, int bottomMargin) {
ViewGroup.MarginLayoutParams layoutParams =
(ViewGroup.MarginLayoutParams) v.getLayoutParams();
if (layoutParams != null) {
layoutParams.bottomMargin = bottomMargin;
}
}
對於上述情況,自定義 setter 可以被重寫。Setter 是使用 @BindingAdapter 註解來實現的,布局屬性使用參數命名,使得綁定適配器被調用。上面示例提供了一個用於綁定 layout_marginBottom 的適配器。
方法必須是 public static void ,而且必須接受綁定適配器調用的首個視圖類型作為參數,然後將數據強綁定到你需要的類型。在這個例子中,我們使用一個 int 類型為類型 View(子類型)定義一個綁定適配器。最後,實現綁定適配器接口。對於 layout_marginBottom,我們需要獲取布局參數,並且設置底部間隔:
@BindingAdapter({"imageUrl", "placeholder"})
public static void setImageFromUrl(ImageView v, String url, int drawableId) {
Picasso.with(v.getContext().getApplicationContext())
.load(url)
.placeholder(drawableId)
.into(v);
}
也可能需要設置多種屬性以綁定適配器調用。為了達到此目的,MMVM 會提供你的屬性名稱列表並用於 @BindingAdapter 實現註解。另外,在現有方法中,每個屬性都有自己的名稱。只有在所有聲明的屬性被設置後,這些 BindingAdapter 才會被調用。
在加載圖片過程中,我想為加載圖片定義一個綁定適配器來綁定 URL 與 placeHolder。如你所見,通過使用 Picasso image loading library,綁定適配器非常容易實現。你可以在自定義綁定適配器中使用任何你想要的方法。
在代碼中使用綁定
MyBinding binding;
// For Activity
binding = DataBindingUtil.setContentView(this, R.layout.layout);
// For Fragment
binding = DataBindingUtil.inflate(inflater, R.layout.layout, container, false);
// For ViewHolder
binding = DataBindingUtil.bind(view);
// Access the View with ID text_view
binding.textView.setText(R.string.sometext);
// Setting declared variables
binding.set<VariableName>(variable);
現在我們在 xml 文件中定義了綁定,並且編寫了自定義 setter,那我們如何在代碼中使用綁定呢? 數據綁定庫通過生成綁定類為我們完成所有的工作。要獲取布局的相應綁定類的實例,就要用到庫提供的輔助方法。Activity 對應使用 DataBindingUtil.setContentView(),fragment 對應使用 inflate(),視圖擁有者請使用 bind()。 如前所述,綁定類為定義 final 字段的 ID 提供了所有視圖。同樣,您可以在綁定對象的布局文件中設置你所聲明的變量。
自動更新布局
如果使用數據綁定,在數據發生變化時,庫代碼可以控制布局自動更新。然而,庫仍然需要獲得關於數據變化的通知。如果綁定的變量實現了 Observable 接口(不要跟 RxJava 的 Observable混淆了)就能解決這個問題。
對於像 int 和 boolean 這樣的簡單數據類型,庫已經提供了合適的實現 Observable 的類型,比如 ObservableBoolean。還有一個 ObservableField 類型用於其它對象,比如字符串。
public class MyViewModel extends BaseObservable {
private Model model = new Model();
public void setModel(Model model) {
this.model = model;
notifyChange();
}
public void setAmount(int amount) {
model.setAmount(amount);
notifyPropertyChanged(BR.amount);
}
@Bindable public String getText() { return model.getText(); }
@Bindable public String getAmount() { return Integer.toString(model.getAmount()); }
}
在更復雜的情況下,比如視圖模型,有一個 BaseObservable 類提供了工具方法在變化時通知布局。就像上面在 setModel() 方法中看到那樣,我們可以在模型變化之後通過調用 notifyChange() 來更新整個布局。
再看看 setAmount(),你會看到模型中只有一個屬性發生了變化。這種情況下,我們不希望更新整個布局,只更新用到了這個屬性的部分。為達此目的,可以在屬性對應的 getter 上添加 @Bindable 註解。然後 BR 類中會產生一個字段,用於傳遞給 notifyPropertyChanged() 方法。這樣,綁定庫可以只更新確實依賴變化屬性的部分布局。
匯總
? 在布局文件中申明變量並將之與視圖中的屬性綁定。
? 在代碼中創建綁定來設置變量。
? 確保你的變量類型實現了 Observable 接口 —— 可以從 BaseObservable 繼承 —— 這樣數據變化時會自動反映到布局上。
模型、視圖、視圖模型(MVVM)架構
現在來看看 MVVM 架構,以及它的三個組成部分是如何一起工作的。
視圖是用戶界面,即布局。在 Android 中通常是指 Activity、Fragment 或者 ViewHolder 以及配合它們使用的 XML 布局文件。
模型就是業務邏輯層,提供方法與數據進行互動。
視圖模型就像是視圖和模型的中間人,它既能訪問模型的數據,又包含 UI 狀態。它也定義了一些命令可以被事件,比如單擊事件調用。視圖模型包含了應用中的呈現邏輯。
在 MVVM 架構模式中,模型和視圖模型主要通過數據綁定來進行互動。理想情況下,視圖和視圖模型不必相互了解。綁定應該是視圖和視圖模型之間的膠水,並且處理兩個方向的大多數東西。然而,在Anroid中它們不能真實的分離:
你要保存和恢復狀態,但現在狀態在視圖模型中。
你需要讓視圖模型知道生命周期事件。
你可能會遇到需要直接調用視圖方法的情況。
在這些情況下,視圖和視圖模型應該實現接口,然後在需要的時候通過命令通信。視圖模型的接口在任何情況都是需要的,因為數據綁定庫會處理與視圖的交互,並在上下文需要的時候使用自定義組件。
視圖模型還會更新模型,比如往數據庫添加新的數據,或者更新一個現有數據。它也用於從模型獲取數據。理想情況下,模型也應該在變化的時候通知視圖模型,但這取決於實現。
一般來說,視圖和視圖模型的分離會讓呈現邏輯易於測試,也有助於維持長期運行。與數據綁定庫一起會帶來更少更簡潔的代碼。
示例
<layout xmlns:android="...">
<data>
<variable name="vm" type="pkg.MyViewModel" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{vm.shouldShowText}"
android:text="@={vm.text}" />
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="@{vm::onButtonClick}"
android:text="@string/button"/>
</FrameLayout>
</layout>
使用 MVVM 的時候,布局只引用一個變量,即這個視圖的視圖模型,在這個示例中是 MyViewModel。在視圖模型中,你需要提供布局所需要的屬性,其簡單復雜程度取決於你的用例。
public class MyViewModel extends BaseObservable {
private Model model = new Model();
public void setModel(Model model) {
this.model = model;
notifyChange();
}
public boolean shouldShowText() {
return model.isTextRequired();
}
public void setText(String text) {
model.setText(text);
}
public String getText() {
return model.getText();
}
public void onButtonClick(View v) {
// Save data
}
}
這裏有一個 text 屬性。將 EditText 用於用戶輸入的時候,可以使用雙向綁定,同時,數據綁定庫將輸入反饋回視圖模型。為此,我們創建一個 setter 和 getter 並將屬性綁定到 EditText 的 text 屬性,這時候大括號前面的 = 號標誌著我們要在這裏進行雙向綁定。
另外,我們只想在模型需要輸入 text 的時候顯示 EditText。這種情況下,我們會在視圖模型中提供一個布爾屬性將其與 visibility 屬性綁定。為了讓它工作,我們還要創建一個綁定適配器(BindingAdapter),在值為 false 的時候設置 visibility 為 GONE,在值為 true 的時候設置為 VISIBLE。
@BindingAdapter("android:visibility")
public static void setVisibility(View view, boolean visible) {
view.setVisibility(visible ? View.VISIBLE : View.GONE);
}
最後,我們想在點擊 Button 時存儲信息,於是,在視圖模型中創建一個 onButtonClick() 命令,它負責處理與模型的交互。在布局中,我們通過對該方法引用將命令綁定到 Button 的 onClick 屬性上。為了使它直接工作,我們需要在方法中引入一個 View 的單個參數,類似於 OnClickListener。如果你不想使用 View 參數,你也可以直接在布局中使用 lambda 表達式。
為方便測試,我們需要在視圖模型中展示邏輯處理,但要盡量避免將邏輯處理直接放入其中。當然,你也可以自定義綁定適配器,這種方法更簡單。
生命周期和狀態
在實現 MVVM 架構的時候要考慮的另外一件事情是,在應用中如何處理生命周期和狀態。首先,我建議你為視圖模型創建一個基類用於處理這類問題。
public abstract class BaseViewModel<V extends MvvmView> extends BaseObservable {
private V view;
@CallSuper public void attachView(V view, Bundle sis) {
this.view = view;
if(sis != null) { onRestoreInstanceState(sis); }
}
@CallSuper public void detachView() {
this.view = null;
}
protected void onRestoreInstanceState(Bundle sis) { }
protected void onSaveInstanceState(Bundle outState) { }
protected final V view() { return view; }
}
Activity 和 Fragment 中都有生命周期回調。現在它們都放在視圖模型中來處理。因此,我們需要傳遞生命周期回調。我建議使用兩個回調,它們能滿足大多數需要:標誌著視圖被創建出來的 attachView() 和標誌著視圖被銷毀的 detachView()。在 attachView() 中,傳入視圖接口,用於在必要時向視圖發送命令。attachView() 通常在 Fragment 的 onCreate() 或 onCreateView() 中調用,detachView() 則是在 onDestory() 和 onDestoryView() 中調用。
現在 Activity 和 Fragment 也提供回調,用於在系統銷毀組件或配置發生變化時保存狀態。我們把狀態保存在視圖模型中,還需要將這些回調傳遞給視圖模型。我建議把 savedInstanceState 直接傳遞至 attachView(),以便在這裏自動恢復狀態。另一個 onSaveInstanceState() 方法需要用於保存狀態,這個方法必須在 Activity 和 Fragment 的相關回調中調用。如果有 UI 狀態,可為每個視圖模型創建單獨的狀態類,當這個類實現 Parcelable 時,保存和恢復狀態都很容易,因為你只需要保存或恢復一個對象。
視圖
public abstract class BaseActivity<B extends ViewDataBinding, V extends MvvmViewModel>
extends AppCompatActivity implements MvvmView {
protected B binding;
@Inject protected V viewModel;
protected final void setAndBindContentView(@LayoutRes int layoutResId, @Nullable Bundle sis) {
binding = DataBindingUtil.setContentView(this, layoutResId);
binding.setVariable(BR.vm, viewModel);
viewModel.attachView((MvvmView) this, sis);
}
@Override @CallSuper protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if(viewModel != null) { viewModel.onSaveInstanceState(outState); }
}
@Override @CallSuper protected void onDestroy() {
super.onDestroy();
if(viewModel != null) { viewModel.detachView(); }
binding = null;
viewModel = null;
}
}
現在,讓我們討論下視圖的細節。上面例子是創建 activity 基類。View 模型可通過註入用於基類,以便初始化架構配置。然後你只需要在 activity 的 onCreate() 或 fragment 的 onCreateView() 中調用這個方法即可。
上面代碼使用了 setAndBindContentView() 方法處理,和通常的 setContentView() 調用不同,它可以在 onCreate() 中調用。此方法能設置內容視圖並創建綁定,在綁定上設置視圖模型變量,並將視圖附加到視圖模型上,同時還提供保存的示例狀態。
如你所見,onSaveInstanceState() 和 detachView() 回調也可以在基類中實現。 onSaveInstanceState() 將回調轉發到視圖模型中,onDestroy() 則在視圖模型上調用 detachView() 接口。
通過這樣設置基類後,你就可以使用 MVVM 架構編寫 APP 了。
其他考慮項
了解 MVVM 架構 Android 應用的基礎後,還需對應用程序架構做進一步完善。
依賴註入
使用依賴註入可以非常容易地將組件註入到視圖模型中,並將組件很好的聯合在一起,如使用 Dagger 2 依賴註入框架。
依賴註入可以進一步解耦代碼,讓代碼更簡單也更容易測試。同時,也大大增強了代碼的可維護性。更重要的是,依賴接口能真正實現解耦。
業務邏輯
註意:視圖模型只包含呈現邏輯,所以不要把業務邏輯放在視圖模型中。創建模型類的存儲接口並選擇的存儲方式將其實現:
public interface ModelRepo {
Single<List<Model>> findAll();
Single<Model> findById(int id);
void save(Model model);
void delete(Model model);
}
對於網絡,則使用 Retrofit 創建網絡相關的代碼來實現定義的接口。
public interface ModelRepo {
@GET("model")
Single<List<Model>> findAll();
@GET("model/{id}")
Single<Model> findById(@Path("id") int id);
@PUT("model")
Completable create(@Body Model model);
}
對於像查找、創建這樣的基本操作,可以將存儲庫註入到視圖模型中以獲取和操作數據。對於其它更復雜的情況,比如校驗,則需要創建獨立的組件來實現這些行為,並將其註入到視圖模型中。
導航
Android 中另一個重要內容是導航,因為你需要視圖提供組件,它可能是啟動 Activity 的 Context,也可能是替換 Fragment 的 FragmentManager。同時,使用視圖接口來調用導航命令只會讓架構變得更復雜。
因此,我們需要一個獨立的組件來處理應用中的導航。Navigator 接口定義了一些公共方法用於啟動 Activity,處理 Fragment 並將它們註入視圖模型中。你可以直接在視圖模型中進行導航,而不需要 Context 或者 FragmentManager,因為這些都是由導航器的實現來處理的。
public interface Navigator {
String EXTRA_ARGS = "_args";
void finishActivity();
void startActivity(Intent intent);
void startActivity(String action);
void startActivity(String action, Uri uri);
void startActivity(Class<? extends Activity> activityClass);
void startActivity(Class<? extends Activity> activityClass, Bundle args);
void replaceFragment(int containerId, Fragment fragment, Bundle args);
void replaceFragmentAndAddToBackStack(int containerId, @NonNull Fragment fragment,
Bundle args, String backstackTag);
...
}
視圖持有者可以在視圖模型中使用導航器進行導航,十分方便。比如,點擊回收視圖的某張卡片可以啟動新的 Activity。
單元測試
最後,我們了解一下視圖模型和單元測試。正如前面提到的,MVVM 架構能簡化測試呈現邏輯。我更一般使用 Mockito,它讓我可以模擬視圖接口和其它註入視圖模型和組件。當然,你也可以使用 PowerMock 來進行要求更高的測試,它使用字節碼控制,可以模擬靜態方法。
public class MyViewModelUnitTest {
@Mock ModelRepo modelRepo;
@Mock Navigator navigator;
@Mock MvvmView myView;
MyViewModel myViewModel;
@Before public void setup() {
MockitoAnnotations.initMocks(this);
myViewModel = new MyViewModel(modelRepo, navigator);
myViewModel.attachView(myView, null);
}
@Test public void buttonClick_submitsForm() {
final Model model = new Model();
doReturn(model).when(modelRepo).create();
myViewModel.onButtonClick(null);
verify(modelRepo).save(model);
verify(navigator).finishActivity();
}
}
在 setup() 方法中初始化 mock,創建視圖模型,同時註入 mock 對象並將視圖接口附加到視圖模型。寫測試用例的時候,若有必要,先通過 Mockito 的 doReturn().when() 語法指定 mock 對象的行為。 然後在視圖模型中調用測試方法。最後使用斷言和 verify() 方法檢查返回值是否正確,檢查 mock 的方法是否按預期進行調用。
總結
? 關於按照 ModelViewViewModel 模式使用數據綁定庫組織 app 架構,總結如下:
? 視圖模型是視圖和模型之間的中間介。
? 視圖通過數據綁定自動更新視圖模型的屬性。
? 視圖事件可調用視圖模型中的命令。
? 視圖模型也可在視圖上調用命令。
? 在 Android 中,視圖模型可以處理基本的生命周期回調和狀態保存及恢復。
? 依賴註入有助於測試和獲得更整潔的代碼。
? 不要在視圖模型中放置業務邏輯,它們只包含展示邏輯。另外,要使用存儲庫進行數據訪問。
? 在 Android App 中導航請使用導航器組件。
MVVM 架構與數據綁定庫