Table of Contents
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.
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.
@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)
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.
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 CodingAuthor
-
Android Passionate Developer that enjoy solving issues. Focused on developing quality and scalable software.
Ver todas las entradas
More to Explore