Model-View-ViewModel

Esta práctica es el primer comienzo con la arquitectura Model-View-ViewModel, recomendada para desarrollar apps.

Exploraremos los beneficios que proporciona esta arquitectura, y los problemas que resuelve.

Usaremos las clases ViewModel y LiveData.

Desarrollaremos una app que consiste en una Calculadora de la cuota de hipoteca:

https://github.com/gerardfp/mvvm

Crea el proyecto

Simulador Hipoteca

La app que vamos a desarrollar consiste en que el usuario introduce el capital que solicita y el plazo de devolución y la app determina el interés y calcula la cuota mensual por el método francés.

La siguiente clase SimuladorHipoteca implementa esta funcionalidad base de la aplicación.

La clase SimuladorHipoteca tiene el método calcular que recibe como parámetro un objeto Solicitud con los campos capital y plazo solicitados, y calcula la cuota en función de un interés determinado.

Para esta demo se establece un interes arbitrario de 0.01605. Se ha añadido una pausa (sleep()) para simular la obtención del interés mediante una operación de larga duración, como el acceso a base de datos, a un servidor, etc.

Crea esta clase:

SimuladorHipoteca.java public class SimuladorHipoteca { public static class Solicitud { public double capital; public int plazo; public Solicitud(double capital, int plazo) { this.capital = capital; this.plazo = plazo; } } public double calcular(Solicitud solicitud) { double interes = 0; try { Thread.sleep(10000); // simular operacion de larga duracion (10s) interes = 0.01605; } catch (InterruptedException e) {} return solicitud.capital*interes/12/(1-Math.pow(1+(interes/12),-solicitud.plazo*12)); } }

En una aplicación típica de consola, estas dos clases se podrían utilizar así:

Scanner scanner = new Scanner(System.in); SimuladorHipoteca simulador = new SimuladorHipoteca(); System.out.println("Introduzca el capital:"); double capital = scanner.nextDouble(); System.out.println("Introduzca el plazo:"); int plazo = scanner.nextInt(); SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); double cuota = simulador.calcular(solicitud); System.out.println("Cuota: " + String.format("%.2f", cuota)); Introduzca el capital: 100000 Introduzca el plazo: 15 Cuota: 625.48

Bloqueos, pérdida de datos y Memory Leaks

Cuando se desarrolla un app Android, un error común es escribir todo el código en una Activity o Fragment.

Por ejemplo, en el método onViewCreated() de la clase MiHipotecaFragment podríamos estar tentados a hacer lo siguiente:

MiHipotecaFragment.java / onViewCreated() binding.calcular.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { SimuladorHipoteca simuladorHipoteca = new SimuladorHipoteca(); double capital = Double.parseDouble(binding.capital.getText().toString()); int plazo = Integer.parseInt(binding.plazo.getText().toString()); SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); double cuota = simuladorHipoteca.calcular(solicitud); binding.cuota.setText(String.format("%.2f",cuota)); } });

La intención de este código es que cuando se hace click en el botón calcular, se obtienen el capital y el plazo introducidos por el usuario, se llama al método calcular() del simulador y se muestra la cuota obtenida.

Bloqueo

Si ejecutas la app, tal vez parezca que funciona correctamente. Sin embargo hay varios problemas.

El primero es que se ha bloqueado la Interfaz de Usuario (View) durante todo el tiempo que se ha estado ejecutando el método calcular().

Debido al bloqueo de la UI, no habríamos podido mostrar, por ejemplo, una barra de progreso, o tal vez dar al usuario la opción de cancelar la operación... Es más, si la operación tarda más de 5 segundos y el usuario trata de interactuar entre tanto con la Interfaz de Usuario, el sistema Android le mostrará el infame diálogo de "la aplicación no responde".

No se deben realizar operaciones de larga duración en el thread de la Interfaz de Usuario.

Una solución que se utilitzó de forma bastante extendida durante algún tiempo fue el uso de la clase AsyncTask, para ejecutar las tareas de larga duración en segundo plano. Solía ser más o menos así:

MiHipotecaFragment.java / onViewCreated() binding.calcular.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { double capital = Double.parseDouble(binding.capital.getText().toString()); int plazo = Integer.parseInt(binding.plazo.getText().toString()); SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); new AsyncTask<SimuladorHipoteca.Solicitud, Void, Double>(){ @Override protected Double doInBackground(SimuladorHipoteca.Solicitud... solicitudes) { SimuladorHipoteca simulador = new SimuladorHipoteca(); return simulador.calcular(solicitudes[0]); } @Override protected void onPostExecute(Double cuota) { super.onPostExecute(cuota); binding.cuota.setText(String.format("%.2f",cuota)); } }.execute(solicitud); } });

La intención de este código es ejecutar el método calcular() en una tarea en segundo plano, y actualizar el TextView con la cuota obtenida cuando finalice la tarea.

De esta forma se previene el bloqueo de la interfaz de usuario. Si ejcutas ahora la app, verás que la interfaz de usuario sigue respondiendo aún cuando se está ejecutando la tarea de larga duración calcular().

Pero esta solución tampoco resuelve todos los problemas.

Pérdida de datos

Un comportamiento característico de Android es que cuando el usuario rota la pantalla del móvil, el sistema destruye la Activity en ejecución, y crea una nueva con la nueva configuración de pantalla. Con esto, todas las vistas (Fragments, widgets, etc.) agregadas y todas las variables de la instancia Activity destruída también se pierden. Además, el layout de la Activity vuelve al punto inicial, tal como está definido el xml.

Así que debido a esto, ninguna de las dos soluciones anteriores resuelve el problema de la pérdida de datos.

Debes considerar que la Interfaz de Usuario no te pertenece y que el sistema android puede hacer con ella lo que le parezca oportuno.

Memory Leaks

Vamos ahora el caso de que el usuario rote la pantalla justo cuando se está realizando el cálculo.

Con el programa sin AsyncTask, el comportamiento de la app será errático, ya que Android, no puede ni destruir la Activity ni crear una nueva hasta que haya finalizado la tarea calcular().

Con el programa con AsyncTask, ocurre lo siguiente: cuando se rota la pantalla en medio de la ejecución de la AsyncTask, Android tratará de destruir la MainActivity en ejecución (hay que tener en cuenta que el fragment MiHipotecaFragment está dentro de la MainActivity). Sin embargo no podrá hacerlo ya que la AsyncTask mantiene una referencia a la variable binding.cuota que pertenece a la MainActivity. Así que Android creará una nueva MainActivity sin haber podido destruir la anterior, con lo cual esta quedará ocupando innecesariamente memoria ram del sistema. Esto es un Memory Leak.

Además, cuando finalice la AsyncTask, esta pondrá la cuota resultante en el TextView de la MainActivity que debería haber sido destruida y que ha quedado perdida en la memoria, y por tanto, nunca se llegará a mostrar en el TextView de la nueva MainActivity en ejecución.

No hay que mantener referencias a elementos de la Interfaz de Usuario en las tareas en segundo plano.

Para paliar estos problemas, han ido apareciendo de forma constante diferentes procedimientos que involucraban AsyncTasks estáticas, WeakReferences, anulaciones de los métodos del ciclo de vida, etc, etc, todas ellas con sus ventajas e inconvenientes. Actualmente, parece que la solución más aceptada es implementar la arquitectura MVVM que veremos a continuación.

Arquitectura MVVM

La arquitectura Model-View-ViewModel se basa en el principio de Separación de intereses, dividiendo el código de la app en tres categorías:

Cuando la View ha de realizar alguna acción sobre los datos (cálculo, consulta, modificación, etc...) no lo hace directamente sobre el Model, sino que usa el ViewModel como intermediario. El ViewModel traslada la acción al Model. El Model responde al ViewModel con el resultado de la acción (datos o errores), y el ViewModel la transfiere de vuelta a la View. En las apps Android implementaremos el ViewModel utilizando la clase AndroidViewModel.

El punto clave para el desacoplamiento de la View del resto de componentes de la app, y evitar así los problemas vistos en el punto anterior, es que el ViewModel no traslada directamente el resultado de las acciones a la View, sino que es la View la que observa el resultado. El mecanismo de observación implica que el ViewModel notifica el resultado a la View, solo si la View está en ejecución. Para implementar la observación utilizaremos la clase LiveData.

Así pues, la comunicación entre estos componentes suele ser así:

Otro aspecto clave es que el ViewModel sobrevive a la rotación de pantalla, con lo cual los datos almacenados en el ViewModel no se pierden cuando Android destruye la View y la vuelve a crear.

Implementación MVVM

Para implementar un ViewModel en Android hay que crear una clase y hacer que extienda de la clase AndroidViewModel.

En esta app llamaremos a esta clase MiHipotecaViewModel. En ella hacemos lo siguiente:

El código del ViewModel queda así:

MiHipotecaViewModel.java public class MiHipotecaViewModel extends AndroidViewModel { Executor executor; SimuladorHipoteca simulador; MutableLiveData<Double> cuota = new MutableLiveData<>(); public MiHipotecaViewModel(@NonNull Application application) { super(application); executor = Executors.newSingleThreadExecutor(); simulador = new SimuladorHipoteca(); } public void calcular(double capital, int plazo) { final SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); executor.execute(new Runnable() { @Override public void run() { double cuotaResultante = simulador.calcular(solicitud); cuota.postValue(cuotaResultante); } }); } }

La View (MiHipotecaFragment) por su parte realiza lo siguiente:

MiHipotecaFragment.java public class MiHipotecaFragment extends Fragment { private FragmentMiHipotecaBinding binding; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return (binding = FragmentMiHipotecaBinding.inflate(inflater, container, false)).getRoot(); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); final MiHipotecaViewModel miHipotecaViewModel = new ViewModelProvider(this).get(MiHipotecaViewModel.class); binding.calcular.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { double capital = Double.parseDouble(binding.capital.getText().toString()); int plazo = Integer.parseInt(binding.plazo.getText().toString()); miHipotecaViewModel.calcular(capital, plazo); } }); miHipotecaViewModel.cuota.observe(getViewLifecycleOwner(), new Observer<Double>() { @Override public void onChanged(Double cuota) { binding.cuota.setText(String.format("%.2f",cuota)); } }); } }

El siguiente diagrama ilustra la comunicación entre los 3 componentes de la app:

La clave de porqué el ViewModel resuelve el problema de la rotación de pantalla está en el parámetro getViewLifecycleOwner() pasado al método observe, que asegura que la notificación del resultado (mediante la llamada a onChanged()), solo se efectuará si la View (el fragment) todavía está en ejecución cuando se obtenga el resultado.

Supongamos que el usuario pulsa el botón de calcular con el móvil en vertical, y mientras se está efectuando el cálculo rota el móvil. El fragment que originalmente realizó la acción calcular() y observaba el resultado ya no estará en ejecución y no será notificado del resultado. Sin embargo el nuevo fragment que se ha creado y que también observa el resultado sí será notificado.

Callbacks

El mecanismo de callback consiste en que cuando se llama a un método se le pasa un objeto en el que están definidos los métodos a los que tiene que llamar para devolver el resultado. En los métodos de ese objeto se define qué hacer con el resultado.

El siguiente ejemplo muestra la similitud entre devolver datos con return y hacerlo mediante callbacks.

class EjemploCallback { public void main(){ int resultado = metodoConReturn(10000); System.out.println(resultado); metodoConCallback(10000, new Callback() { @Override public void alRetornar(int resultado) { System.out.println(resultado); } }); } // return int metodoConReturn(int datos){ int r = datos*datos; return r; } // callback interface Callback { void alRetornar(int resultado); } void metodoConCallback(int datos, Callback callback){ int r = datos*datos; callback.alRetornar(datos*datos); } }

La comunicación entre el ViewModel y el Model se realiza mediante llamadas y llamadas de vuelta (callbacks). En las llamadas de vuelta, se informa del resultado y también del progreso y los errores. El ViewModel se los notifica a la Vista mediante variables LiveData.

Cambia la clase SimuladorHipoteca para que comunique el resultado mediante un callback:

SimuladorHipoteca.java public class SimuladorHipoteca { public static class Solicitud { public double capital; public int plazo; public Solicitud(double capital, int plazo) { this.capital = capital; this.plazo = plazo; } } interface Callback { void cuandoEsteCalculadaLaCuota(double cuota); } public void calcular(Solicitud solicitud, Callback callback) { double interes = 0; try { Thread.sleep(2500); // long run operation interes = 0.01605; } catch (InterruptedException e) {} callback.cuandoEsteCalculadaLaCuota(solicitud.capital*interes/12/(1-Math.pow(1+(interes/12),-solicitud.plazo*12))); } }

Cambia el ViewModel para que reciba en un callback la cuota resultante:

public class MiHipotecaViewModel extends AndroidViewModel { Executor executor; SimuladorHipoteca simulador; MutableLiveData<Double> cuota = new MutableLiveData<>(); public MiHipotecaViewModel(@NonNull Application application) { super(application); executor = Executors.newSingleThreadExecutor(); simulador = new SimuladorHipoteca(); } public void calcular(double capital, int plazo) { final SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); executor.execute(new Runnable() { @Override public void run() { simulador.calcular(solicitud, new SimuladorHipoteca.Callback() { @Override public void cuandoEsteCalculadaLaCuota(double cuotaResultante) { cuota.postValue(cuotaResultante); } }); } }); } }

Puede parecer que el mecanismo de callback no aporta nada respecto al return, pero en los siguientes apartados veremos que ofrece más versatilidad, ya que en el callback se pueden definir distintos métodos no solo para tratar el resultado, sino también los errores y el proceso.

Desde una aplicación típica de consola, el SimuladorHipoteca versión callback se podría utilizar así:

import java.util.Scanner; class SimuladorHipoteca { public static class Solicitud { public double capital; public int plazo; public Solicitud(double capital, int plazo) { this.capital = capital; this.plazo = plazo; } } interface Callback { void cuandoEsteCalculadaLaCuota(double cuota); } public void calcular(Solicitud solicitud, Callback callback) { double interes = 0; try { Thread.sleep(2500); // long run operation interes = 0.01605; } catch (InterruptedException e) {} double cuota = solicitud.capital*interes/12/(1-Math.pow(1+(interes/12),-solicitud.plazo*12)); callback.cuandoEsteCalculadaLaCuota(cuota); } } public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); SimuladorHipoteca simulador = new SimuladorHipoteca(); System.out.println("Introduzca el capital:"); double capital = scanner.nextDouble(); System.out.println("Introduzca el plazo:"); int plazo = scanner.nextInt(); SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); simulador.calcular(solicitud, new SimuladorHipoteca.Callback() { @Override public void cuandoEsteCalculadaLaCuota(double cuota) { System.out.println("Cuota: " + String.format("%.2f", cuota)); } }); } }

Gestión de errores

Vamos a suponer que el SimuladorHipoteca además de detrminar el interés, también establece un capital mínimo y un plazo mínimo (estos valores se podrían sacar del servidor de un banco, por ejemplo).

Ahora, cuando se llame al método calcular() del simulador, en lugar de informar únicamente cuando esté calculada la cuota, debe informar de los errores cuando el capital o el plazo solicitados sean inferiores a los valores mínimos. Solo en caso de que sean válidos debe calcular la cuota y notificarla.

Para ello, añadiremos dos métodos en el callback, que además de decir si el capital o plazo son inferiores al mínimo, informarán de cuales son esos mínimos en cada caso.

SimuladorHipoteca.java public class SimuladorHipoteca { public static class Solicitud { public double capital; public int plazo; public Solicitud(double capital, int plazo) { this.capital = capital; this.plazo = plazo; } } interface Callback { void cuandoEsteCalculadaLaCuota(double cuota); void cuandoHayaErrorDeCapitalInferiorAlMinimo(double capitalMinimo); void cuandoHayaErrorDePlazoInferiorAlMinimo(int plazoMinimo); } public void calcular(Solicitud solicitud, Callback callback) { double interes = 0; double capitalMinimo = 0; int plazoMinimo = 0; try { Thread.sleep(2500); // long run operation interes = 0.01605; capitalMinimo = 1000; plazoMinimo = 2; } catch (InterruptedException e) {} boolean error = false; if (solicitud.capital < capitalMinimo) { callback.cuandoHayaErrorDeCapitalInferiorAlMinimo(capitalMinimo); error = true; } if (solicitud.plazo < plazoMinimo) { callback.cuandoHayaErrorDePlazoInferiorAlMinimo(plazoMinimo); error = true; } if(!error) { callback.cuandoEsteCalculadaLaCuota(solicitud.capital * interes / 12 / (1 - Math.pow(1 + (interes / 12), -solicitud.plazo * 12))); } } }

Por su parte, el ViewModel cuando llama al método calcular() del Model, define en el callback lo que hay que hacer cuando ocurran estos errores. Obviamente, tiene que informar a la View para que los muestre al usuario. Para comunicarse con la View, el ViewModel usa variables LiveData, así que definiremos dos variables para informar a la View si hay un error en el capital y/o en el plazo.

Estas variables serán de clase Double para el capital y de clase Integer para el plazo. Si el valor de estas variables es null, la View entenderá que no ha habido un error, y en caso contrario el valor de estas variables será el valor mínimo que permite el banco para el capital y plazo. Así que cuando el Model notifique que se ha calculado el resultado, las pondremos a null (no ha habido error), y cuando notifique los errores pondremos el valor mínimo que nos pase.

MiHipotecaViewModel.java public class MiHipotecaViewModel extends AndroidViewModel { Executor executor; SimuladorHipoteca simulador; MutableLiveData<Double> cuota = new MutableLiveData<>(); MutableLiveData<Double> errorCapital = new MutableLiveData<>(); MutableLiveData<Integer> errorPlazos = new MutableLiveData<>(); public MiHipotecaViewModel(@NonNull Application application) { super(application); executor = Executors.newSingleThreadExecutor(); simulador = new SimuladorHipoteca(); } public void calcular(double capital, int plazo) { final SimuladorHipoteca.Solicitud solicitud = new SimuladorHipoteca.Solicitud(capital, plazo); executor.execute(new Runnable() { @Override public void run() { simulador.calcular(solicitud, new SimuladorHipoteca.Callback() { @Override public void cuandoEsteCalculadaLaCuota(double cuotaResultante) { errorCapital.postValue(null); errorPlazos.postValue(null); cuota.postValue(cuotaResultante); } @Override public void cuandoHayaErrorDeCapitalInferiorAlMinimo(double capitalMinimo) { errorCapital.postValue(capitalMinimo); } @Override public void cuandoHayaErrorDePlazoInferiorAlMinimo(int plazoMinimo) { errorPlazos.postValue(plazoMinimo); } }); } }); } }

Por último la View solo tiene que observar las LiveData de los errores y mostrárselos u ocultárselos al usuario:

MiHipotecaFragment.java miHipotecaViewModel.errorCapital.observe(getViewLifecycleOwner(), new Observer<Double>() { @Override public void onChanged(Double capitalMinimo) { if (capitalMinimo != null) { binding.capital.setError("El capital no puede ser inferor a " + capitalMinimo + " euros"); } else { binding.capital.setError(null); } } }); miHipotecaViewModel.errorPlazos.observe(getViewLifecycleOwner(), new Observer<Integer>() { @Override public void onChanged(Integer plazoMinimo) { if (plazoMinimo != null) { binding.plazo.setError("El plazo no puede ser inferior a " + plazoMinimo + " años"); } else { binding.plazo.setError(null); } } });

Un error que sí puede gestionar la vista es que los datos introducidos sean del tipo correcto:

binding.calcular.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { boolean error = false; double capital = 0; int plazo = 0; try { capital = Double.parseDouble(binding.capital.getText().toString()); } catch (Exception e){ binding.capital.setError("Introduzca un número"); error = true; } try { plazo = Integer.parseInt(binding.plazo.getText().toString()); } catch (Exception e){ binding.plazo.setError("Introduzca un número"); error = true; } if (!error) { miHipotecaViewModel.calcular(capital, plazo); } } });

Progreso

Añadiremos a la Vista una ProgressBar circular para indicar al usuario que se está calculando la cuota.

Sustituye el <TextView> de la cuota por este <FrameLayout>, que incluye la cuota y una <ProgressBar> (el FrameLayout hará que estén superpuestos):

res/layout/fragment_mi_hipoteca.xml <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/cuota" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="48sp" android:gravity="center"/> <ProgressBar android:id="@+id/calculando" android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" android:gravity="center"/> </FrameLayout>

Observa que el ProgressBar tiene el atributo android:visibility="gone" que hará que no se muestre. Lo mostraremos y volveremos a ocultar des de el código Java cuando sea conveniente.

Añadimos dos métodos al Callback, uno para notificar cuándo empieza el cálculo y otro para cuando finaliza.

El Model notificará al Viewmodel llamando a estos métodos al principio y al final del método calcular():

SimuladorHipoteca.java interface Callback { // resto de métodos void cuandoEmpieceElCalculo(); void cuandoFinaliceElCalculo(); } public void calcular(Solicitud solicitud, Callback callback) { callback.cuandoEmpieceElCalculo(); // resto de sentencias callback.cuandoFinaliceElCalculo(); }

El ViewModel utilizará una variable LiveData para comunicar a la View de cuándo ha empezado y finalizado el cálculo. Pondrá en ella el valor true o false, segun le notifique el Model:

MiHipotecaViewModel.java // ... MutableLiveData<Boolean> calculando = new MutableLiveData<>(); // ... @Override public void cuandoEmpieceElCalculo() { calculando.postValue(true); } @Override public void cuandoFinaliceElCalculo() { calculando.postValue(false); }

La View alternará entre mostrar/ocultar el ProgressBar y el TextView acorde al valor que observe en la variable:

MiHipotecaFragment.java miHipotecaViewModel.calculando.observe(getViewLifecycleOwner(), new Observer<Boolean>() { @Override public void onChanged(Boolean calculando) { if (calculando) { binding.calculando.setVisibility(View.VISIBLE); binding.cuota.setVisibility(View.GONE); } else { binding.calculando.setVisibility(View.GONE); binding.cuota.setVisibility(View.VISIBLE); } } });

Práctica

Implementa una Vista que realice una acción sobre un Modelo.

La Vista tiene que obtener datos del usuario, y el Modelo tiene que usarlos para realizar la acción.

El Modelo debe imponer algún tipo de restricción sobre los datos.

La Vista debe mostrar el resultado de la acción, y notificar de los errores y el progreso.