High-level Feature Overview

At a high level, Ballast is a library to help you manage the state of your application as it changes over time. It follows the basic pattern of MVI, which is that the ViewModel state cannot be changed directly, but instead you must send your intent to change the state to the library. The library processes those requests safely, in a way that is predicable and repeatable, which generates new states that flow back to the UI automatically. The basic MVI loop looks like this:

graph
    ViewModel--State-->UI
    UI--Inputs-->ViewModel

In addition to providing a robust and safe system for processing changes and producing new states, it also offers features for emitting one-off Events and running and restarting tasks that run in the background with Side Jobs. For more advanced usage, Ballast also has a flexible plugin API, with many Modules available to extend the functionality of your ViewModels with features like logging, time-travel debugging, and automatic state restoration.

Explore the sections below for the basic components that make up a complete Ballast ViewModel workflow.

ViewModels

The ViewModel is Ballast's container for implementing the MVI pattern. It holds onto all data and assembles your components together to process work in a predictable manner. There are a number of ViewModel implementations provided by Ballast to run in a variety of scenarios, such as:

  • AndroidViewModel: A subclass of androidx.lifecycle.ViewModel
  • IosViewModel: A custom ViewModel that is tied to an iOS ViewController's lifecycle
  • BasicViewModel: A generic ViewModel which can be used in any arbitrary context, including Kotlin targets that don't have their own platform-specific ViewModel. BasicViewModel's lifecycle is controlled by a coroutineScope provided to it upon creation.

Typically, a single ViewModel serves as the store for a single Screen, and is not shared among multiple screens. Data that should persist through multiple screens should either be passed directly through the navigation request, or be managed by your repository layer and re-fetched from the later screen.

Contracts

The Contract is a declarative model what is happening in a screen. The Contract is entirely separate from any Ballast APIs, so while the snippet below shows the opinionated structure of a ViewModel's Contract, you are free to swap it out for any other classes you may already have defined. There is no requirement for any of these components to serializable in any way.

The contract is canonically a single top-level object with a name like *Contract, and it has 3 nested classes named State, Inputs, and Events.

object LoginScreenContract {
    data class State(
        val username: TextFieldValue,
        val password: TextFieldValue,
    )

    sealed interface Inputs {
        data class UsernameChanged(val newValue: TextFieldValue) : Inputs
        data class PasswordChanged(val newValue: TextFieldValue) : Inputs
        data object LoginButtonClicked : Inputs
        data object RegisterButtonClicked : Inputs
    }

    sealed interface Events {
        data object NavigateToDashboard : Events
        data object NavigateToRegistration : Events
    }
}

State

The most important component of the MVI contract, and of the Ballast library, is the State. All the data in your UI that changes meaningfully should be modeled in your State. States are held in-memory, and are guaranteed to always exist through the StateFlow. You will typically observe a StateFlow of your ViewModel state, but you can also access it once as a snapshot at that point in time. How you build your UI and model your Inputs should be derived completely from how you model your State.

State is modeled as a Kotlin immutable data class:

data class State(
    val loggingIn: Boolean = false,
    val username: TextFieldValue = TextFieldValue(),
    val password: TextFieldValue = TextFieldValue(),
)

Many articles on MVI suggest for using a sealed class to model UI state. However, experience has shown me that UI states are rarely so cleanly delineated between such discrete states; you're more likely to have the UI go through a range of mixed values and states as data is loaded, refreshed, or changed by the user. Additionally, a sealed class as your State is only capable of modeling a single feature, but real-world UIs commonly have many features that all must be modeled simultaneously.

For these reasons, Ballast's opinion is that the Contract's State class should be a data class. But sealed classes work great as individual properties within that State!

Info

Pro tip: data classes are great because they make it easy to update the state with .copy() and are built-in to the Kotlin language, but there are other ways to update immutable data classes that you might find nicer to work with. Try checking out Arrow Optics or KopyKat as an alternative!

Inputs

Inputs are the core of how Ballast does all its processing. The "intent" a user has when interacting with the UI is captured into an Input class, which is sent to the Ballast ViewModel and scheduled to be processed at some later point in time.

Inputs are modeled as a Kotlin sealed class:

sealed interface Inputs {
    data class UsernameChanged(val newValue: TextFieldValue) : Inputs
    data class PasswordChanged(val newValue: TextFieldValue) : Inputs
    data object LoginButtonClicked : Inputs
    data object RegisterButtonClicked : Inputs
}

A good rule of thumb is to avoid re-using any Inputs for more than 1 purpose. It should be entirely clear what an Input will do to the State without having to look at its implementation or the State. If you are tempted to re-send the same input to do 2 different things, it should just be 2 different Inputs.

Info

Pro tip: try using the new data object, available as a preview in Kotlin 1.8.20! You can also use a sealed interface instead of sealed class.

Events

A necessary feature of UI programming is to handle some actions once, only once, and only at the appropriate time (such as Navigation requests). The processing of these Events is typically tightly coupled to the UI framework itself and doesn't make much sense to be modeled in the State because the request should not be kept around after it has been handled. Ballast uses Events as a way to keep the platform-specific event-handling logic out of the ViewModel while ensuring all the guarantees of one-off Events that one would expect.

Like Inputs, Events are modeled as a Kotlin sealed class:

sealed interface Events {
    data object NavigateToDashboard : Events
    data object NavigateToRegistration : Events
}

Info

Pro tip: try using the new data object, available as a preview in Kotlin 1.8.20! You can also use a sealed interface instead of sealed class.

Warning

In a strict MVI model, using "one-off events" is sometimes considered an anti-pattern. Ballast processes Events with a Channel, making for an "at-most once" delivery model, which may not offer strong enough guarantees for your application. If this model is not strong enough for your needs, it's best to take the suggestion from the above article and reduce your Events to State instead of using Ballast's Events system.

Handlers

Everything in the Contract is entirely declarative, but at some point Ballast needs to do something with what you defined in your Contract. There are several elements of a complete Ballast ViewModel that get composed together to implement the full MVI pattern.

Input Handlers

All of Ballast's processing revolves around the Input Handler. It is the only place in the MVI loop that is allowed to run arbitrary code, and it is based upon Kotlin Coroutines to allow the entire processor loop to run asynchronously. Inputs that get sent to a ViewModel are placed into a queue, and the Input Handler pulls them out of the queue one-at-a-time to be processed.

An InputHandler is a class which implements the InputHandler interface. The InputHandler.handleInput() callback receives a generic Input which should get split out into its sealed subclasses with a when statement. The InputHandler will be provided to the ViewModel upon its creation.

import LoginScreenContract.*

class LoginScreenInputHandler : InputHandler<Inputs, Events, State> {
    override suspend fun InputHandlerScope<Inputs, Events, State>.handleInput(
        input: Inputs
    ) = when (input) {
        is UsernameChanged -> { }
        is PasswordChanged -> { }
        is LoginButtonClicked -> { }
        is RegisterButtonClicked -> { }
    }
}

The InputHandlerScope DSL is able to update the ViewModel State, post Events, start sideJobs, and call any other suspending functions within the Input queue.

Event Handlers

The Event Handler works very similarly to the Input Handler, but should implement EventHandler instead. Events are sent from the Input Handler into a queue, and the EventHandler will pull them out of the queue to be processed one-at-a-time.

Inputs are sent from the UI into the ViewModel, and finally delivered to the Input Handler. The Event Handler is the exact opposite, handling Events sent from the ViewModel to the UI. But crucially, the ViewModel may live longer than the UI element it is associated with, and so the EventHandler may be attached and detached dynamically in response to the UI element's own lifecycle. Events sent while the Event Handler is detached will be queued, and will only be delivered to the EventHandler once the UI is back in a valid lifecycle state.

import LoginScreenContract.*

class LoginScreenEventHandler : EventHandler<Inputs, Events, State> {
    override suspend fun EventHandlerScope<Inputs, Events, State>.handleEvent(
        event: Events
    ) = when (event) {
        is Events.Notification -> { }
    }
}

The EventHandlerScope DSL is able to post Inputs back into the queue.

Side-jobs

Inputs are normally processed in a queue, one-at-a-time, but there are lots of great use-cases for concurrent work in the MVI model. Side-jobs allow you to start coroutines that run in the "background" of your ViewModel, on the side of the normal Input queue. These side-jobs are bound by the same lifecycle as the ViewModel, and can even collect from infinite flows.

Unlike all other components in Ballast, Side-jobs are just part of the InputHandlerScope DSL. You call sideJob(), provide it with a key that is used to determine when to restart it, and run your code in the lambda.

override suspend fun InputHandlerScope<Inputs, Events, State>.handleInput(
    input: Inputs
) = when (input) {
    is InfiniteSideJob -> {
        sideJob("ShortSideJob") {
            infiniteFlow()
                .map { Inputs.SomeInputType() }
                .onEach { postInput(it) }
                .launchIn(this)
        }
    }
}

SideJobs are not able to directly access the ViewModel State since they are running in parallel to the InputHandler, but the sideJob() lambda's receiver DSL is able to post both Inputs and Events back to the ViewModel to request changes to the state.

Configuration

The above sections outline the overall usage of Ballast, but there are a few more useful features that can expand the functionality of Ballast with its configuration.

Config Builder

All ViewModels will require a BallastViewModelConfiguration provided when they're created where most of the configuration takes place, but some platform-specific ViewModel classes may need some additional configuration, too. A BasicViewModel configuration looks like this, using the helpful BallastViewModelConfiguration.Builder:

public class ExampleViewModel(
    viewModelScope: CoroutineScope
) : BasicViewModel<Inputs, Events, State>(
    config = BallastViewModelConfiguration.Builder()
        .apply {
            // set configuration common to all ViewModels, if needed
        }
        .withViewModel(
            initialState = State(),
            inputHandler = ExampleInputHandler(),
            name = "Example"
        )
        .build(),
    eventHandler = ExampleEventHandler(),
    coroutineScope = viewModelScope,
)

Interceptors

One of the primary features of Ballast, and indeed one of the biggest benefits of the MVI pattern in general, is it ability to decouple the intent to do work from the actual processing of that work. Because of this separation, it makes it possible to intercept all the objects moving throughout the ViewModel and add a bunch of other really useful functionality, without requiring any changes to the Contract or Handler code.

A basic Interceptor works like a Decorator, being attached to the ViewModel without affecting any of the normal processing behavior of the ViewModel. It receives BallastNotifications from the ViewModel to notify the status of every feature as it goes through the steps of processing, such as being queued, completed, or failed. Basic Interceptors are purely a read-only mechanism, and are not able to make any changes to the ViewModel.

public class CustomInterceptor<Inputs : Any, Events : Any, State : Any>(
) : BallastInterceptor<Inputs, Events, State> {

    override suspend fun onNotify(logger: BallastLogger, notification: BallastNotification<Inputs, Events, State>) {
        // do something
    }
}

Danger

Note that this style of writing Interceptors is deprecated since v3.0.0, and the onNotify method will be removed in v4. Use the "advanced" Interceptor below for creating new Interceptors.

More advanced Interceptors are given additional privileges and are able to push changes back to the ViewModel. Rather than being notified when something interesting happens, they are notified when the ViewModel starts up and are given direct access to the Notifications flow, as well as a way to send data directly back into the ViewModel's processing queue, for doing unique and privileged things like time-travel debugging. Advanced Interceptors are able to restore the ViewModel state arbitrarily, and send Inputs back to the ViewModel for processing, both of which will be processed in the normal queue by the InputStrategy.

public class CustomInterceptor<Inputs : Any, Events : Any, State : Any>(
) : BallastInterceptor<Inputs, Events, State> {

    public fun BallastInterceptorScope<Inputs, Events, State>.start(
        notifications: Flow<BallastNotification<Inputs, Events, State>>,
    ) {
        launch(start = CoroutineStart.UNDISPATCHED) {
            notifications.awaitViewModelStart()
            notifications
                .onEach {
                    // do something
                }
                .collect()
        }
    }
}

Ballast offers a number of useful Interceptors and modules to aid in debugging and monitoring your application, see Modules.

Input Strategy

Until now in this page, I've described the Ballast ViewModel's internals as a "queue" and they're processed "one-at-a-time", but that's not entirely accurate. More specifically, Inputs are buffered into a Kotlin Coroutine Channel, and Ballast offers an API for customizing exactly how the Inputs are read from that channel.

Ballast offers 3 different Input Strategies out-of-the-box, which each adapt Ballast's core functionality for different applications:

  • LifoInputStrategy: A last-in-first-out strategy for handling Inputs, and the default strategy if none is provided. Only 1 Input will be processed at a time, and if a new Input is received while one is still working, the running Input will be cancelled to immediately accept the new one. Corresponds to Flow.collectLatest { }, best for UI ViewModels that need a highly responsive UI where you do not want to block the user's actions.
  • FifoInputStrategy: A first-in-first-out strategy for handling Inputs. Inputs will be processed in the same order they were sent and only ever one-at-a-time, but instead of cancelling running Inputs, new ones are queued and will be consumed later when the queue is free. Corresponds to the normal Flow.collect { }, best for non-UI ViewModels, or UI ViewModels where it is OK to "block" the UI while something is loading.
  • ParallelInputStrategy: For specific edge-cases where neither of the above strategies works. Inputs are all handled concurrently so you don't have to worry about blocking the queue or having Inputs cancelled. However, it places additional restrictions on State reads/changes to prevent usage that might lead to race conditions.

Danger

For historical reasons, LifoInputStrategy is the default, but can be unintuitive to work with and cause subtle issues in your application. For this reason, it is recommended to manually choose to use FifoInputStrategy unless you are familiar enough with Ballast and it's workflow to understand the full implications LifoInputStrategy.

This default input strategy will likely be changed to FifoInputStrategy in a future version, so it would be best to start by explicitly choosing the strategy you wish to use for every ViewModel, rather than relying on the default or having your application start behaving differently in a future version of Ballast.

InputStrategies are responsible for creating the Channel used to buffer incoming Inputs, consuming the Inputs from that channel, and providing a "Guardian" to ensure the Inputs are handled properly according the needs of that particular strategy. The DefaultGuardian is a good starting place if you need to create your own InputStrategy to maintain the same level of safety as the core strategies listed above.

Info

Pro Tip: The text descriptions of these InputStrategies can be a bit confusing, but seeing them play out in real-time should make it obvious how they work. Playing with the Kitchen Sink example while using the Debugger gives you a simple way of experiencing these behaviors to get an intuition for when to use each one.

Logging

Ballast offers a simple logging API integrated throughout the library. An instance of BallastLogger installed in the BallastViewModelConfiguration is exposed through all interfaces where custom code is run, so you don't have to juggle injecting Loggers and properly matching up tags amongst all the different classes that make up the Ballast ViewModel.

Loggers are created individually for each ViewModel, and are supplied with a tag (the ViewModel name) upon creation, so you can easily filter logs to isolate the activity from a single ViewModel.

import LoginScreenContract.*

class LoginScreenInputHandler : InputHandler<Inputs, Events, State> {
    override suspend fun InputHandlerScope<Inputs, Events, State>.handleInput(
        input: Inputs
    ) = when (input) {
        is UsernameChanged -> { }
        is PasswordChanged -> { }
        is LoginButtonClicked -> { 
            logger.info("Attempting Logging In...")
            val loginSuccessful = attemptLogin()
            if(loginSuccessful) {
                logger.info("Login success")
            } else {
                logger.info("Login failed")
            }
        }
        is RegisterButtonClicked -> { }
    }
}

You can access the Logger in the following places:

  • InputHandlerScope
  • EventHandlerScope
  • SideJobScope
  • BallastInterceptorScope
  • SaveStateScope
  • RestoreStateScope

Ballast offers several logger implementations out-of-the-box:

  • NoOpLogger: The default implementation, it simply drops all messages and exceptions so nothing gets logged accidentally. It's recommended to use this in production builds.
  • PrintlnLogger: Useful for quick-and-dirty logging on all platforms. It just writes log messages to stdout through println.
  • AndroidLogger: Only available on Android, writes logs to the default LogCat at the appropriate levels.
  • JsConsoleLogger: Only available on JS, writes logs to console.log() or console.error()
  • NSLogLogger: Only available on iOS, writes logs to NSLog
  • OSLogLogger: Only available on iOS, writes logs to OSLog

By default, only logs written directly to the logger will be displayed, but by installing the LoggingInterceptor into the BallastViewModelConfiguration you'll get automatic logging of all activity within the ViewModel. This interceptor maintains a list of all Inputs and a copy of the latest State, so it may consume large amounts of memory or write sensitive information to the logger, and as such should never be used in production.