Android
MVVM (Architektur)
Suche…
Bemerkungen
Syntax-Macken mit DataBinding
Beim Binden einer viewModel-Funktion an eine Eigenschaft in xml werden bestimmte Funktionspräfixe wie get
oder is
gelöscht. Z.B. ViewModel::getFormattedText
auf dem ViewModel wird zu @{viewModel.formattedText}
wenn es an eine Eigenschaft in XML gebunden wird. Ähnlich bei ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(Java Bean-Notation)
Die generierten Bindungsklassen wie ActivityMainBinding
sind nach der XML-Datei benannt, für die sie Bindungen erstellen, nicht nach der Java-Klasse.
Kundenspezifische Bindungen
In der activity_main.xml habe ich das textColor-Attribut für die app
und nicht den android
Namespace festgelegt. Warum das? Weil für das Attribut textColor
ein benutzerdefinierter Setter definiert ist, der eine vom ViewModel textColor
ColorRes-Ressourcen-ID in eine tatsächliche Farbe auflöst.
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);
}
}
Einzelheiten zur Funktionsweise von DataBinding Library: Custom Setters finden Sie hier
Warten Sie ... es gibt Logik in Ihrer XML !!!?
Sie könnten argumentieren, dass die Dinge, die ich in XML für android:visibility
und app:textColor
falsche / Anti-Muster im MVVM-Kontext sind, da in meiner Ansicht Ansichtslogik app:textColor
. Ich würde jedoch argumentieren, dass es für mich wichtiger ist, Android-Abhängigkeiten zu Testzwecken aus meinem ViewModel herauszuhalten.
Was macht app:textColor
wirklich? Es löst nur einen Ressourcenzeiger auf die tatsächlich zugeordnete Farbe auf. Das ViewModel entscheidet also immer noch, welche Farbe aufgrund bestimmter Bedingungen angezeigt wird.
Bei android:visibility
empfinde ich aufgrund der Benennung der Methode als richtig, den ternären Operator hier zu verwenden. Aufgrund der Namen isLoadingVisible
und isContentVisible
besteht kein Zweifel daran, in was jedes Ergebnis in der Ansicht aufgelöst werden soll. Ich habe also das Gefühl, dass es eher einen Befehl ausführt, der vom ViewModel gegeben wird, als die View-Logik.
Andererseits stimme ich zu, dass viewModel.isLoading ? View.VISIBLE : View.GONE
wäre eine schlechte Sache, weil Sie Annahmen in der Ansicht machen, was dieser Zustand für die Ansicht bedeutet.
Nützliches Material
Die folgenden Ressourcen haben mir sehr geholfen, dieses Konzept zu verstehen:
- Jeremy Likness - Model-View-ViewModel (MVVM) erklärt (C #) (08.2010)
- Shamlia Shukkur - Grundlagen des MVVM-Entwurfsmusters (C #) (03.2013)
- Frode Nilsen - Android Databinding: Auf Wiedersehen Presenter, Hallo ViewModel! (07.2015)
- Joe Birch - Mit Android auf MVVM (09.2015)
- Florina Muntenescu - Android Architekturmuster Teil 3: Modellansicht- AnsichtModell (10.2016)
MVVM-Beispiel mit DataBinding-Bibliothek
Der springende Punkt von MVVM besteht darin, Layer mit Logik von der Ansichtsebene zu trennen.
Auf Android können wir die DataBinding-Bibliothek verwenden , um uns dabei zu helfen und die meisten unserer Logikeinheitstests zu testen, ohne sich über Android-Abhängigkeiten Gedanken zu machen.
In diesem Beispiel zeige ich die zentralen Komponenten für eine dumme einfache App, die Folgendes ausführt:
- Beim Start fälschen Sie einen Netzwerkanruf und zeigen einen ladenden Spinner
- Zeigen Sie eine Ansicht mit einem Klickzähler TextView, einer Nachricht TextView und einer Schaltfläche zum Erhöhen des Zählers an
- Klicken Sie auf die Schaltfläche "Zähler aktualisieren" und aktualisieren Sie die Zählerfarbe und den Nachrichtentext, wenn der Zähler eine bestimmte Anzahl erreicht
Beginnen wir mit der Ansichtsebene:
activity_main.xml
:
Wenn Sie mit der Funktionsweise von DataBinding nicht vertraut sind, sollten Sie sich wahrscheinlich zehn Minuten Zeit nehmen , um sich damit vertraut zu machen. Wie Sie sehen, sind alle Felder, die Sie normalerweise mit Setters aktualisieren würden, an Funktionen der viewModel-Variablen gebunden.
Wenn Sie eine Frage zu android:visibility
oder app:textColor
Eigenschaften haben, überprüfen Sie den Abschnitt "Anmerkungen".
<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>
Als nächstes die Modellebene. Hier habe ich:
- zwei Felder, die den Status der App darstellen
- Getter, um die Anzahl der Klicks und den Erregungszustand zu lesen
- eine Methode, um meine Klickanzahl zu erhöhen
- eine Methode zum Wiederherstellen eines früheren Zustands (wichtig für Orientierungsänderungen)
Auch definiere ich hier einen "Aufregungszustand", der von der Anzahl der Klicks abhängig ist. Dies wird später verwendet, um Farbe und Nachricht in der Ansicht zu aktualisieren.
Es ist wichtig zu beachten, dass im Modell keine Annahmen darüber gemacht werden, wie der Status für den Benutzer angezeigt werden kann!
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;
}
}
}
Als nächstes das ViewModel.
Dadurch werden Änderungen am Modell und an den Formatdaten des Modells ausgelöst, um sie in der Ansicht anzuzeigen. Beachten Sie, dass hier ausgewertet wird, welche GUI-Darstellung für den vom Modell resolveCounterColor
resolveLabelText
geeignet ist ( resolveCounterColor
und resolveLabelText
). So könnten wir beispielsweise problemlos ein UnderachieverClickerModel
implementieren, das niedrigere Schwellenwerte für den Erregungszustand aufweist, ohne Code im viewModel oder in der Ansicht zu berühren.
Beachten Sie auch, dass das ViewModel keine Referenzen auf Ansichtsobjekte enthält. Alle Eigenschaften werden über die Annotationen @Bindable
gebunden und aktualisiert, wenn entweder notifyChange()
(signalisiert, dass alle Eigenschaften aktualisiert werden müssen) oder notifyPropertyChanged(BR.propertyName)
(bedeutet, dass diese Eigenschaften aktualisiert werden müssen).
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;
}
}
}
Alles in der Aktivität zusammen binden!
Hier sehen wir die Ansicht, die das viewModel mit allen Abhängigkeiten initialisiert, die es möglicherweise benötigt, die aus einem Android-Kontext instanziiert werden müssen.
Nachdem das viewModel initialisiert wurde, wird es über das DataBindingUtil an das XML-Layout gebunden. (Benennen Sie die generierten Klassen bitte im Abschnitt "Syntax").
Hinweis Abonnements werden auf dieser Ebene abonniert, da das Abbestellen der Abonnements erforderlich ist, wenn die Aktivität angehalten oder zerstört wird, um Speicherlecks und NPEs zu vermeiden. Hier wird auch das persistierende und erneute Laden des viewState bei OrientationChanges ausgelöst
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;
}
}
Um alles in Aktion zu sehen, schauen Sie sich dieses Beispielprojekt an .