Android
MVVM (arkitektur)
Sök…
Anmärkningar
Syntax-förfrågningar med DataBinding
När binda en Viewmodel funktion till en fastighet i XML vissa funktions prefix som get
eller is
tappas. T.ex. ViewModel::getFormattedText
på ViewModel blir @{viewModel.formattedText}
när det binds till en egenskap i xml. På liknande sätt med ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(Java Bean-notation)
De genererade bindningsklasserna som ActivityMainBinding
namnges efter den xml de skapar bindningar för, inte java-klassen.
Anpassade bindningar
I Activity_main.xml ställde jag in textColor-attributet på app
och inte på android
namnområdet. Varför är det så? Eftersom det finns en anpassad setter definierad för attributet textColor
som löser ett ColorRes-resurs-ID som skickas ut av ViewModel till en faktisk färg.
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);
}
}
För information om hur detta fungerar, kolla DataBinding Library: Custom Setters
Vänta ... det finns logik i din xml !!!
Du kan hävda att de saker jag gör i xml för android:visibility
och app:textColor
är fel / antimönster i MVVM-sammanhanget eftersom det finns visningslogik enligt min åsikt. Men jag skulle hävda att det är viktigare för mig att hålla androidberoende från min ViewModel av testskäl.
Vad gör app:textColor
? Den löser bara en resurspekare till den faktiska färgen som är associerad med den. Så ViewModel bestämmer fortfarande vilken färg som ska visas baserat på något tillstånd.
När det gäller android:visibility
Jag känner på grund av hur metoden heter det är det faktiskt okej att använda den ternära operatören här. På grund av namnet isLoadingVisible
och isContentVisible
finns det verkligen ingen tvekan om vad varje utfall borde lösa i vyn. Så jag känner att det är snarare att utföra ett kommando som ges av ViewModel än att verkligen göra visningslogik.
Å andra sidan skulle jag hålla med om att använda viewModel.isLoading ? View.VISIBLE : View.GONE
skulle vara en dålig sak att göra eftersom du gör antaganden i vyn vad det tillståndet betyder för vyn.
Användbart material
Följande resurser har hjälpt mig mycket att försöka förstå detta koncept:
- Jeremy Likness - Model-View-ViewModel (MVVM) Explained (C #) (08.2010)
- Shamlia Shukkur - Förstå grunderna i MVVM-designmönster (C #) (03.2013)
- Frode Nilsen - Android-databasering: Goodbye Presenter, hej ViewModel! (07.2015)
- Joe Birch - Närmar sig Android med MVVM (09.2015)
- Florina Muntenescu - Android-arkitekturmönster Del 3: Model-View-ViewModel (10.2016)
MVVM Exempel med användning av DataBinding Library
Målet med MVVM är att separera lager som innehåller logik från visningsskiktet.
På Android kan vi använda DataBinding-biblioteket för att hjälpa oss med detta och göra det mesta av vår logiska enhet-testbar utan att oroa oss för Android-beroenden.
I det här exemplet visar jag de centrala komponenterna för en dum enkel app som gör följande:
- Vid starten startar du ett nätverkssamtal och visar en laddningsspinner
- Visa en vy med en klickräknare TextView, ett meddelande TextView och en knapp för att öka räknaren
- På knappen klickar du på uppdateringsräknare och uppdaterar räknarfärg och meddelandetext om räknaren når något nummer
Låt oss börja med vynlagret:
activity_main.xml
:
Om du inte känner till hur DataBinding fungerar bör du antagligen ta tio minuter att bekanta dig med det. Som du ser är alla fält som du vanligtvis uppdaterar med inställare bundna till funktioner i variabeln viewModel.
Om du har en fråga om android:visibility
eller app:textColor
avsnittet "Kommentarer".
<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>
Nästa modellskikt. Här har jag:
- två fält som representerar appens tillstånd
- bokstäver för att läsa antalet klick och spänningstillståndet
- en metod för att öka mitt klickantal
- en metod för att återställa vissa tidigare tillstånd (viktigt för orienteringsändringar)
Här definierar jag också ett "spänningstillstånd" som är beroende av antalet klick. Detta kommer senare att användas för att uppdatera färg och meddelande i vyn.
Det är viktigt att notera att det inte finns några antaganden i modellen om hur tillståndet kan visas för användaren!
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;
}
}
}
Nästa ViewModel.
Detta utlöser förändringar på modellen och formaterar data från modellen för att visa dem i vyn. Observera att det är här vi utvärderar vilken GUI-representation som är lämplig för det tillstånd som ges av modellen ( resolveCounterColor
och resolveLabelText
). Så vi kan till exempel enkelt implementera en UnderachieverClickerModel
som har lägre trösklar för spänningstillståndet utan att röra någon kod i viewModel eller view.
Observera också att ViewModel inte har några referenser för att visa objekt. Alla egenskaper är bundna via @Bindable
kommentarerna och uppdateras när antingen notifyChange()
(signalerar att alla egenskaper måste uppdateras) eller notifyPropertyChanged(BR.propertyName)
(signalerar att dessa egenskaper måste uppdateras).
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;
}
}
}
Binda det hela tillsammans i aktiviteten!
Här ser vi vyn som initialiserar viewModel med alla beroenden den kan behöva, som måste instanseras från ett Android-sammanhang.
När viewModel har initierats är det bundet till xml-layouten via DataBindingUtil (se "Syntax" -avsnittet för namngivning av genererade klasser).
Anteckningsabonnemang prenumereras på detta lager eftersom vi måste hantera att avbeställa dem när aktiviteten är pausad eller förstörd för att undvika minnesläckor och NPE. Här fortsätter och fortsätter och ladda om vynState on OrientationChanges
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;
}
}
För att se allt i aktion, kolla in detta exempelprojekt .