ViewModel, LiveData, Koin y el paradigma MVVM

Hace ahora más de dos años que escribí por primera vez sobre el patrón de presentación MVP y Clean Architecture, hoy, en cambio, tras haber investigado durante los últimos meses los Android Architecture Components, escribiré sobre el patrón MVVM y mis lecciones aprendidas desde entonces.

He tomado como referencia el proyecto sobre el que basé aquellos artículos, y lo he refactorizado utilizando los Architecture Components y Koin.

Presenter -> ViewModel

La primera diferencia entre ambos patrones es que MVP se basa en un paradigma imperativo (la vista comunica eventos al presenter, el presenter solicita acciones a la vista) mientras que MVVM se basa en la observación (el ViewModel expone datos observables, la vista se suscribe y escucha dichos datos).

Surge aquí la primera ventaja de MVVM: nuestro ViewModel no necesita una instancia de la vista, ya que es ésta la que se suscribe a los datos que expone el ViewModel. Rompemos así cualquier ligazón entre ambas, y nos evitamos la consabida problemática del memory leak al intentar hacer uso de la representación de una clase (Activity/Fragment) que puede estar ya destruida.

mvp vs mvvm

La segunda ventaja importante tiene que ver con el ciclo de vida. El ViewModel es una clase que convive con el ciclo de vida de la Activity/Fragment a la que está ligado. Por lo tanto, es resistente a cambios de configuración, manteniendo así el estado de sus datos.

viewmodel scope
Esta imagen pertenece a https://developer.android.com

LiveData

LiveData es una clase de retención de datos observable. Está optimizada para respetar el ciclo de vida del objeto de tipo LifecycleOwner que sus observers le envían por parámetro (y que normalmente suele ser una Activity o un Fragment).

Esto se traduce en que solo notificará cuando sus observadores estén activos, es decir cuando pasen a un estado STARTED o RESUMED. Con esto nos aseguramos de no enviar información a un receptor que no se encuentre visible.

class MovieListViewModel(
  private val executor: InteractorExecutor,
  private val getMovies: GetMovies,
  private val movieModelFactory: MovieModelFactory
) : ViewModel() {

  private val _movieModels = MutableLiveData<Resource<List<MovieModel>>>()

  val movieModels: LiveData<Resource<List<MovieModel>>>
    get() = _movieModels

  private fun load() {
    _movieModels.value = Resource.loading()
    executor(
      interactor = getMovies,
      request = GetMovies.Request(false),
      onError = {
        _movieModels.value = Resource.error(it)
      },
      onSuccess = {
        _movieModels.value =
          Resource.success(movieModelFactory.createMovieModels(it))
      }
    )
  }
}

class MovieListActivity : AppCompatActivity() {

  // Code...

  private fun setUpViewModel() {
    viewModel.movieModels.observe(this, Observer { moviesResource ->
      when (moviesResource.status) {
        Status.LOADING -> {
          if (!refreshLayout.isRefreshing)
            progressBar.visibility = View.VISIBLE
        }
        Status.ERROR -> {
          cancelLoadingViews()
          showErrorMessage(moviesResource.exception?.message!!)
        }
        Status.SUCCESS -> {
          cancelLoadingViews()
          showMovies(moviesResource.data!!)
        }
      }
    })
    viewModel.load()
  }

  // Code...
}

Cabe destacar del primer ejemplo de código que los datos expuestos están envueltos en una clase, Resource, que nos permite dotarlos de estado. Algo muy útil ya que nos brinda la opción de saber si los datos a los que nos hemos suscrito se están cargando, si ha ido todo bien o si ha habido algún problema.

Por otro lado, como podemos ver en el código de la Activity, al subscribirnos a un objeto LiveData necesitamos 2 parámetros: un objeto de tipo LifecycleOwner, o lo que es lo mismo, una clase que tenga un ciclo de vida Android (por defecto, las Activities y Fragments son de este tipo), y un objeto de tipo Observer, que se encargará de recibir los eventos.

Testing

Para testear nuestros ViewModels necesitaremos declarar una Rule, InstantTaskExecutorRule, que básicamente se encargará de que durante la ejecución de nuestros tests los valores de tipo LiveData sean actualizados en el mismo hilo de llamada.

class MovieListViewModelTest {

  @get:Rule
  var rule: TestRule = InstantTaskExecutorRule()

  private val getMovies = mock<GetMovies>()
  private val  movieModelFactory = mock<MovieModelFactory>()

  private val observer = mock<Observer<Resource<List<MovieModel>>>>()

  private lateinit var viewModel: MovieListViewModel

  @Before
  fun setUp() {
    viewModel = MovieListViewModel(
      executor = SyncInteractorExecutor(),
      getMovies = getMovies,
      movieModelFactory = movieModelFactory
    )
    viewModel.movieModels.observeForever(observer)
  }

  @Test
  fun `should shows movies when interactor returns correct data`() {
    val movies = listOf<Movie>(mock())
    val movieModels = listOf<MovieModel>(mock())
    given(getMovies.invoke(any())).willReturn(Either.right(movies))
    given(movieModelFactory.createMovieModels(movies)).willReturn(movieModels)

    viewModel.load()

    verify(observer).onChanged(Resource.loading())
    verify(observer).onChanged(Resource.success(movieModels))
  }
}

Muy importante es también la función observeForever(), que se utiliza cuando necesitamos registrar un observador sin un objeto de tipo LyfecycleOwner, es decir, sin una Activity o un Fragment.

Factorías para los modelos de presentación

Cómo tip extra en este apartado, me gustaría reseñar que he encontrado muy útil delegar la lógica de creación de los modelos de presentación en factorías, separándola así de la lógica de presentación.

Como «frontenders» es bastante probable que buena parte de nuestra lógica se deposite en cómo creamos los modelos de presentación (filtros, ordenaciones…), de ahí que puede sernos muy útil el hecho de que ambas puedan evolucionar por separado.

Dagger -> Koin

Ya he hablado anteriormente acerca de la inyección de dependencias y el uso de Dagger. Pues bien, Koin es un framework de inyección de dependencias que busca el mismo objetivo que Dagger pero que además es mucho más fácil de implementar y se integra perfectamente con el ecosistema Android y los ViewModel.

val appModule = module {

  single<SQLiteOpenHelper> { MoviesDatabaseHelper(androidContext()) }

  single<MovieService> {
    Retrofit.Builder()
      .baseUrl("https://api.themoviedb.org/3/")
      .addConverterFactory(GsonConverterFactory.create())
      .build()
      .create<MovieService>(MovieService::class.java)
  }

  single<MoviesLocalDataSource> { MoviesLocalDataSourceImpl(sqLiteOpenHelper = get()) }

  single<MoviesRemoteDataSource> { MoviesRemoteDataSourceImpl(movieService = get()) }

  single<MoviesRepository> {
    MoviesRepositoryImpl(
      localDataSource = get(),
      remoteDataSource = get(),
      movieMapper = MovieMapper()
    )
  }

  single<InteractorExecutor> {
    AsyncInteractorExecutor(
      runOnBgThread = BackgroundRunner(),
      runOnMainThread = MainRunner()
    )
  }

  single { Formatter() }
}

La implementación más común de Koin se suele basar en un módulo principal, que contiene aquellas dependencias (normalmente Singleton) que queremos pervivir a lo largo de todo el ciclo de vida de la aplicación, más un módulo por cada feature, donde alojaremos las dependencias que afecten de un modo concreto a una determinada funcionalidad.

val listModule = module {
  factory { GetMovies(repository = get()) }
  factory { MovieModelFactory() }
  viewModel {
    MovieListViewModel(
      executor = get(),
      getMovies = get(),
      movieModelFactory = get()
    )
  }
}

Cómo veis, la declaración de las dependencias queda muy limpia, además, sus palabras clave resultan bastante intuitivas:

  • single -> crea una instancia Singleton.
  • factory -> crea una instancia nueva cada vez que es requerida.
  • viewModel -> crea una instancia del ViewModel, y nos abstrae así de la utilización de ViewModelProvider.
  • get -> infiere una dependencia.

Por último, solo nos quedaría lanzar en el método onCreate() de nuestra clase Application el método startKoin() con los módulos que queremos inyectar.

class MoviesApp : Application() {

  override fun onCreate() {
    super.onCreate()
    startKoin {
      androidLogger()
      androidContext(this@MoviesApp)
      modules(listOf(
        appModule,
        listModule,
        detailModule
      ))
    }
  }
}

Y ya podríamos recibir la dependencia o dependencias en nuestra Activity o Fragment a través de inject() o viewModel().

import org.koin.android.viewmodel.ext.android.viewModel

class MovieListActivity : BaseActivity() {

  private val viewModel: MovieListViewModel by viewModel()

  // Code...
}

Fuente única de verdad

Antes de terminar el artículo me gustaría hacer una reseña sobre el concepto Single-Source-Of-Truth (SSOT). Este principio viene a decir que para mantener la consistencia de los datos mostrados debemos optar por asignar una fuente de datos como «fuente única de verdad». Esto se puede conseguir de muchas maneras, pero si optáis por delegar ese flujo en una clase abstracta, la implementación de vuestros repositorios se simplificará bastante.

abstract class PrefetchLocalData<RequestType, ResultType> {

  fun load(): Either<Exception, ResultType> {
    val localData = loadFromLocal()
    return if (shouldFetch(localData)) {
      val serviceData = loadFromService()
      if (serviceData.isRight) {
        saveServiceResult(serviceData.rightValue)
        Either.Right(loadFromLocal()!!)
      } else {
        Either.Left(serviceData.leftValue)
      }
    } else {
      Either.Right(localData!!)
    }
  }

  abstract fun loadFromLocal(): ResultType?

  abstract fun shouldFetch(data: ResultType?): Boolean

  abstract fun loadFromService(): Either<Exception, RequestType>

  abstract fun saveServiceResult(item: RequestType)
}

Como podéis ver, esta clase se focaliza en que la fuente local de datos sea siempre el punto de retorno de los datos. Esto nos ayuda a unificar el flujo de obtención de datos de nuestros repositorios cuando necesitemos trabajar con más de una fuente de datos.

class MoviesRepositoryImpl(
  private val localDataSource: MoviesLocalDataSource,
  private val remoteDataSource: MoviesRemoteDataSource,
  private val movieMapper: MovieMapper
) : MoviesRepository {

  override fun getMovies(onlyOnline: Boolean): Either<Exception, List<Movie>> {
    return object : PrefetchLocalData<List<MovieEntity>, List<Movie>>() {
      override fun loadFromLocal() = movieMapper.transform(localDataSource.getMovies())

      override fun shouldFetch(data: List<Movie>?) = (onlyOnline || data.isNullOrEmpty())

      override fun loadFromService() = remoteDataSource.getMovies()

      override fun saveServiceResult(item: List<MovieEntity>) = saveData(item)
    }.load()
  }

  private fun saveData(movieEntityList: List<MovieEntity>) {
    localDataSource.deleteAllMovies()
    localDataSource.saveMovies(movieEntityList)
  }

  // Code...
}

Conclusión

MVVM es un patrón arquitectónico diferente de MVP, ni mejor ni peor. Es cierto que la clase ViewModel, como parte de los Architecture Components, ofrece una serie de ventajas a la hora de lidiar con el ciclo de vida de nuestras Activities y nuestros Fragments, pero esto no quiere decir que nos tenemos que lanzar como pollos sin cabeza, renegando de nuestra arquitectura anterior. Al fin y al cabo, los Architecture Components no dejan de ser un framework, y debemos recordar siempre que Clean Architecture no va de dar importancia a los frameworks, sino de todo lo contrario. 😉

Código fuente

Enlaces de referencia