Android
MVVM (Architecture)
Recherche…
Remarques
Syntaxe quirks avec DataBinding
Lors de la liaison d'une fonction viewModel à une propriété dans xml, certains préfixes de fonctions tels que get
ou is
sont supprimés. Par exemple. ViewModel::getFormattedText
sur le ViewModel deviendra @{viewModel.formattedText}
lors de la liaison à une propriété au format XML. De même avec ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(notation Java Bean)
Les classes de liaison générées, telles que ActivityMainBinding
portent le nom du fichier XML pour lequel elles créent des liaisons, et non la classe Java.
Fixations personnalisées
Dans le fichier activity_main.xml, je définis l'attribut textColor sur l' app
et non l'espace de noms android
. Pourquoi donc? Étant donné qu'un attribut personnalisé est défini pour l'attribut textColor
qui résout un ID de ressource ColorRes envoyé par ViewModel à une couleur réelle.
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);
}
}
Pour plus d'informations sur son fonctionnement, consultez DataBinding Library: Custom Setters
Attendez ... il y a de la logique dans votre xml !!!
Vous pourriez faire valoir que les choses que je fais dans XML pour android:visibility
et app:textColor
sont fausses / anti-patterns dans le contexte MVVM car il y a une logique de vue à mon avis. Cependant, je pense qu'il est plus important pour moi de garder les dépendances Android hors de mon ViewModel pour des raisons de test.
En outre, que fait vraiment app:textColor
? Il ne résout qu'un pointeur de ressource sur la couleur réelle qui lui est associée. Donc, le ViewModel décide toujours quelle couleur est affichée en fonction de certaines conditions.
En ce qui concerne l' android:visibility
me semble liée à la manière dont la méthode est nommée. Il est donc normal d'utiliser l'opérateur ternaire ici. En raison du nom isLoadingVisible
et isContentVisible
il n'y a vraiment aucun doute sur la résolution de chaque résultat dans la vue. Je pense donc qu’il s’agit plutôt d’exécuter une commande donnée par ViewModel que de faire de la logique de vue.
D'un autre côté, je suis d'accord que l'utilisation de viewModel.isLoading ? View.VISIBLE : View.GONE
serait une mauvaise chose à faire car vous faites des suppositions dans la vue de ce que cet état signifie pour la vue.
Matériel utile
Les ressources suivantes m'ont beaucoup aidé à comprendre ce concept:
- Jeremy Likness - Explication de Model-View-ViewModel (MVVM) (C #) (08.2010)
- Shamlia Shukkur - Comprendre les bases du modèle de conception MVVM (C #) (03.2013)
- Frode Nilsen - Liaison de données Android: Goodbye Presenter, Hello ViewModel! (07.2015)
- Joe Birch - Approcher Android avec MVVM (09.2015)
- Florina Muntenescu - Modèles d'architecture Android Partie 3: Model-View-ViewModel (10.2016)
Exemple MVVM utilisant la bibliothèque DataBinding
L'intérêt de MVVM est de séparer les couches contenant la logique de la couche de vue.
Sur Android, nous pouvons utiliser la bibliothèque DataBinding pour nous aider avec ceci et rendre la plus grande partie de notre logique testable sans nous soucier des dépendances Android.
Dans cet exemple, je vais montrer les composants centraux d'une application simple et stupide qui effectue les opérations suivantes:
- Au démarrage, simulez un appel réseau et affichez un compteur de chargement
- Afficher une vue avec un compteur de clics TextView, un message TextView et un bouton pour incrémenter le compteur
- Sur le bouton cliquez sur le compteur de mise à jour et mettez à jour la couleur du compteur et le texte du message si le compteur atteint un certain nombre
Commençons par la couche de vue:
activity_main.xml
:
Si vous ne connaissez pas le fonctionnement de DataBinding, vous devriez probablement prendre 10 minutes pour vous familiariser avec celui-ci. Comme vous pouvez le voir, tous les champs que vous mettrez à jour avec setters sont liés à des fonctions de la variable viewModel.
Si vous avez une question à propos de l' android:visibility
propriétés android:visibility
ou app:textColor
, consultez la section "Remarques".
<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>
Suivant la couche modèle. J'ai ici:
- deux champs qui représentent l'état de l'application
- les getters à lire le nombre de clics et l'état d'excitation
- une méthode pour incrémenter mon nombre de clics
- une méthode pour restaurer un état précédent (important pour les changements d'orientation)
Je définis également ici un «état d'excitation» qui dépend du nombre de clics. Cela sera utilisé plus tard pour mettre à jour la couleur et le message sur la vue.
Il est important de noter qu'il n'y a pas d'hypothèses dans le modèle sur la manière dont l'état peut être affiché pour l'utilisateur!
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;
}
}
}
Suivant le ViewModel.
Cela déclenchera des modifications sur le modèle et formatera les données du modèle pour les afficher dans la vue. Notez que c'est ici que nous évaluons quelle représentation graphique est appropriée pour l'état donné par le modèle ( resolveCounterColor
et resolveLabelText
). Ainsi, nous pourrions par exemple facilement implémenter un UnderachieverClickerModel
qui présente des seuils inférieurs pour l’état d’excitation sans toucher au code de viewModel ou de la vue.
Notez également que ViewModel ne contient aucune référence pour afficher les objets. Toutes les propriétés sont liées via les annotations @Bindable
et mises à jour lorsque notifyChange()
(signale que toutes les propriétés doivent être mises à jour) ou notifyPropertyChanged(BR.propertyName)
(signale que ces propriétés doivent être mises à jour).
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;
}
}
}
Lier tout ensemble dans l'activité!
Nous voyons ici la vue initialisant le viewModel avec toutes les dépendances dont il pourrait avoir besoin, qui doivent être instanciées à partir d'un contexte android.
Une fois le viewModel initialisé, il est lié à la mise en page XML par le biais de DataBindingUtil (veuillez vérifier la section "Syntaxe" pour la dénomination des classes générées).
Notez que les abonnements sont abonnés à cette couche car nous devons les désabonner lorsque l'activité est suspendue ou détruite pour éviter les fuites de mémoire et les NPE. La persistance et le rechargement de la vueState on OrientationChanges se déclenchent ici
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;
}
}
Pour voir tout ce qui se passe, consultez cet exemple de projet .