Android
MVVM (architektura)
Szukaj…
Uwagi
Dziwna składnia za pomocą DataBinding
Podczas wiązania funkcji viewModel z właściwością w pliku XML niektóre prefiksy funkcji, takie jak get
lub is
są usuwane. Na przykład. ViewModel::getFormattedText
na ViewModel zmieni się na @{viewModel.formattedText}
po powiązaniu go z właściwością w xml. Podobnie z ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(notacja Java Bean)
Wygenerowane klasy powiązań, takie jak ActivityMainBinding
są nazwane na podstawie pliku XML, dla którego tworzą powiązania, a nie klasy Java.
Wiązania niestandardowe
W Activity_main.xml ustawiam atrybut textColor w app
a nie w przestrzeni nazw android
. Dlaczego? Ponieważ dla atrybutu textColor
zdefiniowano niestandardowy obiekt ustawiający, który rozwiązuje identyfikator zasobu ColorRes wysyłany przez ViewModel do rzeczywistego koloru.
public class CustomBindings {
@TargetApi(23)
@BindingAdapter({"bind:textColor"})
public static void setTextColor(TextView textView, int colorResId) {
final Context context = textView.getContext();
final Resources resources = context.getResources();
final int apiVersion = Build.VERSION.SDK_INT;
int color;
if (apiVersion >= Build.VERSION_CODES.M) {
color = resources.getColor(colorResId, context.getTheme());
} else {
color = resources.getColor(colorResId);
}
textView.setTextColor(color);
}
}
Aby uzyskać szczegółowe informacje na temat tego, jak to działa, sprawdź DataBinding Library: Custom Setters
Poczekaj ... w twoim xml jest logika !!!?
Można argumentować, że rzeczy, które robię w XML dla android:visibility
i app:textColor
są nieprawidłowe / anty-wzorce w kontekście MVVM, ponieważ w moim widoku jest logika widoku. Jednak twierdzę, że ważniejsze jest dla mnie utrzymanie zależności Androida od mojego ViewModel ze względów testowych.
Poza tym, co tak naprawdę robi app:textColor
? Rozpoznaje on tylko wskaźnik zasobów do rzeczywistego koloru z nim związanego. Więc ViewModel nadal decyduje, który kolor jest wyświetlany na podstawie pewnych warunków.
Co do android:visibility
Czuję, że ze względu na nazwę tej metody można w tym miejscu użyć operatora trójskładnikowego. Ze względu na nazwę isLoadingVisible
i isContentVisible
naprawdę nie ma wątpliwości co do tego, co każdy wynik powinien rozwiązać w widoku. Więc wydaje mi się, że raczej wykonuje polecenie podane przez ViewModel niż naprawdę robi logikę widoku.
Z drugiej strony zgodziłbym się, że używając viewModel.isLoading ? View.VISIBLE : View.GONE
byłoby złym viewModel.isLoading ? View.VISIBLE : View.GONE
, ponieważ przyjmujesz założenia w widoku, co ten stan oznacza dla widoku.
Przydatny materiał
Następujące zasoby bardzo mi pomogły w zrozumieniu tej koncepcji:
- Jeremy Likness - Objaśnienie Model-View-ViewModel (MVVM) (C #) (08.2010)
- Shamlia Shukkur - Zrozumienie podstaw wzorca projektowego MVVM (C #) (03.2013)
- Frode Nilsen - Android Databinding: Goodbye Presenter, Hello ViewModel! (07.2015)
- Joe Birch - Zbliżanie się Androida z MVVM (09.2015)
- Florina Muntenescu - Wzorce architektury Androida Część 3: Model-View-ViewModel (10.2016)
Przykład MVVM z wykorzystaniem biblioteki DataBinding
Celem MVVM jest oddzielenie warstw zawierających logikę od warstwy widoku.
Na Androidzie możemy skorzystać z Biblioteki DataBinding, aby nam w tym pomóc i uczynić większość naszej logiki testowalną Jednostką, nie martwiąc się o zależności Androida.
W tym przykładzie pokażę główne komponenty głupiej prostej aplikacji, która wykonuje następujące czynności:
- Przy uruchomieniu fałszywe połączenie sieciowe i pokaż ładujący spinner
- Pokaż widok za pomocą licznika kliknięć TextView, komunikatu TextView i przycisku zwiększającego licznik
- Na przycisku kliknij aktualizuj licznik i aktualizuj kolor licznika oraz tekst wiadomości, jeśli licznik osiągnie określoną liczbę
Zacznijmy od warstwy widoku:
activity_main.xml
:
Jeśli nie wiesz, jak działa DataBinding, prawdopodobnie powinieneś poświęcić 10 minut na zapoznanie się z nim. Jak widać, wszystkie pola, które zwykle aktualizujesz ustawiaczami, są powiązane z funkcjami zmiennej viewModel.
Jeśli masz pytanie dotyczące android:visibility
lub app:textColor
właściwości app:textColor
, sprawdź sekcję „Uwagi”.
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="de.walled.mvvmtest.viewmodel.ClickerViewModel"/>
</data>
<RelativeLayout
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/activity_horizontal_margin"
tools:context="de.walled.mvvmtest.view.MainActivity">
<LinearLayout
android:id="@+id/click_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="60dp"
android:visibility="@{viewModel.contentVisible ? View.VISIBLE : View.GONE}"
android:padding="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/number_of_clicks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/ClickCounter"
android:text="@{viewModel.numberOfClicks}"
android:textAlignment="center"
app:textColor="@{viewModel.counterColor}"
tools:text="8"
tools:textColor="@color/red"
/>
<TextView
android:id="@+id/static_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginStart="4dp"
style="@style/ClickCounter"
android:text="@string/label.clicks"
app:textColor="@{viewModel.counterColor}"
android:textAlignment="center"
tools:textColor="@color/red"
/>
</LinearLayout>
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/click_counter"
android:layout_centerHorizontal="true"
android:visibility="@{viewModel.contentVisible ? View.VISIBLE : View.GONE}"
android:text="@{viewModel.labelText}"
android:textAlignment="center"
android:textSize="18sp"
tools:text="You're bad and you should feel bad!"
/>
<Button
android:id="@+id/clicker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/message"
android:layout_centerHorizontal="true"
android:layout_marginTop="8dp"
android:visibility="@{viewModel.contentVisible ? View.VISIBLE : View.GONE}"
android:padding="8dp"
android:text="@string/label.button"
android:onClick="@{() -> viewModel.onClickIncrement()}"
/>
<android.support.v4.widget.ContentLoadingProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="90dp"
android:layout_centerHorizontal="true"
style="@android:style/Widget.ProgressBar.Inverse"
android:visibility="@{viewModel.loadingVisible ? View.VISIBLE : View.GONE}"
android:indeterminate="true"
/>
</RelativeLayout>
</layout>
Następnie warstwa modelu. Oto mam:
- dwa pola reprezentujące stan aplikacji
- pobierający odczytać liczbę kliknięć i stan podniecenia
- metoda zwiększania liczby kliknięć
- metoda przywracania poprzedniego stanu (ważna dla zmian orientacji)
Tutaj również definiuję „stan podniecenia”, który zależy od liczby kliknięć. Będzie to później wykorzystane do aktualizacji koloru i komunikatu w Widoku.
Należy zauważyć, że w modelu nie ma żadnych założeń dotyczących sposobu wyświetlania stanu użytkownikowi!
ClickerModel.java
import com.google.common.base.Optional;
import de.walled.mvvmtest.viewmodel.ViewState;
public class ClickerModel implements IClickerModel {
private int numberOfClicks;
private Excitement stateOfExcitement;
public void incrementClicks() {
numberOfClicks += 1;
updateStateOfExcitement();
}
public int getNumberOfClicks() {
return Optional.fromNullable(numberOfClicks).or(0);
}
public Excitement getStateOfExcitement() {
return Optional.fromNullable(stateOfExcitement).or(Excitement.BOO);
}
public void restoreState(ViewState state) {
numberOfClicks = state.getNumberOfClicks();
updateStateOfExcitement();
}
private void updateStateOfExcitement() {
if (numberOfClicks < 10) {
stateOfExcitement = Excitement.BOO;
} else if (numberOfClicks <= 20) {
stateOfExcitement = Excitement.MEH;
} else {
stateOfExcitement = Excitement.WOOHOO;
}
}
}
Następnie ViewModel.
Spowoduje to zmiany w modelu i sformatowanie danych z modelu, aby pokazać je w widoku. Zauważ, że tutaj oceniamy, która reprezentacja GUI jest odpowiednia dla stanu podanego przez model ( resolveCounterColor
i resolveLabelText
). Więc moglibyśmy na przykład łatwo zaimplementować UnderachieverClickerModel
że ma niższe progi do stanu podniecenia bez dotykania żadnego kodu w ViewModel lub widoku.
Należy również pamiętać, że ViewModel nie przechowuje żadnych odniesień do obiektów widoku. Wszystkie właściwości są powiązane za pomocą adnotacji @Bindable
i aktualizowane, gdy albo notifyChange()
(sygnalizuje, że wszystkie właściwości muszą zostać zaktualizowane), albo notifyPropertyChanged(BR.propertyName)
(sygnalizuje, że te właściwości muszą zostać zaktualizowane).
ClickerViewModel.java
import android.databinding.BaseObservable;
import android.databinding.Bindable;
import android.support.annotation.ColorRes;
import android.support.annotation.StringRes;
import com.android.databinding.library.baseAdapters.BR;
import de.walled.mvvmtest.R;
import de.walled.mvvmtest.api.IClickerApi;
import de.walled.mvvmtest.model.Excitement;
import de.walled.mvvmtest.model.IClickerModel;
import rx.Observable;
public class ClickerViewModel extends BaseObservable {
private final IClickerApi api;
boolean isLoading = false;
private IClickerModel model;
public ClickerViewModel(IClickerModel model, IClickerApi api) {
this.model = model;
this.api = api;
}
public void onClickIncrement() {
model.incrementClicks();
notifyChange();
}
public ViewState getViewState() {
ViewState viewState = new ViewState();
viewState.setNumberOfClicks(model.getNumberOfClicks());
return viewState;
}
public Observable<ViewState> loadData() {
isLoading = true;
return api.fetchInitialState()
.doOnNext(this::initModel)
.doOnTerminate(() -> {
isLoading = false;
notifyPropertyChanged(BR.loadingVisible);
notifyPropertyChanged(BR.contentVisible);
});
}
public void initFromSavedState(ViewState savedState) {
initModel(savedState);
}
@Bindable
public String getNumberOfClicks() {
final int clicks = model.getNumberOfClicks();
return String.valueOf(clicks);
}
@Bindable
@StringRes
public int getLabelText() {
final Excitement stateOfExcitement = model.getStateOfExcitement();
return resolveLabelText(stateOfExcitement);
}
@Bindable
@ColorRes
public int getCounterColor() {
final Excitement stateOfExcitement = model.getStateOfExcitement();
return resolveCounterColor(stateOfExcitement);
}
@Bindable
public boolean isLoadingVisible() {
return isLoading;
}
@Bindable
public boolean isContentVisible() {
return !isLoading;
}
private void initModel(final ViewState viewState) {
model.restoreState(viewState);
notifyChange();
}
@ColorRes
private int resolveCounterColor(Excitement stateOfExcitement) {
switch (stateOfExcitement) {
case MEH:
return R.color.yellow;
case WOOHOO:
return R.color.green;
default:
return R.color.red;
}
}
@StringRes
private int resolveLabelText(Excitement stateOfExcitement) {
switch (stateOfExcitement) {
case MEH:
return R.string.label_indifferent;
case WOOHOO:
return R.string.label_excited;
default:
return R.string.label_negative;
}
}
}
Wiązanie wszystkiego razem w działaniu!
Tutaj widzimy widok inicjujący viewModel ze wszystkimi niezbędnymi zależnościami, które muszą być tworzone z kontekstu Androida.
Po zainicjowaniu viewModel jest on powiązany z układem xml poprzez DataBindingUtil (proszę sprawdzić sekcję „Składnia”, aby nazwać generowane klasy).
Uwaga: subskrypcje są subskrybowane na tej warstwie, ponieważ musimy poradzić sobie z ich anulowaniem, gdy aktywność jest wstrzymana lub zniszczona, aby uniknąć wycieków pamięci i NPE. Wywoływane jest również utrzymywanie i ponowne ładowanie widoku Stan na Orientacji Zmiany
MainActivity.java
import android.databinding.DataBindingUtil;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import de.walled.mvvmtest.R;
import de.walled.mvvmtest.api.ClickerApi;
import de.walled.mvvmtest.api.IClickerApi;
import de.walled.mvvmtest.databinding.ActivityMainBinding;
import de.walled.mvvmtest.model.ClickerModel;
import de.walled.mvvmtest.viewmodel.ClickerViewModel;
import de.walled.mvvmtest.viewmodel.ViewState;
import rx.Subscription;
import rx.subscriptions.Subscriptions;
public class MainActivity extends AppCompatActivity {
private static final String KEY_VIEW_STATE = "state.view";
private ClickerViewModel viewModel;
private Subscription fakeLoader = Subscriptions.unsubscribed();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// would usually be injected but I feel Dagger would be out of scope
final IClickerApi api = new ClickerApi();
setupViewModel(savedInstanceState, api);
ActivityMainBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
binding.setViewModel(viewModel);
}
@Override
protected void onPause() {
fakeLoader.unsubscribe();
super.onPause();
}
@Override
protected void onDestroy() {
fakeLoader.unsubscribe();
super.onDestroy();
}
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putSerializable(KEY_VIEW_STATE, viewModel.getViewState());
}
private void setupViewModel(Bundle savedInstance, IClickerApi api) {
viewModel = new ClickerViewModel(new ClickerModel(), api);
final ViewState savedState = getViewStateFromBundle(savedInstance);
if (savedState == null) {
fakeLoader = viewModel.loadData().subscribe();
} else {
viewModel.initFromSavedState(savedState);
}
}
private ViewState getViewStateFromBundle(Bundle savedInstance) {
if (savedInstance != null) {
return (ViewState) savedInstance.getSerializable(KEY_VIEW_STATE);
}
return null;
}
}
Aby zobaczyć wszystko w akcji, sprawdź ten przykładowy projekt .