Android
MVVM (Architettura)
Ricerca…
Osservazioni
Sintassi stranezze con DataBinding
Quando si associa una funzione viewModel a una proprietà in xml, determinati prefissi di funzione come get
o is
vengono rilasciati. Per esempio. ViewModel::getFormattedText
sul ViewModel diventerà @{viewModel.formattedText}
quando lo si lega a una proprietà in xml. Analogamente con ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(notazione Java Bean)
Le classi di bind generate come ActivityMainBinding
prendono il nome dal xml per cui stanno creando i collegamenti per, non la classe java.
Binding personalizzati
In activity_main.xml ho impostato l'attributo textColor app
e non lo spazio dei nomi di android
. Perché? Poiché esiste un setter personalizzato definito per l'attributo textColor
che risolve un ID risorsa ColorRes inviato da ViewModel a un colore effettivo.
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);
}
}
Per i dettagli su come funziona controlla DataBinding Library: Custom Setters
Aspetta ... c'è una logica nel tuo xml !!!?
Si potrebbe argomentare che le cose che faccio in xml per android:visibility
e app:textColor
sono errate / anti-pattern nel contesto MVVM perché c'è una logica di visualizzazione nella mia vista. Tuttavia, direi che per me è più importante mantenere le dipendenze Android dal mio ViewModel per motivi di test.
Inoltre, cosa fa realmente app:textColor
? Risolve solo un puntatore di risorse per il colore effettivo associato ad esso. Quindi il ViewModel decide ancora quale colore viene mostrato in base ad alcune condizioni.
Per quanto riguarda l' android:visibility
che sento a causa di come viene chiamato il metodo, in realtà è ok per usare qui l'operatore ternario. Poiché il nome isLoadingVisible
e isContentVisible
non vi è alcun dubbio su cosa dovrebbe risolvere ogni risultato nella vista. Quindi ritengo che sia piuttosto l'esecuzione di un comando dato da ViewModel piuttosto che fare una logica di visualizzazione.
D'altra parte sono d'accordo che l'uso di viewModel.isLoading ? View.VISIBLE : View.GONE
sarebbe una brutta cosa da fare perché stai facendo delle supposizioni nella vista che cosa significa questo stato per la vista.
Materiale utile
Le seguenti risorse mi hanno aiutato molto nel cercare di capire questo concetto:
- Jeremy Likness - Model-View-ViewModel (MVVM) Explained (C #) (08.2010)
- Shamlia Shukkur - Capire le basi del modello di progettazione MVVM (C #) (03.2013)
- Frode Nilsen - Android Databinding: addio presentatore, Ciao ViewModel! (07.2015)
- Joe Birch - Avvicinarsi ad Android con MVVM (09.2015)
- Florina Muntenescu - Modelli di architettura Android Parte 3: Model-View-ViewModel (10.2016)
Esempio MVVM utilizzando la libreria DataBinding
L'intero punto di MVVM consiste nel separare i livelli contenenti la logica dal livello di vista.
Su Android possiamo usare la libreria DataBinding per aiutarci con questo e rendere la maggior parte della nostra unità logica testabile senza preoccuparci delle dipendenze di Android.
In questo esempio mostrerò i componenti centrali per un'app semplice e stupida che fa quanto segue:
- All'avvio simula una chiamata di rete e mostra uno spinner di caricamento
- Mostra una vista con un contatore dei clic TextView, un messaggio TextView e un pulsante per incrementare il contatore
- Sul contatore dei clic aggiorna contatore e aggiorna il colore del contatore e il testo del messaggio se il contatore raggiunge un numero
Iniziamo con il livello vista:
activity_main.xml
:
Se non hai familiarità con il funzionamento di DataBinding dovresti probabilmente impiegare 10 minuti per familiarizzarti con esso. Come puoi vedere, tutti i campi che dovresti aggiornare con i setter sono legati alle funzioni della variabile viewModel.
Se hai una domanda su android:visibility
o app:textColor
proprietà app:textColor
controlla la sezione "Note".
<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>
Successivo il livello del modello. Qui ho:
- due campi che rappresentano lo stato dell'app
- getter per leggere il numero di clic e lo stato di eccitazione
- un metodo per incrementare il conteggio dei clic
- un metodo per ripristinare alcuni stati precedenti (importante per le modifiche di orientamento)
Inoltre definisco qui uno 'stato di eccitazione' che dipende dal numero di clic. Questo verrà in seguito utilizzato per aggiornare il colore e il messaggio sulla vista.
È importante notare che non ci sono ipotesi fatte nel modello su come lo stato potrebbe essere visualizzato all'utente!
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;
}
}
}
Avanti il ViewModel.
Ciò attiverà le modifiche sul modello e formatterà i dati dal modello per mostrarli sulla vista. Si noti che è qui dove si valuta quale rappresentazione della GUI è appropriata per lo stato dato dal modello ( resolveCounterColor
e resolveLabelText
). Quindi, per esempio, potremmo facilmente implementare un UnderachieverClickerModel
che ha soglie più basse per lo stato di eccitazione senza toccare alcun codice nel viewModel o nella vista.
Si noti inoltre che ViewModel non contiene alcun riferimento per visualizzare oggetti. Tutte le proprietà sono associate tramite le annotazioni @Bindable
e aggiornate quando notifyChange()
(segnala che tutte le proprietà devono essere aggiornate) o notifyPropertyChanged(BR.propertyName)
(segnala che queste proprietà devono essere aggiornate).
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;
}
}
}
Legando tutto insieme nell'attività!
Qui vediamo la vista di inizializzare viewModel con tutte le dipendenze di cui potrebbe aver bisogno, che devono essere istanziate da un contesto Android.
Dopo che viewModel è inizializzato, è associato al layout xml tramite DataBindingUtil (si prega di controllare la sezione 'Sintassi' per la denominazione delle classi generate).
Gli abbonamenti alle note sono sottoscritti su questo livello perché dobbiamo gestire l'annullamento dell'iscrizione quando l'attività viene messa in pausa o distrutta per evitare perdite di memoria e NPE. Qui viene attivato anche il persistere e il ricaricamento della viewState su 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;
}
}
Per vedere tutto in azione controlla questo esempio di progetto .