Esta biblioteca está en alfa. La API está en su mayoría completa y no cambiará, pero todavía hay trabajo por hacer para que la producción esté lista en todas las plataformas. Para JVM y multiplataforma, la biblioteca está lista para el uso de la producción, pero para iOS, JS y nativo todavía no está completamente configurado. Si desea ayudar, no dude en abrir un problema o un PR.
Redukks es un conjunto de abstracciones simples de tipo seguro necesario para implementar una arquitectura de uniflow y similar a Redux en Kotlin Multiplatform. Simplifica la creación de tiendas, reductores y acciones, y proporciona una manera simple de probarlas.
Si bien hay muchas implementaciones de Uniflow o Redux para la multiplataforma de Kotlin/Kotlin, la mayoría de ellas son demasiado complejas o demasiado simples. O el nombramiento es demasiado "REDUXY", te obligan a una forma de hacer las cosas o las abstracciones son demasiado complejas para ser escrita de forma regular. Y si bien hay arquitecturas que se ajustan a este estilo, el nombre que usan es bastante específico para Android y los desarrolladores sin Android, lo que dificulta razonar o discutir con otros desarrolladores.
Desde que me encontré reutilizando el mismo conjunto de abstracciones a través de los años, decidí convertirlas en una biblioteca que pueda reutilizar y compartir.
El objetivo es proporcionar abstracciones simples, tipo seguras y comprobables para la arquitectura similar a Redux, sin embargo, aún no lo vincula a un solo patrón, proporcionándole abstracciones que pueden ayudarlo a construir su propia solución. Si bien la mayoría de las bibliotecas intentan presionar por un patrón Redux completo, los reductores pueden ser una sobrecarga para algunos casos simples, y debería poder evitarlas si lo desea, es por eso que Redukks no hace cumplir el patrón Redux en sí.
Para obtener más razonamiento sobre por qué Uniflow y por qué Redux, puede consultar la arquitectura de construcción de charlas (DE).
Gestión del estado : Redukks proporciona una forma simple de administrar su estado, actualizarlo de manera predecible y escuchar los cambios. Puede usarlo para administrar el estado de toda su aplicación, una sola característica, tener un estado compartido entre diferentes características (pantallas) o tener múltiples estados para una sola pantalla.
Manejo de acción : Redukks proporciona una manera simple de manejar acciones y ejecutarlas de manera predecible. Puede usarlo para manejar las acciones de la interfaz de usuario, llamadas de red o cualquier otro trabajo que pueda necesitar. La naturaleza no opinionada de la biblioteca le permite implementar sus propios manejadores de acción específicos para su USECase, incluso permitiéndole romper el patrón Uniflow al implementar la señalización hacia atrás.
Pruebas : con Redukks, Prueba de su estado, reductores y acciones es increíblemente simple, tanto debido a la naturaleza de la API y la seguridad de compilación adicional. Puede probar fácilmente su lógica de gestión estatal o su lógica de manejo de acciones sin la necesidad de API de pruebas complejas.
La arquitectura Uniflow se basa en una idea simple: su aplicación es un ciclo constante de datos que fluye en una dirección. El estado y sus cambios son impulsados por la interfaz de usuario, y la interfaz de usuario es impulsada por el estado. Esto le permite tener una sola fuente de verdad y le proporciona una forma simple de pensar y razonar sobre su aplicación.
Mientras tanto, Redux es un patrón que lo ayuda a administrar su estado. Se basa en la idea de que su estado es una sola fuente de verdad, y que debe actualizarse de manera predecible.
Redukks es una combinación de estas dos ideas: le proporciona las abstracciones básicas para implementar arquitecturas basadas en Uniflow y Redux, pero no lo obliga a una sola forma de hacer las cosas.

El flujo en sí es bastante simple: tienes un estado X y tienes n formas de cambiarlo. Esas formas de cambiarlo se llaman "reductores" y son la única forma de cambiar ese estado. Entonces, en lugar de actualizarlo salvajemente desde donde sea que deseemos, definimos un conjunto de "actualizaciones" que se pueden usar para actualizar el estado.
Pero, dado que la mayoría de las aplicaciones son más complejas que un contador simple, también necesita una forma de manejar un trabajo complejo. Ahí es donde entran las acciones. Usted "despacha" una acción y ejecuta algún trabajo, y durante esa ejecución, puede actualizar el estado a través de reductores o invocar otras acciones.
Esto hace que sea fácil definir y probar todas las posibles permutaciones de los cambios en el estado, y facilita la razón sobre el estado de su aplicación y estar segura de que funciona cómo pretendía.
Gradle Groovy:
dependencies {
implementation ' com.ianrumac.redukks:redukks:0.1.4 '
}Gradle Kotlin:
implementation( " com.ianrumac.redukks:redukks:0.1.4 " ) data class CountState ( val total : Int )
sealed class Updates ( override val reduce : ( CountState ) -> CountState ) : Reducer<CountState> {
class Add ( val number : Int ) : Updates({
state.copy(state.total + number)
})
class Subtract ( val number : Int ) : Updates({
state.copy(state.total - number)
})
}FeatureContext que contenga todas las dependencias para su función (o para sus acciones). interface CountContext {
val client : CountingAPI
val store : Store < CountState >
}Actions que se pueden ejecutar en el contexto sealed class Actions ( override val execute : suspend CountContext .() -> Unit ) : TypedAction<CountContext> {
class AddToClient ( val number : Int ) : Actions({
// here, we have access to all the CountContext properties that are captured during execution
// For example, we can access the store and update it with the result of the network call.
val result = client.add(number)
state.update( Updates . Add (result))
})
class SubtractFromClient ( val number : Int ) : Actions({
val result = client.subtract(number)
state.update( Updates . Subtract (result))
})
} data class CountWithAnAPI ( val scope : CoroutineScope ) : CountContext {
override val client = CountingAPI ()
override val store = reducedStore( CountState ( 0 ))
override val handler =
actionHandlerFor(scope, this ) // this is a coroutine scope that will be used to execute the actions
}
val context = CountWithAnAPI (scope)
// In a class that implements Dispatcher<Actions>
// This is actually a call to context.handler.dispatch(Actions.Add(1))
dispatch( Actions . Add ( 1 )) interface CountContext {
val client : CountingAPI
val store : Store < CountState >
val handler : ActionDispatcher < Actions >
data class CountState ( val total : Int )
sealed class Updates ( override val reduce : ( CountState ) -> CountState ) : Reducer<CountState> {
class Add ( val number : Int ) : Updates({
state.copy(state.total + number)
})
class Subtract ( val number : Int ) : Updates({
state.copy(state.total - number)
})
}
sealed class Actions ( override val execute : suspend CountContext .() -> Unit ) : TypedAction<CountContext> {
class AddToClient ( val number : Int ) : Actions({
// here, we have access to all the CountContext properties that are captured during execution
// For example, we can access the store and update it with the result of the network call.
val result = client.add(number)
state.update( Updates . Add (result))
})
class SubtractFromClient ( val number : Int ) : Actions({
val result = client.subtract(number)
state.update( Updates . Subtract (result))
})
}
} val context = CountWithAnAPI (scope)
val handler = context.handler
handler.dispatch( Actions . AddToClient ( 1 ))
context.store.listen().collectLatest { state ->
// do something with the state
} Puede usar fácilmente Redukks con la ViewModel de Android y se ajusta muy bien con JetPack Compose (¡y también el marco de la vista!). Simplemente recoja la tienda como un flujo de estado o úsela con Livedata. Su ViewModel también puede implementar Dispatcher<Actions> para que pueda enviar las acciones aguas arriba (delegarlas al controlador de acción). También puede crear abstracciones para esto, como un ReduxViewModel que manejan estas cosas por usted.
Usarlo con un Modelo View es simple:
@HiltViewModel
class CountViewModel @Inject constructor(
val actionHandler : Dispatcher < CountContext . Actions >,
val store : Store < CountContext . State >
) : ViewModel(), Dispatcher<Actions> by actionHandler {
// Note: Not a real implementation, be careful where you collect and map your state
val state = store.listen().map( this ::mapToViewModel)
.stateIn(viewModelScope, SharingStarted . WhileSubscribed (), initialValue = CountVM ( 0 ))
fun mapToViewModel ( it : CountContext . State ): CountVM {
.. .
}
}Y ahora en tu interfaz de usuario, simplemente puedes hacer:
val vm : CountViewModel by viewModels()
.. .
vm.dispatch( Actions . AddToClient ( 1 ))También puede controlar el ciclo de vida de su estado alzándolo hacia arriba o hacia abajo en el árbol de alcance del ciclo de vida. Por ejemplo, si necesita compartir una tienda de estado, puede moverlo desde IE, un alcance de fragmento a un alcance de actividad y pasarlo hacia abajo a los fragmentos. De esta manera, puede compartir el estado entre fragmentos, pero aún tiene una sola tienda estatal. Incluso puede proporcionar varias tiendas para una acción, o proporcionar varias tiendas a su interfaz de usuario para combinar.
typealias CountStore = Store < CountState >
typealias UserStore = Store < UserState >
interface CountContext {
val countStore : CountStore
val userStore : UserStore
}
class AuthenticatedCounterContext ( userStore : UserStore ) {
override val userStore = userStore
override val countStore = CountStore ( CountState ( 0 ))
}
// ...
class AddForUser : Actions ({
val count = countStore.state.total
countStore.update( Updates . Add (1))
userStore.update( UpdateStateForUser (countStore.total))
})
Esto funciona maravillosamente con JetPack Compose, ya que puede derivar fácilmente la interfaz de usuario del estado (por favor, asigna a una clase ViewModel antes de hacerlo, no mezcle sus modelos centrales y sus modelos de interfaz de usuario).

Dado que Redukks es un conjunto modular de abstracciones, solo puede usar las piezas que necesita para el nivel de complejidad que desea. Redukks proporciona algunas implementaciones predeterminadas de las abstracciones, pero puede crear fácilmente las suyas.
Comencemos con el caso más simple, una tienda simple. Comenzaremos con una simple implementación de contador, con solo una tienda sin reductores o acciones. En general, si prefiere no escribir reductores, puede usar la clase BasicStore , que es una tienda simple que expone una función update con un cierre que toma en el estado actual y devuelve un nuevo estado.
data class CountState ( val total : Int )
val store = BasicStore ( CountState ( 0 ))
store.update {
it.copy(it.total + 1 )
}
println (store.state.total) // prints 1
store.listen().collectLatest { state ->
// do something with the state
} Ahora, también podemos usar un reductor para definir las actualizaciones de estado de una manera típica. Así que creemos un reductor. Los reductores son solo objetos que implementan la interfaz de Reducer<StateType> , lo que significa que tienen un cierre reduce que toma en el estado actual y devuelve un nuevo estado. Es como escribir
fun addNumber ( state : CountState , number : Int ) : CountState {
return state.copy(state.total + number)
}Pero con un poco más de seguridad de tipo, por lo que puede estar seguro de que todos los cambios de estado posibles están predefinidos y probados.
sealed class Updates ( override val reduce : ( CountState ) -> CountState ) : Reducer<CountState> {
class Add ( val number : Int ) : Updates({
state.copy(state.total + number)
})
class Subtract ( val number : Int ) : Updates({
state.copy(state.total - number)
})
} Ahora, para usar este reductor, necesitamos crear un ReducedStore<StateType, ReducerType> . Esta es una tienda que solo se puede actualizar a través del tipo de reductor definido. Se proporciona una implementación predeterminada como BasicReducedStore, pero puede crear fácilmente la suya.
val basicStore = BasicStore ( CountState ( 0 ))
// you can either provide it with a store or the initial state to use a BasicStore
val store = BasicReducedStore < CountState , Updates >(basicStore)
// or use the helper function
val store = createReducedStore< CountState , Updates >( CountState ( 0 ))
store.update( Updates . Add ( 1 ))
store.listen().collectLatest { state ->
// do something with the state
}Las acciones son piezas de código asincrónicas que se pueden ejecutar. Por lo general, se utilizan para realizar efectos secundarios, como llamadas de red o para enviar otras acciones y actualizaciones. Por ejemplo, obtener una lista de elementos de un servidor y luego enviar una acción para actualizar el estado con el resultado.
Las acciones son un poco más complicadas, ya que generalmente necesitan poder acceder a las dependencias. Para hacer esto, proporcionamos un <Context> que contiene todas las dependencias y luego creamos acciones que se ejecutan con contexto como receptor, lo que significa que tienen acceso a ella como this . Con esto, podemos enviarlos a un Dispatcher<ActionType> , que debería manejar acciones.
Un controlador de acción basado en coroutine básico se implementa de forma predeterminada en TypedActionHandler y uno personalizado se puede implementar fácilmente proporcionando un Dispatcher<ActionType> o AsyncDispatcher<ActionType> para obtener más control de ejecución (a través de diferido). Además, siempre se proporciona una clase AsyncAction que se puede utilizar para simplemente pasar el cierre a través del constructor.
Ahora, veamos cómo definir las acciones de asíncrono.
Primero, necesitamos un contexto en el que se puedan ejecutar estas acciones.
Si bien para casos simples (es decir, simplemente actualizando la tienda) puede usar una Store<StateType> , es mejor crear un contexto personalizado. Esto le brinda una manera fácil de acceder a las dependencias, burlarse, reemplazarlas, etc. Además, es una buena práctica mantener las acciones estrechamente acopladas para ellas, para que pueda ver fácilmente qué acciones necesitan qué dependencias.
Con los receptores de contexto de Kotlin en el camino, el código puede ser aún más limpio en el futuro, eliminando la necesidad de acciones por completo e implementando funciones simples en los receptores. Pero dado que la mayoría de las bases de código aún no tienen receptores de contexto, nos centraremos solo en esta implementación por ahora.
Primero, definimos una interfaz CountContext que contiene las dependencias que necesitamos:
interface CountContext {
val client : CountingAPI
val store : Store < CountState >
val handler : Dispatcher < Actions >
} Luego, podemos definir acciones a través de clases selladas que implementan TypedAction<CountContext> :
sealed class Actions ( override val execute : suspend CountContext .() -> Unit ) : TypedAction<CountContext> {
class Add ( val number : Int ) : Actions({
// here, we have access to all the CountContext properties that are captured during execution
// For example, we can access the store and update it with the result of the network call.
val result = client.add(number)
state.update( Updates . UpdateState (result))
})
class Subtract ( val number : Int ) : Actions({
val result = client.subtract(number)
state.update( Updates . UpdateState (result))
})
}Ahora, podemos implementar el contexto y usar el despachador para ejecutar acciones:
data class CountWithAnAPI ( val scope : CoroutineScope ) : CountContext {
override val client = CountingAPI ()
override val store = BasicReducedStore < CountState , Updates >( CountState ( 0 ))
override val handler = createActionHandler(scope, this )
}
// In a class that implements Dispatcher<Actions>, like a viewmodel that delegates it to the handler
// This is actually a call to context.handler.dispatch(Actions.Add(1))
dispatch( Actions . Add ( 1 ))
dispatch( Actions . Subtract ( 1 ))
Las pruebas son bastante fáciles, tanto para reductores como para acciones. Para los reductores, simplemente puede crear un estado y un reductor, es decir, val state = CountState(0) y val reducer = Updates.Add(1) , y luego llame reducer.reduce(state) . Ahora puede afirmar que el resultado es el esperado.
@Test
fun `update the total count by adding 1` () {
val state = CountState ( 0 )
val reducer = Updates . Add ( 1 )
val result = reducer.reduce(state)
assertEquals( 1 , result.total)
} Para las acciones, puede crear un contexto y un despachador, es decir, val context = CountWithAnAPI(TestCoroutineScope()) y val action = Action.Add(1) , y luego llamar a action.execute(context) . Esto le proporciona una manera fácil de probar acciones, ya que puede burlarse fácilmente del contexto y afirmar que las acciones se ejecutan como se esperaba o utilizar bibliotecas de burla como Mockk para verificar los simulacros ejecutados correctamente.
@Test
fun `update the total count and add from client` () = runTest {
val context = CountWithAnAPI ( this )
val action = Actions . Add ( 1 )
action.execute(context)
assertEquals( 1 , context.store.state.total)
}Cada componente está escrito de una manera que permita una fácil personalización, para que pueda crear fácilmente el suyo. Es decir, al implementar una tienda o un despachador, puede tener fácilmente cosas como una tienda respaldada por API/Socket/Room, o acciones que indican cuándo están listos u otros flujos en su tienda. No tenga miedo de personalizar el patrón de sus necesidades: no estamos escribiendo código para satisfacer los patrones, lo estamos escribiendo para nosotros, nuestros compañeros de equipo y nuestros usuarios.
Siéntase libre de abrir un problema o un PR si tiene alguna sugerencia o mejoras.