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:

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 .



Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow