Máquina de estados finita con Android (Kotlin): Pros y contras

Máquina de estados finita con Android (Kotlin): Pros y contras

¡Hola! ¿Cómo estás? En este artículo aprenderás sobre las máquinas de estados finitos, cómo implementar este truco con Kotlin para Android, cuáles son los beneficios y desventajas de este modelo y cuáles son los casos de uso apropiados para implementar este concepto.

¿Qué es una máquina de estados?


La máquina de estados finitos (o en inglés Finite State Machine o FSM) fue introducida a mediados de 1955 por el matemático Edward Moore, poco después de que la FSM fuera optimizada por George H. Mealy y hasta hoy sigue siendo un modelo matemático de extrema importancia para informática y matemáticas, siendo ampliamente utilizado en banca, sistemas de aviación, servicios de atención médica y mucho más.

El FSM representa un sistema computacional. Este modelo abstracto se compone de estados, eventos y transiciones. Los estados representan el modo actual del sistema, el cual puede cambiar según un determinado evento que desencadene una transición del estado actual al siguiente.

Desafortunadamente, el FSM y todo lo que ofrece ha sido pasado por alto por muchos en la informática moderna, ya que resolvería fácilmente muchos problemas de sistemas de mediana y baja complejidad de una manera muy eficiente. Así que ¡NO ENTRES EN PÁNICO!, porque este artículo explica algunos de los casos de uso apropiados para usar FSM y cómo implementar un FSM.


Conceptos de FSM

Como se mencionó anteriormente, el FSM se compone de estados, eventos y transiciones y, afortunadamente, existe un modelo visual que representa cada uno de estos conceptos en formato de diagrama. Para que todo funcione, el FSM también representa un conjunto de reglas que determinan cómo funciona su lógica.

Esas reglas son:

  • Cada FSM debe contener un estado inicial, representado por una flecha que viene del vacío a un círculo con el nombre del estado:
  • Cada transición debe provenir de un estado de origen a un estado de destino, por lo que cada transición involucra solo 2 estados. Ejemplo a continuación donde salimos del estado Ligado para el estado Desligado (y viceversa) a través del evento Pressionar interruptor:
  • La FSM puede tener un estado final, es decir, un estado que no realiza ninguna transición a otro estado de destino. Esto está representado por el doble círculo, como se muestra a continuación en un ejemplo de flujo de producto:
Flujo de producto: Lista de deseos, la venta, el carrito de compras, comprado.


¿Por qué usar una máquina de estados finita y cuáles son los casos de uso apropiados?


El FSM es un modelo ampliamente estudiado, probado y utilizado, por lo que es fiable. Cuando nos enfrentamos a un problema computacional que involucra una cantidad finita de estados conocidos que transitan entre sí a través de eventos, uno puede considerar sin miedo el uso del FSM.

Por supuesto, es posible implementar la gestión estatal sin adoptar el uso del FSM. Sin embargo, al aplicar este concepto en este tipo de escenarios se gana la confianza y la calidad de un modelo matemático que ofrece operaciones lógicas precisas y seguras.

Es apropiado utilizar un FSM en contextos con estados finitos, como en estos ejemplos:

  • Estatus de resultado de request: Loading, Failure, Success.
  • Estatus de jugador principal: Idle, Walking, Running, Attacking, Defending.
  • Flujo de pedido: Pending, Confirmed, Canceled, Finalized.


Es importante resaltar que es necesaria una buena planificación y una buena división de ámbitos para no confundir los contextos y así crear un FSM gigante, complejo y confuso, por lo que es necesario que los ámbitos sean independientes y desacoplados para que se pueda incluso tienen FSM más pequeños e independientes entre sí. Recuerda: aplica el FSM solo a la característica (o alcance) en la que mejor se ajuste.


Pros y contras


Ventajas


- Planificar un FSM ayuda en la proyección y abstracción del proyecto, implicando una mejor comprensión del contexto del sistema para el equipo.

- Previsibilidad: un FSM bien implementado hace que sea más fácil predecir lo que sucederá con el sistema a partir de un estado determinado, insertar y eliminar cambios se vuelve más fácil cuando se puede predecir lo que viene antes o lo que viene después.

- Debug: La FSM facilita el debug porque en cada nueva entrada/salida es posible observar con logs, permitiendo una especie de "historia" de eventos dentro del sistema.

- Confiabilidad: Basada en un modelo bien estructurado y definido, la implementación del FSM es muy confiable, ya que es posible determinar exactamente el flujo a seguir y uno/a está seguro/a de que lo implementado es exactamente lo que se ejecutará, sin sorpresas.

- Facilidad: La implementación de un FSM es simple y, si se aplica en el contexto adecuado, puede facilitar enormemente el mantenimiento y el rendimiento del sistema.

Desventajas

- Si el diseño del FSM se abstrae y diseña incorrectamente, puede llevar a la implementación de un FSM inconsistente e impredecible.

- Inflexibilidad: si bien es fácil agregar/quitar nuevos estados, puede ser difícil hacer muchos cambios a la vez, un proyecto que cambia constantemente de alcance y aún está en fase de conceptualización, representa un gran desafío para la implementación de un FSM, ya que debe basarse en un flujo consistente y bien definido.

- Limitación para lidiar con lo inesperado: dependiendo del contexto del proyecto, el FSM se vuelve limitado, ya que no puede ir más allá de los estados que fueron definidos para él. Si se dejó un vacío no implementado en la delimitación de transiciones y eventos, la máquina puede llegar a estado muerto y no continuar con sus actividades como debe.

- Dificultad en la alineación: los sistemas con lógica de negocio que necesitan muchos estados pueden convertirse en un problema a la hora del mantenimiento, ya que la complejidad puede llegar a ser muy alta y, por tanto, costosa.


Implementación de una máquina de estados finita simple

Definiendo el caso de uso


El caso de uso definido para este tutorial es una función mecánica para controlar el estado de un reproductor de video.

Los estados son:

  • Idle: Representa cuando el reproductor arranca y no reproduce ninguna película o video.
  • Play: Representa cuando el reproductor está reproduciendo una película.
  • Pause: Representa cuando se pausó el reproductor.
  • Forward: Representa cuando el reproductor avanza el contenido.
  • Rewind: Representa cuando el reproductor rebobina el contenido.


Las restricciones son:

  • El estado Idle es el inicial.
  • El estado Play se emplea cada vez que el usuario escoge una nueva película o video.
  • El estado Pause solo puede usarse si el contenido está en reproducción, es decir, cuando está activo Play.
  • El estado Pause puede llevar al estado Play y hacer que el video retome la reproducción.
  • El estado Forward puede usarse cuando se reproduce una película, es decir, al activarse el estado Play o cuando se va en dirección opuesta con Rewind.
  • El estado Rewind puede usarse cuando se reproduce un video (Play) o cuando se avanza en dirección opuesta (Forward).


Para permitir esas transiciones, dejaremos al usuario 4 botones y una lista de películas: Play, Pause, Forward y Rewind. A continuación se muestra el diagrama del FSM definido para este caso:


Etapa 1. Delimitando los estados

Para crear los estados, vamos a usar el tipo enum class, donde podemos definirlos como constantes dentro de un mismo grupo llamado PlayerStates.

enum class PlayerStates {

   IDLE,

   PLAYING,

   PAUSED,

   FORWARDING,

   REWINDING,

}


Etapa 2. Delimitando los eventos

Siguiendo el diagrama de FSM en el caso de player, tenemos 4 eventos. Para delimitarlos, usaremos el tipo sealed class, donde podemos definir los eventos como objetos o classes identificados en el mismo grupo Event:

sealed class Event {

   object PausePressed : Event()

   object ForwardPressed: Event()

   object RewindPressed: Event()

   class PlayPressed(val movie: String?): Event()

}


Etapa 3. Delimitando las transiciones

Para implementar las transiciones, necesitamos crear un método que permita tratar la lógica de las restricciones de cada evento basado en el evento activado. Para eso, generaremos el método triggerEvent en una clase que represente una máquina de estados finita.

Este método debe recibir como parámetro el evento activado, el cual será tratado internamente según su tipo, y el resultado de este tratamiento, que dependerá del tipo de transición, devolverá el nuevo estado del FSM.

También necesitamos mantener la referencia del estado actual de la máquina, que comienza como idle, y la referencia de la película actual que se está reproduciendo, que comienza vacía, ya que comenzamos sin película.

class FSM {

   var currentState: PlayerStates = PlayerStates.IDLE

   var currentMovie: String? = null


   fun triggerEvent(event: Event): PlayerStates {

       currentState = when(event) {

           is Event.PlayPressed -> {}

           is Event.PausePressed -> {}

           is Event.ForwardPressed -> {}

           is Event.RewindPressed -> {}

       }

       return currentState

   }

}

En el evento PlayPressed, debemos actualizar el estado actual de la máquina para Playing. En caso de que escojas una nueva película, debes actualizar la actual.

is Event.PlayPressed -> {

   // Always allow PLAYING except if movie is already PLAYING

   if (event.movie != null) currentMovie = event.movie

   PlayerStates.PLAYING

}


Ahora, en el evento PausePressed, solo podemos permitir el estado Pause. Si el estado actual es Playing, haremos esta restricción:

is Event.PausePressed -> {

   // Only allow PAUSED if state is PLAYING

   if (currentState != PlayerStates.PLAYING) return currentState

   PlayerStates.PAUSED

}


En cuanto al evento ForwardPressed, como definimos en las restricciones, solo debe alcanzarse si el estado actual es Playing o Rewinding.

is Event.ForwardPressed -> {

   // Only allow FORWARDING when current state is PLAYING or REWINDING

   if (currentState == PlayerStates.PAUSED || 

       currentState == PlayerStates.IDLE) return currentState

   PlayerStates.FORWARDING

}


Por último, el evento RewindPressed, que siguiendo la línea de ForwardPressed, solo puede activarse si el estado actual fuera Playing o Forwarding.

is Event.RewindPressed -> {

   // Only allow REWINDING when current state is PLAYING or FORWARDING

   if (currentState == PlayerStates.PAUSED || 

       currentState ==  PlayerStates.IDLE) return currentState

   PlayerStates.REWINDING

}


Implementación del ViewModel

Ahora implementaremos el ViewModel, que va a consumir la FSM y tenderá el puente con View. Para eso, usaremos una clase llamada MainViewModel que se extiende desde el tipo ViewModel, y agregaremos en esta clase los métodos correspondientes a los eventos que creamos en el FSM.

class MainViewModel: ViewModel() {


   private val fsm: FSM = FSM()


   private val _playerData = MutableLiveData<Pair<PlayerStates, String?>>(Pair(fsm.currentState, null))

   val playerData: LiveData<Pair<PlayerStates, String?>> = _playerData


   fun play(movie: String? = null) {

       _playerData.value = Pair(fsm.triggerEvent(Event.PlayPressed(movie)), fsm.currentMovie)

   }


   fun pause() {

       _playerData.value = Pair(fsm.triggerEvent(Event.PausePressed), fsm.currentMovie)

   }


   fun forward() {

       _playerData.value = Pair(fsm.triggerEvent(Event.ForwardPressed), fsm.currentMovie)

   }


   fun rewind() {

       _playerData.value = Pair(fsm.triggerEvent(Event.RewindPressed), fsm.currentMovie)

   }

}


Para actualizar View de acuerdo con los cambios en la FSM, usaremos una propiedad del tipo LiveData llamada playerData. Este LiveData mantendrá actualizado el par de datos PlayerStates, correspondiente al estado del reproductor, y String, correspondiente al nombre del filme.


Etapa 4. Implementación de View

La implementación de View será muy simple: la organización de los componentes en pantalla debe quedar como en la imagen a continuación. A título de mantener este tutorial enfocado en FSM, no implementaremos una instancia real de reproductor, pero sí una abstracción de uno usando: ImageButton para forward, play, pause y rewind; una ListView que representa el catálogo de películas y, finalmente, un TextView para representar el estado actual del player y el nombre de la película que se reproduce.


En la clase MainActivity instanciaremos nuestro MainViewModel creado en la Etapa 3 usando ViewModelProvider y crearemos los métodos responsables de controlar las views.

class MainActivity : AppCompatActivity() {


   private val binding by lazy {

       ActivityMainBinding.inflate(layoutInflater)

   }


   private lateinit var mainViewModel: MainViewModel


   override fun onCreate(savedInstanceState: Bundle?) {

       super.onCreate(savedInstanceState)

       mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java)


       registerMoviesList()

       registerObservers()

       registerButtons()


       setContentView(binding.root)

   }


   private fun registerMoviesList() { }


   private fun registerObserver() { }


   private fun registerButtons() { }

}


En el método registerMoviesList crearemos una lista de películas e integraremos esos datos en ListView. También se debe implementar el evento play al hacer clic en una película o video de la lista.

private fun registerMoviesList() {

   val movies = arrayOf(

       "Diário de uma Princesa",

       "O Diabo Veste Prada",

       "Deu a Louca na Chapeuzinho Vermelho",

   )


   binding.moviesList.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, movies)


   binding.moviesList.setOnItemClickListener { _, _, position, _ ->

       mainViewModel.play(movies[position])

   }

}


En el método registerObserver implementaremos ahora la observación del LiveData playerData creado en la Etapa 3. Esta propiedad es reactiva y, con cada actualización, viene un nuevo estado y película de la FSM. De esa forma, podemos actualizar nuestro TextView. En caso de que el video sea inexistente, mostraremos al usuario un texto informando que puede escoger una película.

private fun registerObserver() {

   mainViewModel.playerData.observe(this) {

       binding.contentName.text = "${it.first} - ${it.second ?: "Choose a movie"}"

   }

}


Finalmente, vamos a integrar los botones con el método registerButtons:

private fun registerButtons() {

   binding.pauseButton.setOnClickListener {

       mainViewModel.pause()

   }

   binding.playButton.setOnClickListener {

       mainViewModel.play()

   }

   binding.forwardButton.setOnClickListener {

       mainViewModel.forward()

   }

   binding.rewindButton.setOnClickListener {

       mainViewModel.rewind()

   }

}


Resultado

Ahora probaremos el resultado de nuestro tutorial:

https://www.youtube.com/watch?v=qOiGOkVtRRU


Pruebas unitarias

Para complementar nuestro conocimiento y traer más confianza a nuestro FSM, vamos a implementar algunos escenarios de prueba. Es un ejercicio práctico para agregar más casos de prueba.

class FiniteStateMachineTest {


   private lateinit var fsm: FSM

   private val movie = "Movie test"


   @Before

   fun setup() {

       fsm = FSM()

   }


   @Test

   fun `when FSM is created state should be idle`() {

       Assert.assertEquals(PlayerStates.IDLE, fsm.currentState)

   }


   @Test

   fun `when play is pressed, FSM should update to PLAYING`() {

       fsm.triggerEvent(Event.PlayPressed(movie))


       Assert.assertEquals(PlayerStates.PLAYING, fsm.currentState)

       Assert.assertEquals(movie, fsm.currentMovie)

   }


   @Test

   fun `when FSM is playing and pause is pressed, FSM should update to PAUSED`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.PausePressed)


       Assert.assertEquals(PlayerStates.PAUSED, fsm.currentState)

   }


   @Test

   fun `when FSM is paused and play is pressed, FSM should update to PLAYING`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.PausePressed)

       fsm.triggerEvent(Event.PlayPressed(null))


       Assert.assertEquals(PlayerStates.PLAYING, fsm.currentState)

   }


   @Test

   fun `when FSM is paused and forward is pressed, FSM should keep PAUSED`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.PausePressed)

       fsm.triggerEvent(Event.ForwardPressed)


       Assert.assertEquals(PlayerStates.PAUSED, fsm.currentState)

   }


   @Test

   fun `when FSM is paused and rewind is pressed, FSM should keep PAUSED`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.PausePressed)

       fsm.triggerEvent(Event.RewindPressed)


       Assert.assertEquals(PlayerStates.PAUSED, fsm.currentState)

   }


   @Test

   fun `when FSM is playing and forward is pressed, FSM should update to FORWARDING`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.ForwardPressed)


       Assert.assertEquals(PlayerStates.FORWARDING, fsm.currentState)

   }


   @Test

   fun `when FSM is playing and rewind is pressed, FSM should update to REWINDING`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.RewindPressed)


       Assert.assertEquals(PlayerStates.REWINDING, fsm.currentState)

   }


   @Test

   fun `when FSM is forwarding and rewind is pressed, FSM should update to REWINDING`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.ForwardPressed)

       fsm.triggerEvent(Event.RewindPressed)


       Assert.assertEquals(PlayerStates.REWINDING, fsm.currentState)

   }


   @Test

   fun `when FSM is rewinding and forward is pressed, FSM should update to FORWARDING`() {

       fsm.triggerEvent(Event.PlayPressed(movie))

       fsm.triggerEvent(Event.RewindPressed)

       fsm.triggerEvent(Event.ForwardPressed)


       Assert.assertEquals(PlayerStates.FORWARDING, fsm.currentState)

   }

}


Conclusão

En este artículo, vimos los conceptos principales de un FSM y algunos ejemplos de casos de uso importantes. hay muchos otros casos de uso para aprender y practicar, lo que te ayudará a crear mejores FSM y más optimizados. Tómate la libertad de editar y agregar más estados y transiciones en el código implementado aquí.

¿Te gustó el artículo? Guarda esta página para referencias futursa y, por supuesto, sigue estudiando y practicando el uso de FSM para así convertirte en un/a mejor DEV que aporte conceptos informáticos fundamentales para optimizar y crear código más limpio.

¡Hasta la próxima!

💡
Las opiniones y comentarios emitidos en este artículo son propiedad única de su autor y no necesariamente representan el punto de vista de Listopro.

Listopro Community da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.