Máquina de estados finita com Android (Kotlin)

Máquina de estados finita com Android (Kotlin)

Olá! Como vai? Nesse artigo você aprenderá sobre máquina de estados finita, como implementar esse artifício com Kotlin para Android, quais os benefícios e desvantagens desse modelo e quais os casos de uso apropriados para implementar esse conceito.

O que é máquina de estados?

A máquina de estados finita (ou em inglês Finite State Machine ou FSM) foi introduzida em meados de 1955 pelo matemático Edward Moore, não muito tempo depois a FSM foi otimizada por George H. Mealy e até hoje segue sendo um modelo matemático de extrema importância para a computação e a matemática, sendo muito utilizado em bancos, sistemas de aviação, serviço de atendimento de saúde, e a lista segue grande.

Na computação a FSM representa um sistema computacional. Esse modelo abstrato é constituído por estados, eventos e transições. Os estados representam o modo atual do sistema, esse modo pode mudar de acordo com um determinado evento que dispara uma transição do estado atual para o próximo.

Infelizmente, a FSM e tudo que ela oferece foi deixada um pouco de lado por muitos na computação moderna, sendo que ela facilmente resolveria muitos problemas de sistemas de média e baixa complexidade de forma muito eficiente. Por isso DON'T PANIC!, pois esse artigo te explica alguns dos casos de uso apropriados para utilização de FSM e como implementar uma FSM.


Conceitos da FSM

Como dito anteriormente, a FSM é formada por estados, eventos e transições, e felizmente existe um modelo visual que representa cada um desses conceitos em formato de diagrama. E claro, para que toda a coisa funcione, a FSM também representa um conjunto de regras que determinam o funcionamento de sua lógica.

Essas regras são:

  • Toda FSM deve conter um estado inicial, representado por seta vindo do vazio para um círculo com o nome do estado:
  • Toda transição deve vir de um estado origem para um estado destino, sendo assim, cada transição envolve apenas 2 estados. Exemplo abaixo, onde saímos do estado Ligado para o estado Desligado (e vice-versa) através do evento Pressionar interruptor:
  • A FSM pode ter um estado final, ou seja, um estado que não executa nenhuma transição para outro estado destino. Este é representado pelo círculo duplo, como abaixo em um exemplo de fluxo de um produto:


Por que usar máquina de estados finita e quais os casos de uso apropriados?

A FSM é um modelo amplamente estudado, testado e utilizado, portanto confiável. Ao se deparar com um problema computacional que envolve uma quantidade finita de estados conhecidos que transitam entre si através de eventos, pode-se considerar sem medo o uso da FSM.

Claro que é possível implementar o gerenciamento de estados sem adotar o uso da FSM, entretanto ao aplicar esse conceito nesse tipo de cenário ganha-se a confiança e qualidade de um modelo matemático que oferece operações lógicas precisas e seguras.

É apropriado usar uma FSM em contextos com estados finitos, como nos exemplos:

  • Status de resultado de request: Loading, Failure, Success.


  • Status de jogador principal: Idle, Walking, Running, Attacking, Defending.

  • Fluxo de pedido: Pending, Confirmed, Canceled, Finalized.


É importante destacar que é necessário bom planejamento e boa divisão de escopos para não confundir os contextos e assim criar uma FSM gigante, complexa e confusa, por isso é necessário que os escopos sejam independentes e desacoplados, assim você pode até ter FSMs menores e independentes uma da outra. Lembrando: aplique a FSM apenas para a feature (ou escopo) em que ela melhor cabe.

E claro, mesmo que a intenção não seja de fato implementar uma FSM, você ainda pode usufruir da abstração do modelo matemático para criar diagramas e documentar o sistema.

Prós e contras

Vantagens

- Planejar uma FSM auxilia na projeção e abstração do projeto, implicando em um melhor entendimento do contexto do sistema para o time.

- Previsibilidade: uma FSM bem implementada facilita prever o que acontecerá com o sistema a partir de um determinado estado, inserir e remover mudanças se torna mais fácil quando se consegue prever o que vem anteriormente ou a seguir.

- Debug: a FSM facilita fazer debug pois a cada nova entrada/saída é possível observar com logs, possibilitando um uma espécie de "histórico" dos acontecimentos dentro do sistema.

- Confiabilidade: a partir de um modelo bem estruturado e definido, a implementação da FSM é muito confiável, pois é possível determinar exatamente o fluxo a ser percorrido e se tem a certeza de que o que foi implementado é exatamente o que será executado, sem surpresas.

- Facilidade: a implementação de uma FSM é simples, e se aplicada no contexto correto pode facilitar muito a manutenção e performance do sistema.

Desvantagens

- Se o projeto da FSM for incorretamente abstraído e projetado pode causar a implementação de uma FSM inconsistente e imprevisível.

- Inflexibilidade: apesar de ser fácil incluir/remover novos estados, pode ser difícil fazer muitas mudanças de uma vez só, um projeto que muda de escopo constantemente e ainda está em fase de conceituação, representa um grande desafio para implementação de um FSM, pois esta precisa ser baseada em um fluxo consistente e bem definido.

- Limitação em lidar com o inesperado: dependendo do contexto do projeto, a FSM se torna limitada, pois ela não consegue ir além dos estados que lhe foram definidos, se foi deixada uma lacuna não implementada na delimitação das transições e eventos, a máquina pode chegar em um estado morto e não conseguir prosseguir com suas atividades como deveria.

- Dificuldade na escalação: sistemas com lógicas de negócio que possuem necessidade de muitos estados, podem se tornar um problema no momento de fazer manutenção, pois a complexidade pode se tornar muito alta e portanto custosa.

Implementação da máquina de estados finita simples

Definindo Caso de Uso

O caso de uso definido para esse tutorial é uma mecânica de features para controlar o estado de um player de vídeo.

Os estados são:

  • Idle: Representando quando o player inicializa e não está tocando nenhum filme.
  • Play: Representando quando o player está tocando algum filme.
  • Pause: Representando quando o player está pausado.
  • Forward: Representando quando o player está avançando no conteúdo.
  • Rewind: Representando quando o player está rebobinando (ou voltando) o conteúdo.

As restrições são:

  • O estado Idle é o estado inicial.
  • O estado Play é acessado toda vez que o usuário escolhe um novo filme.
  • O estado Pause só pode ser acessado se o conteúdo estiver tocando, ou seja, através do estado Play.
  • O estado Pause pode acessar o estado Play e fazer o filme voltar a tocar.
  • O estado Forward pode ser acessado quando se tem um filme tocando, ou seja, através do estado Play, ou quando se está indo na direção oposta, ou seja, através do estado Rewind.
  • O estado Rewind pode ser acessado quando se tem um filme tocando, ou seja, através do estado Play ou quando se está indo na direção oposta, ou seja, através do estado Forward.

Para possibilitar essas transições, iremos dispor para o usuário 4 botões e uma lista de filmes. Os botões são: Play, Pause, Forward e Rewind. E a lista de filmes, onde o usuário pode escolher o título desejado e dar play nesse filme. Abaixo está o diagrama da FSM definida para esse caso de uso:


Etapa 1. Delimitando os estados

Para criar os estados vamos usar o tipo enum class, onde podemos definir os estados como constantes dentro de um mesmo grupo chamado PlayerStates.

enum class PlayerStates {

   IDLE,

   PLAYING,

   PAUSED,

   FORWARDING,

   REWINDING,

}


Etapa 2. Delimitando os eventos

Seguindo o diagrama de FSM do caso de uso do player, temos 4 eventos, para delimitá-los usaremos o tipo sealed class, onde podemos definir os eventos como objetos ou classes identificados no mesmo grupo Event:

sealed class Event {

   object PausePressed : Event()

   object ForwardPressed: Event()

   object RewindPressed: Event()

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

}


Etapa 3. Delimitando as transições

Para implementar as transições, vamos precisar criar um método que permita tratar a lógica das restrições de cada evento baseado no evento que está sendo acionado, para isso iremos criar o método triggerEvent em uma classe que represente máquina de estados finita.

Esse método deve receber como parâmetro o evento acionado, que será tratado internamente, de acordo com o seu tipo, e o resultado desse tratamento, que dependerá do tipo de transição, retornará o novo estado da FSM.

Precisamos também manter a referência do estado atual da máquina, que inicia como idle, e a referência do filme atual que está tocando, que inicia vazio, pois começamos sem nenhum filme.

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

   }

}


No evento PlayPressed, devemos atualizar o estado atual da máquina para Playing, e caso tenha um novo filme tenha sido escolhido, devemos atualizar o filme atual.

is Event.PlayPressed -> {

   // Always allow PLAYING except if movie is already PLAYING

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

   PlayerStates.PLAYING

}


Agora, no evento PausePressed, só podemos permitir o estado Pause, caso o estado atual seja Playing, por isso faremos essa restrição:

is Event.PausePressed -> {

   // Only allow PAUSED if state is PLAYING

   if (currentState != PlayerStates.PLAYING) return currentState

   PlayerStates.PAUSED

}

Quanto ao evento ForwardPressed, como definimos nas restrições, só deve ser atingido se o estado atual for Playing ou 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

}


E por último, o evento RewindPressed, que seguindo a linha do ForwardPressed, só pode ser acionado se o estado atual for Playing ou 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

}


Implementação da ViewModel

Agora vamos implementar a ViewModel, que vai consumir a FSM e fará a ponte com a View. Para isso usaremos uma classe chamada MainViewModel que extende do tipo ViewModel, e adicionaremos nessa classe os métodos correspondentes aos eventos que criamos na 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 conseguir atualizar a View de acordo com as mudanças que acontecem no FSM, usaremos uma propriedade do tipo LiveData chamada playerData. Esse LiveData vai manter atualizado o par de dados: PlayerStates, correspondente ao estado do player, e o dado String, correspondente ao nome do filme.

Etapa 4. Implementação da View

A implementação da View será bem simples, a organização dos componentes em tela devem ficar como na imagem abaixo. A título de manter esse tutorial focado em FSM, não iremos implementar uma instância real de player, mas sim uma abstração de um player, usando: 4 ImageButton para forward, play, pause e rewind, uma ListView que representa o catálogo de filmes, e finalmente um TextView para representar o estado atual do player e o nome do filme que está tocando.

Agora na classe MainActivity iremos instanciar nosso MainViewModel criado na Etapa 3 usando o ViewModelProvider e vamos criar os métodos responsáveis para controlar as 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() { }

}


No método registerMoviesList iremos criar uma lista de filmes e integrar esses dados na ListView. E também deve-se implementar o evento play ao clicar em um filme da 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])

   }

}


No método registerObserver vamos implementar a observação do LiveData playerData criado na Etapa 3, essa propriedade é reativa e a cada atualização traz o novo estado e filme da FSM, dessa forma podemos atualizar nosso TextView. Caso o filme seja inexistente, vamos mostrar ao usuário um texto informando que ele pode escolher um filme.

private fun registerObserver() {

   mainViewModel.playerData.observe(this) {

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

   }

}


E finalmente vamos integrar os botões com o 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

Agora, claro, vamos testar o resultado do nosso tutorial:

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


Testes unitários

Para complementar nosso conhecimento e trazer mais confiança para nossa FSM, vamos implementar alguns cenários de teste, fica como exercício prático adicionar mais casos de teste.

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

Nesse artigo vimos os principais conceitos de uma FSM e alguns exemplos de caso de uso importantes, existem muitos outros casos de uso a serem aprendidos e praticados, dessa forma pode-se criar FSM cada vez melhores e mais otimizadas. Tome liberdade para editar e adicionar mais estados e transições no código implementado aqui.

Gostou do artigo? Então salve essa página para referência futura e claro, continue estudando e praticando o uso de FSM e assim torne-se um DEV melhor trazendo conceitos fundamentais da computação para otimizar e construir códigos mais limpos.

Até logo!