Android
MVVM (Arquitectura)
Buscar..
Observaciones
Sintaxis con el enlace de datos
Cuando se vincula una función viewModel a una propiedad en xml, se eliminan ciertos prefijos de función como get
o is
. P.ej. ViewModel::getFormattedText
en el ViewModel se convertirá en @{viewModel.formattedText}
al enlazarlo a una propiedad en xml. De forma similar con ViewModel::isContentVisible
-> @{viewModel.contentVisible}
(notación Java Bean)
Las clases de enlace generadas como ActivityMainBinding
se nombran después del xml para el que están creando enlaces, no la clase java.
Encuadernaciones personalizadas
En el activity_main.xml establecí el atributo textColor en la app
y no el espacio de nombres de android
. ¿Porqué es eso? Debido a que hay un textColor
personalizado definido para el atributo textColor
que resuelve un ID de recurso ColorRes enviado por ViewModel a un color real.
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);
}
}
Para obtener detalles sobre cómo funciona esto, consulte la Biblioteca de DataBinding: Configuradores personalizados
Espera ... ¿hay lógica en tu xml?
Podría argumentar que las cosas que hago en xml para android:visibility
y app:textColor
son incorrectas / anti-patrones en el contexto MVVM porque hay una lógica de vista en mi opinión. Sin embargo, diría que es más importante para mí mantener las dependencias de Android fuera de mi ViewModel por razones de prueba.
Además, ¿qué hace realmente la app:textColor
? Solo resuelve un puntero de recurso al color real asociado con él. Así que el ViewModel aún decide qué color se muestra en función de alguna condición.
En cuanto a android:visibility
que siento por el nombre del método, en realidad está bien usar el operador ternario aquí. Debido al nombre isLoadingVisible
y isContentVisible
realmente no hay duda acerca de a qué se debe resolver cada resultado en la vista. Así que siento que es más bien ejecutar un comando dado por ViewModel en lugar de hacer realmente la lógica de visualización.
Por otro lado, estoy de acuerdo en que usar viewModel.isLoading ? View.VISIBLE : View.GONE
sería algo malo porque está haciendo suposiciones en la vista qué significa ese estado para la vista.
Material util
Los siguientes recursos me han ayudado mucho para tratar de entender este concepto:
- Jeremy Likness - Explicación (C #) (08.2010) de Model-View-ViewModel (MVVM)
- Shamlia Shukkur - Comprensión de los conceptos básicos del patrón de diseño de MVVM (C #) (03.2013)
- Frode Nilsen - Encuadernación de datos de Android: ¡Adiós presentador, hola ViewModel! (07.2015)
- Joe Birch - Acercándose a Android con MVVM (09.2015)
- Florina Muntenescu - Patrones de arquitectura de Android, parte 3: Modelo-Vista-Modelo de vista (10.2016)
Ejemplo de MVVM usando la biblioteca de DataBinding
El objetivo principal de MVVM es separar las capas que contienen lógica de la capa de vista.
En Android, podemos usar la biblioteca de DataBinding para ayudarnos con esto y hacer que la mayoría de nuestras unidades lógicas puedan probarse sin preocuparse por las dependencias de Android.
En este ejemplo, mostraré los componentes centrales para una aplicación simple y estúpida que hace lo siguiente:
- En el inicio simula una llamada de red y muestra un spinner de carga
- Muestre una vista con un contador de clic TextView, un mensaje TextView y un botón para incrementar el contador
- Al hacer clic en el botón actualizar contador y actualizar el color y el mensaje de texto si el contador alcanza algún número
Vamos a empezar con la capa de vista:
activity_main.xml
:
Si no está familiarizado con el funcionamiento de DataBinding, probablemente debería tomar 10 minutos para familiarizarse con él. Como puede ver, todos los campos que normalmente actualizaría con los configuradores están vinculados a funciones en la variable viewModel.
Si tiene una pregunta sobre las propiedades de android:visibility
o app:textColor
, consulte la sección 'Comentarios'.
<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>
A continuación la capa modelo. Aquí tengo:
- Dos campos que representan el estado de la aplicación.
- Captadores de leer el número de clics y el estado de emoción
- un método para incrementar mi cuenta de clics
- un método para restaurar un estado anterior (importante para cambios de orientación)
También defino aquí un 'estado de emoción' que depende del número de clics. Esto se usará más adelante para actualizar el color y el mensaje en la Vista.
Es importante tener en cuenta que no hay suposiciones en el modelo sobre cómo se puede mostrar el estado al usuario.
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;
}
}
}
Siguiente el ViewModel.
Esto activará cambios en el modelo y formateará los datos del modelo para mostrarlos en la vista. Tenga en cuenta que es aquí donde evaluamos qué representación de GUI es apropiada para el estado dado por el modelo ( resolveCounterColor
y resolveLabelText
). Entonces, por ejemplo, podríamos implementar fácilmente un UnderachieverClickerModel
que tiene umbrales más bajos para el estado de emoción sin tocar ningún código en viewModel o view.
También tenga en cuenta que ViewModel no contiene ninguna referencia para ver objetos. Todas las propiedades están vinculadas a través de las anotaciones de @Bindable
y se actualizan cuando notifyChange()
(señala que todas las propiedades deben actualizarse) o notifyPropertyChanged(BR.propertyName)
(indica que es necesario actualizar estas propiedades).
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;
}
}
}
¡Atando todo junto en la Actividad!
Aquí vemos la vista que inicializa viewModel con todas las dependencias que pueda necesitar, que deben ser instanciadas desde un contexto de Android.
Una vez que se inicializa viewModel, se vincula al diseño xml a través de DataBindingUtil (consulte la sección 'Sintaxis' para nombrar las clases generadas).
Las suscripciones a las notas están suscritas en esta capa porque tenemos que gestionar la cancelación de la suscripción cuando la actividad se detiene o se destruye para evitar pérdidas de memoria y NPE. También se activa aquí la persistencia y la recarga de viewState en 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;
}
}
Para ver todo en acción mira este proyecto de ejemplo .