Android
MVVM (Architectuur)
Zoeken…
Opmerkingen
Syntax-eigenaardigheden met DataBinding
Bij het binden van een viewModel-functie aan een eigenschap in xml worden bepaalde functievoorvoegsels zoals get
of is
. Bijv. ViewModel::getFormattedText
op het ViewModel wordt @{viewModel.formattedText}
wanneer het wordt @{viewModel.formattedText}
aan een eigenschap in xml. Op dezelfde manier met ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(Java Bean-notatie)
De gegenereerde bindklassen zoals ActivityMainBinding
zijn vernoemd naar de xml waarvoor ze bindingen maken, niet naar de Java-klasse.
Aangepaste bindingen
In de activity_main.xml heb ik het kenmerk textColor ingesteld op de app
en niet de naamruimte van android
. Waarom is dat? Omdat er een aangepaste setter is gedefinieerd voor het kenmerk textColor
die een ColorRes-resource-ID oplost dat door het ViewModel naar een werkelijke kleur wordt verzonden.
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);
}
}
Raadpleeg DataBinding Library: Custom Setters voor meer informatie over hoe dit werkt
Wacht ... er zit logica in je xml !!!?
Je zou kunnen beweren dat de dingen die ik doe in xml voor android:visibility
en app:textColor
zijn verkeerd / anti-patronen in de MVVM-context omdat er volgens mij app:textColor
is. Ik zou echter beweren dat het voor mij belangrijker is om Android-afhankelijkheden uit mijn ViewModel te houden om testredenen.
Trouwens, wat doet app:textColor
eigenlijk? Hiermee wordt een ressource-aanwijzer alleen omgezet in de werkelijke kleur die eraan is gekoppeld. Dus het ViewModel bepaalt nog steeds welke kleur wordt weergegeven op basis van een voorwaarde.
Wat betreft de android:visibility
voel ik vanwege de naam van de methode, het is eigenlijk prima om de ternaire operator hier te gebruiken. Vanwege de naam isLoadingVisible
en isContentVisible
er echt geen twijfel over wat elke uitkomst zou moeten oplossen in de weergave. Dus ik heb het gevoel dat het eerder een commando van het ViewModel uitvoert dan dat het viewlogica echt uitvoert.
Aan de andere kant zou ik het ermee eens zijn dat viewModel.isLoading ? View.VISIBLE : View.GONE
zou een slechte zaak zijn om te doen, omdat je aannames maakt in de weergave wat die status voor de weergave betekent.
Nuttig materiaal
De volgende bronnen hebben me enorm geholpen om dit concept te begrijpen:
- Jeremy Likness - Model-View-ViewModel (MVVM) Explained (C #) (08.2010)
- Shamlia Shukkur - Inzicht in de basisprincipes van MVVM-ontwerppatroon (C #) (03.2013)
- Frode Nilsen - Android Databinding: Goodbye Presenter, Hallo ViewModel! (07.2015)
- Joe Birch - Android naderen met MVVM (09.2015)
- Florina Muntenescu - Patronen voor Android-architectuur Deel 3: Model-View-ViewModel (10.2016)
MVVM-voorbeeld met behulp van DataBinding-bibliotheek
Het hele punt van MVVM is om lagen die logica bevatten te scheiden van de beeldlaag.
Op Android kunnen we de DataBinding-bibliotheek gebruiken om ons hierbij te helpen en het grootste deel van onze logische unit-testbaar te maken zonder ons zorgen te maken over Android-afhankelijkheden.
In dit voorbeeld laat ik de centrale componenten zien voor een domme eenvoudige app die het volgende doet:
- Bij het opstarten nep een netwerkoproep en toon een laadspinner
- Toon een weergave met een clickteller TextView, een bericht TextView en een knop om de teller te verhogen
- Klik op knop update teller en update tellerkleur en berichttekst als teller een aantal bereikt
Laten we beginnen met de viewlaag:
activity_main.xml
:
Als u niet bekend bent met hoe DataBinding werkt, moet u er waarschijnlijk 10 minuten over doen om ermee vertrouwd te raken. Zoals u kunt zien, zijn alle velden die u gewoonlijk bij de instellingen zou bijwerken, gebonden aan functies in de viewModel-variabele.
Als je een vraag hebt over de android:visibility
of app:textColor
eigenschappen, kijk dan in het gedeelte 'Opmerkingen'.
<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>
Vervolgens de modellaag. Hier heb ik:
- twee velden die de status van de app vertegenwoordigen
- getters om het aantal klikken en de staat van opwinding te lezen
- een methode om mijn aantal klikken te verhogen
- een methode om een eerdere status te herstellen (belangrijk voor oriëntatiewijzigingen)
Ook definieer ik hier een 'staat van opwinding' die afhankelijk is van het aantal klikken. Dit wordt later gebruikt om de kleur en het bericht in de weergave bij te werken.
Het is belangrijk op te merken dat er in het model geen veronderstellingen worden gedaan over hoe de status aan de gebruiker kan worden weergegeven!
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;
}
}
}
Vervolgens het ViewModel.
Hierdoor worden wijzigingen in het model geactiveerd en worden gegevens uit het model opgemaakt om ze in de weergave te tonen. Merk op dat we hier evalueren welke GUI-weergave geschikt is voor de status die door het model wordt gegeven ( resolveCounterColor
en resolveLabelText
). Dus we kunnen bijvoorbeeld gemakkelijk een UnderachieverClickerModel
implementeren dat lagere drempels heeft voor de staat van opwinding zonder code in het viewModel of view aan te raken.
Merk ook op dat het ViewModel geen verwijzingen bevat om objecten te bekijken. Alle eigenschappen zijn gebonden via de @Bindable
annotaties en bijgewerkt wanneer ofwel notifyChange()
(signalen dat alle eigenschappen moeten worden bijgewerkt) of notifyPropertyChanged(BR.propertyName)
(signalen dat deze eigenschappen moeten worden bijgewerkt) wordt bijgewerkt.
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 samenbinden in de activiteit!
Hier zien we de weergave die het viewModel initialiseert met alle afhankelijkheden die het nodig heeft, die moeten worden geïnstantieerd vanuit een Android-context.
Nadat het viewModel is geïnitialiseerd, is het gebonden aan de xml-indeling via de DataBindingUtil (controleer de sectie 'Syntaxis' voor het benoemen van gegenereerde klassen).
Op abonnementen wordt geabonneerd op deze laag omdat we moeten afhandelen wanneer ze worden onderbroken of de activiteit wordt onderbroken om geheugenlekken en NPE's te voorkomen. Ook wordt het aanhouden en opnieuw laden van de viewState op OrientationChanges hier geactiveerd
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;
}
}
Bekijk dit voorbeeldproject om alles in actie te zien.