De ViewModel a Compose Presenter

Compartir esta publicación

Comúnmente, gestionamos la lógica del estado en un android ViewModel, aplicando MVI o MVVM y es posible que combinemos una serie de datos asíncronos para crear el estado de la vista. Algunos de estos datos no cambian, algunos están disponibles de inmediato y otros cambian con el tiempo.

Sin embargo, es importante tener en cuenta que la tarea de combinar flujos reactivos se vuelve más compleja a medida que aumentan las fuentes de datos y la lógica involucrada, lo que dificulta la comprensión del código.

Afortunadamente, gracias a Compose y Molecule, podemos construir el objeto de estado utilizando código imperativo y exponerlo de manera reactiva.

¿Qué es Molecule?

Molecule es un complemento del compilador de Kotlin que utiliza Jetpack Compose para recomponerse continuamente y construir un StateFlow o un Flow. Al igual que Jetpack Compose, Molecule depende de un reloj de fotogramas, como el MonotonicFrameClock, para sincronizar su proceso de recomposición con la renderización de los fotogramas. Existen dos tipos de RecompositionClock en Molecule:

  • RecompositionClock.ContextClock se comporta de manera similar a Jetpack Compose. Utiliza el `MonotonicFrameClock` del coroutineContext para la recomposición. Si no se encuentra uno, lanzará una excepción. Es útil con el `AndroidUiDispatcher.Main`, que tiene un MonotonicFrameClock incorporado sincronizado con la velocidad de fotogramas del dispositivo.
  • RecompositionClock.Immediate genera un fotograma cada vez que el flujo está listo para emitir un elemento. Se puede usar cuando no hay un `MonotonicFrameClock` disponible, como en pruebas unitarias o para ejecutar moléculas fuera del hilo principal.
  Catálogos de versiones de Gradle en Android

Asimismo, para crear un flow con molecule  se pueden utilizar dos funciones: moleculeFlow o CoroutineScope.launchMolecule. Se utiliza moleculeFlow para crear un flow con capacidad de backpressure y launchMolecule para crear un StateFlow.

Ok, ¿cómo migramos el ViewModel?

Imaginemos que tenemos una pantalla en la que necesitamos mostrar un listado de usuarios. En la cual tenemos un ViewModel con un flow para recibir las eventos del usuario y el flow del listado de usuarios que obtenemos del repositorio, los cuales combinamos para crear el stateFlow del estado de la vista.

class UserListViewModel(
    private val repository: Repository,
    ....
) : ViewModel() {
		//...
    private val events = _events
        .onStart { emit(RequestUsers()) } // 1
				.onEach { runSomeEffects(it, repository) } // 2
        .shareIn(viewModelScope, SharingStarted.Eagerly, 1)

    val state = repository.getUsersFlow() // 3 -> 6
        .runningFold(UserListState.DEFAULT, UserListState::applyResult) // 4 -> 7
        .combine(events) { state, event -> event.transformState(state) } // 5 -> 8
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(500), UserListState.DEFAULT) 
  
    //...
}

Utilizando Compose migraremos el código de arriba de la siguiente manera:

@Composable
fun UserListPresenter(events: Flow<UserListEvent>, repository: Repository): UserListState {
    var state by remember { mutableStateOf(UserListState.DEFAULT) } // <-- Set Default as inital state
    val userList by repository.getUserListFlow().collectAsState(emptyList()) // <- collect users
    
    state = state.updateState(userList) // <-- updateState with userList

		LaunchedEffect(Unit) { repository.requestUsers() } // <- Load users on first composition
    LaunchedEffect(events) {
        events.collect { event ->
            runSomeEffects(event, repository) // <- Run some sideEffects
            state = event.updateState(state) // <-- updateState with events
        }
    }

    return state
}

Como podemos apreciar es fácil de entender todo se ejecuta de manera lineal, aunque como todo, tiene su curva de adaptación, pero la parte más positiva es que todo lo que aprendamos componiendo nuestra vista con Compose UI podemos aplicarlo en el presenter y viceversa.

Ahora que tenemos nuestro presenter listo ¿Cómo lo utilizamos?

Primero necesitamos agregar la dependencia y aplicar el apply plugin: app.cash.molecule en los módulos donde vayamos a utilizarlo.

dependencies {
    classpath "app.cash.molecule:molecule-gradle-plugin:$version"
}

Podemos instanciar el Presenter desde alguna pantalla de Compose, aunque de esta manera no sobreviviría a los cambios de configuración.

  Nuestra experiencia migrando de Dagger a Koin

@Composable
fun SomeScreen() {
    ...
    val state by scope.launchMolecule(RecompositionClock.ContextClock) {
        UserListPresenter(...)
    }.collectAsState()
}

Pero el objetivo es migrar nuestros ViewModels, así que veamos cómo hacerlo. Primero crearemos una extensión sobre ViewModel que nos ayude a crear el stateFlow utilizando launchMolecule de manera lazy.

/**
 * Creates a lazy StateFlow using [launchMolecule] and [RecompositionClock.ContextClock]
 */
inline fun <T> ViewModel.moleculeStateFlow(
    clockContext: CoroutineContext = AndroidUiDispatcher.Main,
    clock: RecompositionClock = RecompositionClock.ContextClock,
    safetyMode: LazyThreadSafetyMode = LazyThreadSafetyMode.NONE,
    crossinline presenter: @Composable () -> T
): Lazy<StateFlow<T>> = lazy(safetyMode) {
    val scope = CoroutineScope(viewModelScope.coroutineContext + clockContext)
    scope.launchMolecule(clock) { presenter() }
}

A continuación, pasaremos como parámetro el  AndroidUICoroutineContext debido a que el context del ViewModel no tiene MonotonicFrameClock por defecto y el ContexClock como RecompositionClock. Y nuestro viewmodel quedaría de la siguiente manera:

class UserListViewModel(
    private val repository: Repository,
    context: CoroutineContext = AndroidUiDispatcher.Main,
    clock: RecompositionClock = RecompositionClock.ContextClock
    ....
) : ViewModel() {
    private val _events = MutableSharedFlow<UserListEvent>()

    val state: StateFlow<UserListState> by moleculeStateFlow(context, clock) {
        UserListPresenter(_events, repository)
    }
    
    fun emit(event: UserListEvent) = _events.tryEmit(event)
CTA Software

Por último pero no menos importante, ¿cómo lo probamos?

Para hacer pruebas unitarias debemos habilitar returnDefaultValues y agregar la dependencia de Turbine, una pequeña biblioteca de pruebas para Flows.

android {
  ...
  testOptions {
    unitTests.returnDefaultValues = true
  }
  ...
}
dependencies {
  testImplementation "app.cash.turbine:turbine:$version"
}

En nuestro test, podemos elegir entre hacer pruebas a nuestro ViewModel como lo haríamos hasta ahora

class UnitTest {
    ...
    private val viewModel = UserListPresenter(
        repositoryMock,
        UnconfinedTestDispatcher(),
        RecompositionClock.Immediate
    )
    
    @Test
    fun `some test`() = runTest {
        viewModel.state.test {
            val state = awaitItem()
            assertEquals(State.INITIAL, state)
        }
    }
}

o hacer pruebas a nuestra función presenter creando un moleculeFlow pasando un RecompositionClock.Immediate y ejecutando la función test de Turbine. Como es algo que repetiremos en cada test crearemos la siguiente extensión y la utilizaremos en el test.

/**
 * creates a moleculeFlow with [RecompositionClock.Immediate] recomposition clock 
 * and the turbine validate function
 */
suspend fun <T> (@Composable () -> T).test(
    timeout: Duration? = null,
    name: String? = null,
    validate: suspend ReceiveTurbine<T>.() -> Unit
) = moleculeFlow(RecompositionClock.Immediate, this).test(timeout, name, validate)


class UnitTest {
    ...
    private val presenter: @Composable () -> State
            get() = { UserListPresenter(events, repositoryMock) }

    @Test
    fun `some test`() = runTest {
        presenter.test {
            val state = awaitItem()
            assertEquals(State.INITIAL, state)
        }
    }
}

Conclusión

Compose Presenter nos brinda una alternativa para manejar el estado de manera más comprensible y eficiente. Nos permite escapar de la sobrecarga de los operadores de streams, escribiendo código imperativo. Lo podremos utilizar tanto en proyectos android como en KMM.

  Migrando Retrofit a Ktor
Cabe resaltar que para aplicar el patrón Presenter en nuestro proyecto no es necesario utilizar la librería de Molecule, pero es conveniente para este caso de uso. Happy Coding

Author

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange