HAL is a non-deterministic finite-state machine for Android & JVM built with Coroutines StateFlow and LiveData.
Why non-deterministic?
Because in a non-deterministic finite-state machine, an action can lead to one, more than one, or no transition for a given state. That way we have more flexibility to handle any kind of scenario.
Use cases:
- InsertCoin
transition to
Unlocked - LoadPosts
transition to
Loading thentransition to
Success or Error - LogMessage
don't transition
Why HAL?
It's a tribute to HAL 9000 (Heuristically programmed ALgorithmic computer), the sentient computer that controls the systems of the Discovery One spacecraft.
"I'm sorry, Dave. I'm afraid I can't do that." (HAL 9000)
This project started as a library module in one of my personal projects, but I decided to open source it and add more features for general use. Hope you like!
Usage
First, declare your Action
s and State
s. They must implement HAL.Action
and HAL.State
respectively.
sealed class MyAction : HAL.Action {
object LoadPosts : MyAction()
data class AddPost(val post: Post) : MyAction()
}
sealed class MyState : HAL.State {
object Init : MyState()
object Loading : MyState()
data class PostsLoaded(val posts: List<Post>) : MyState()
data class Error(val message: String) : MyState()
}
Next, implement the HAL.StateMachine<YourAction, YourState>
interface in your ViewModel
, Presenter
, Controller
or similar.
The HAL
class receives the following parameters:
- The initial state
- A
CoroutineScope
(tip: use the built in viewModelScope) - An optional CoroutineDispatcher to run the reducer function (default is Dispatcher.DEFAULT)
- A reducer function,
suspend (action: A, state: S) -> Unit
, where:suspend
: the reducer runs inside aCoroutineScope
, so you can run IO and other complex tasks without worrying about block the Main Threadaction: A
: the action emitted to the state machinestate: S
: the current state of the state machine
You should handle all actions inside the reducer function. Call transitionTo(newState)
or simply +newState
whenever you need to change the state (it can be called multiple times).
class MyViewModel(private val postRepository: PostRepository) : ViewModel(), HAL.StateMachine<MyAction, MyState> {
override val stateMachine by HAL(MyState.Init, viewModelScope) { action, state ->
when (action) {
is MyAction.LoadPosts -> {
+MyState.Loading
try {
// You can run suspend functions without blocking the Main Thread
val posts = postRepository.getPosts()
// And emit multiple states per action
+MyState.PostsLoaded(posts)
} catch(e: Exception) {
+MyState.Error("Ops, something went wrong.")
}
}
is MyAction.AddPost -> {
/* Handle action */
}
}
}
}
Finally, choose a class to emit actions to your state machine and observe state changes, it can be an Activity
, Fragment
, View
or any other class.
class MyActivity : AppCompatActivity() {
private val viewModel by viewModels<MyViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
// Easily emit actions to your State Machine
// You can all use: viewModel.emit(MyAction.LoadPosts)
loadPostsBt.setOnClickListener {
viewModel += MyAction.LoadPosts
}
// Observe and handle state changes
viewModel.observeState(lifecycleScope) { state ->
when (state) {
is MyState.Init -> showWelcomeMessage()
is MyState.Loading -> showLoading()
is MyState.PostsLoaded -> showPosts(state.posts)
is MyState.Error -> showError(state.message)
}
}
}
}
If you want to use a LiveData-based state observer, just pass your LifecycleOwner
to observeState()
, otherwise HAL will use the default Flow-based state observer.
// Observe and handle state changes backed by LiveData
viewModel.observeState(lifecycleOwner) { state ->
// Handle state
}
Single source of truth
Do you like the idea of have a single source of truth, like the Model in The Elm Architecture or the Store in Redux? I have good news: you can do the same with HAL!
Instead of use a sealed class with multiple states just create a single data class to represent your entire state:
sealed class MyAction : HAL.Action {
// Declare your actions as usual
}
// Tip: use default parameters to represent your initial state
data class MyState(
val posts: List<Post> = emptyList(),
val loading: Boolean = false,
val error: String? = null
) : HAL.State
Now, when handling the emitted actions use state.copy()
to change your state:
override val stateMachine by HAL(MyState(), viewModelScope) { action, state ->
when (action) {
is NetworkAction.LoadPosts -> {
+state.copy(loading = true)
try {
val posts = postRepository.getPosts()
+state.copy(posts = posts)
} catch (e: Throwable) {
+state.copy(error = "Ops, something went wrong.")
}
}
is MyAction.AddPost -> {
/* Handle action */
}
}
}
And finally you can handle the state as a single source of truth:
viewModel.observeState(lifecycleScope) { state ->
showPosts(state.posts)
setLoading(state.loading)
state.error?.let(::showError)
}
Custom StateObserver
If needed, you can also create your custom state observer by implementing the StateObserver<S>
interface:
class MyCustomStateObserver<S : HAL.State>(
private val myAwesomeParam: MyAwesomeClass
) : HAL.StateObserver<S> {
override fun observe(stateFlow: Flow<S>) {
// Handle the incoming states
}
}
And to use, just create an instance of it and pass to observeState()
function:
viewModel.observeState(MyCustomStateObserver(myAwesomeParam))
Import to your project
- Add the JitPack repository in your root build.gradle at the end of repositories:
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
- Next, add the desired dependencies to your module:
dependencies {
// Core with Flow state observer
implementation "com.github.adrielcafe.hal:hal-core:$currentVersion"
// LiveData state observer only
implementation "com.github.adrielcafe.hal:hal-livedata:$currentVersion"
}
Platform compatibility
hal-core | hal-livedata | |
---|---|---|
Android | ✓ | ✓ |
JVM | ✓ |