Skip to content
🤖 A state machine library for Kotlin, with extensions for Android.
Branch: master
Clone or download
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
art Update showcase image Jul 7, 2019
core-android-annotations Overhauled the View Model generation system Jul 7, 2019
core-android-processor Cleanup Jul 9, 2019
core-android Move LiveData extensions from the sample-android module to the core-a… Jul 7, 2019
core-test Remove wildcard import from TestStateMachine Jul 7, 2019
core Kotlin reflect should probably only be an impl dep Jul 11, 2019
gradle/wrapper Initial commit Jul 6, 2019
sample-android Android sample tweaks Jul 8, 2019
sample Sample module should be just a plain 'java' module, not a library Jul 11, 2019
.gitignore Initial commit Jul 6, 2019
.travis.yml Update README.md and add .travis.yml Jul 6, 2019
LICENSE.md Initial commit Jul 6, 2019
README.md README updates Jul 10, 2019
bintrayconfig.gradle Initial commit Jul 6, 2019
build.gradle Initial commit Jul 6, 2019
dependencies.gradle Gradle Plugin 3.4.2 Jul 9, 2019
gradle.properties Initial commit Jul 6, 2019
gradlew Initial commit Jul 6, 2019
gradlew.bat Initial commit Jul 6, 2019
javaapp-nokapt.gradle Sample module should be just a plain 'java' module, not a library Jul 11, 2019
javalib-nokapt.gradle Initial commit Jul 6, 2019
javalib-withkapt.gradle Initial commit Jul 6, 2019
settings.gradle Add a core-test module with test utilities that consumers can use, an… Jul 6, 2019
sourcesets.gradle Add a core-test module with test utilities that consumers can use, an… Jul 6, 2019
spotless.gradle Initial commit Jul 6, 2019
spotless.license.kt Initial commit Jul 6, 2019
versionsPlugin.gradle Initial commit Jul 6, 2019

README.md

State Machine

Build Status License

Table of Contents

  1. States and Events
  2. Building a Machine
    1. Gradle Dependency
    2. Basics
    3. Usage
  3. Using a Machine on Android
    1. Gradle Dependency
    2. States and View Models
    3. Dispatchers
    4. Usage
  4. Unit Testing Machines
    1. Gradle Dependency
    2. Usage

States and Events

A state machine takes three generic parameters: the events it takes in, the states it spits out, and the arguments it finishes with. These could be sealed classes, or plain classes & objects.

sealed class Event {
  data class InputText(val text: String) : Event()
  
  object ClickedButton : Event()
}

sealed class State {
  abstract val showProgress: Boolean

  data class Idle(val currentInput: String = "") : State() {
     override val showProgress: Boolean = false
  }
  
  data class Submitting(val currentInput: String) : State() {
     override val showProgress: Boolean = true
  }
  
  object Success : State() {
     override val showProgress: Boolean = false
  }
  
  data class Failure(val reason: String) : State() {
     override val showProgress: Boolean = false
  }
}

data class Result(val success: Boolean)

Building a Machine

A basic machine only requires the core module without any extensions.

Gradle Dependency

Core

dependencies {
   ...
   implementation "com.afollestad:statemachine:0.0.1-alpha1"
}

Basics

Your state machine should be a subclass of the StateMachine class.

When the react method is called, your machine should setup handlers for states. Each state handles a set of events, each event returns a state. The state should propagate to the UI, and the UI should send in events when there is interaction. It's a loop, which is basically what a state machine is.

class MyStateMachine : StateMachine<Event, State, Result>() {
  override suspend fun react() {
    onState<Idle> {
      onEvent<InputText> {
        state.copy(currentInput = event.text)
      }
      onEvent<ClickedButton> {
        Submitting(currentInput = state.currentInput)
      }
    }
    onState<Submitting> {
      onEnter { doServerWork(state) }
    }
    onState<Success> {
      onEnter {
        delay(2000) // A synthetic delay
        finish(Result(true)) // Kills the state machine.
      }
    }
    onState<Failure> {
      onEnter {
        delay(5000) 
        Idle() 
      }
    }
  }

  private suspend fun doServerWork(state: Submitting): State {
    delay(2000)
    return if (state.currentInput == "fail") {
      Failure(reason = "Your wish is my command.")
    } else {
      Success
    }
  }
}

Usage

This example below demonstrates usage from a JVM command-line program.

fun main() {
  val stateMachine = MyStateMachine()
  
  // Listen for state changes in the background
  GlobalScope.launch {
    stateMachine.stateFlow()
        .collect { state ->
          println("Current state: $state")
        }
  }
  
  // Start the machine with an initial state, launches the event looper.
  stateMachine.start(Idle()) { args ->
    // This callback is invoked when the machine is finished.
    // It cannot be re-used after this point.
    println("Goodbye.")
  }

  val inputReader = Scanner(System.`in`)
  while (stateMachine.isActive) {
    when (val input = inputReader.nextLine()) {
      "send" -> {
        // The invoke operator on the machine streams in events.
        stateMachine(ClickedButton)
      }
      "exit" -> {
        // The finish() method destroys the state machine.
        // This releases resources and shuts down the event looper.
        stateMachine.finish()
      }
      else -> stateMachine(InputText(input))
    }
  }
}

Using a Machine on Android

On Android, there are extension modules that you can use to make connecting your states and UI much easier. Annotation processing is used to generate view models from your state.

Gradle Dependency

Core Android

On Android, your application should depend on the core-android module as an implementation dependency, and the core-android-processor module as an annotation processor (with kapt).

dependencies {
   ...
   implementation "com.afollestad:statemachine-android:0.0.1-alpha1"
   kapt "com.afollestad:statemachine-android-processor:0.0.1-alpha1"
}

States and View Models

Your state must be tagged with the @StateToViewModel annotation. Only the parent class should be tagged.

@StateToViewModel
sealed class MyState {
  abstract val buttonEnabled: Boolean

  data class Idle(val currentInput: String = "") : State() {
     override val buttonEnabled = true
  }
  data class Submitting(val currentInput: String) : State() {
     override val buttonEnabled = false
  }
  object Success : State() {
     override val buttonEnabled = false
  }
  data class Failure(val errorMessage: String) : State() {
     override val buttonEnabled = true
  }
}

This will generate a ViewModel that you can use in your UI. From the above state, you'd get something similar to this (some details are omitted because they aren't relevant):

class MyStateViewModel : ViewModel(), StateConsumer<MyState> {
  val onButtonEnabled: LiveData<Boolean> = // ...
  val onCurrentInput: LiveData<String> = // ...
  val onErrorMessage: LiveData<String> = // ...
  
  override fun accept(state: MyState) {
    ...
     // This is filled automatically to propagate state changes into the live data fields above.
  }
}

Dispatchers

On Android, being thread conscious is important. StateMachine's constructor takes two coroutine dispatchers - one for background execution, and one for the main thread.

class MyStateMachine : StateMachine<Event, State, Result>(
   executionContext = Dispatchers.IO,
   mainContext = Dispatchers.Main
) {
  ...
}

Usage

In your Activity, Fragment, etc. you can use the generated ViewModels like you would without this library.

class MyActivity : AppCompatActivity() {
  private lateinit var button: Button
  private lateinit var input: EditText
  private lateinit var error: TextView

  private lateinit var stateMachine: MyStateMachine

  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      // ...
      
      stateMachine = MyStateMachine()
      
      val viewModel = ViewModelProviders.of(this)
         .get(MyStateViewModel::class.java)
      
      viewModel.onButtonEnabled
         .observe(this, button::setEnabled)
      viewModel.onCurrentInput
         /** Filtering here is important to we don't end up with an input/output loop */
         .filter { it != input.text.toString() }
         .observe(this, input::setText)
      viewModel.onErrorMessage
         .observe(this, error::setTextAndVisibility)
          
      input.onTextChanged { text -> 
         stateMachine(InputText(text = text)) 
      }
      button.setOnClickListener { 
         stateMachine(ClickedSubmit) 
      }
      
      // This is important on Android! This will automatically finish your state machine
      // when `this` (a LifecycleOwner) is destroyed. You could of course use plain `start()` 
      // here and remember to call `finish` somewhere below later, e.g. on `onDestroy()`.
      startMachine.startWithOwner(this)
  }
  
  private fun TextView.setTextAndVisibility(text: String) {
     setText(text)
     visibility = if (text.isNotEmpty()) VISIBLE else GONE
  }
}

Your UI will automatically receive propagated values from your states as they come in. The generated view model will automatically distinct values as well, so you won't receive duplicate emissions (which could result in unnecessary UI invalidation).

If you're familiar with LiveData, you'll also know that this implementation is totally lifecycle safe. LiveData will not emit if the lifecycle owner is no longer started.


Unit Testing Machines

A core-test module is provided to aid in unit testing your state machines.

Gradle Dependency

Core Testing

dependencies {
   ...
   testImplementation "com.afollestad:statemachine-test:0.0.1-alpha1"
}

Usage

Here's a simple example of a JUnit test, for the state machine above.

class MyTests {
  // The core-test module exposes a `test()` extension method.
  private val stateMachine = MyStateMachine().test()
  
  @Test fun `idle state accepts input`() {
     val initialState = Idle()
     stateMachine.start(initialState)
     // send an event in
     stateMachine(InputText(text))
     // assert the initial state and new state
     stateMachine.assertStates(initialState, Idle(text))
  }
  
  @Test fun `click button in idle submits`() {
     val initialState = Idle("hello world")
     stateMachine.start(initialState)
     // send an event in
     stateMachine(ClickedButton)
     // assert the initial state and new states
     // Submitting's `onEnter` brings us to the Success state.
     stateMachine.assertStates(initialState, Submitting(text), Success)
  }
  
  @After fun shutdown() {
     // Ensure resources are released and the event looper is stopped.
     stateMachine.destroy()
  }
}
You can’t perform that action at this time.