FlowMVI Documentation

repository·master·Indexed 21 days ago

https://github.com/respawn-llc/flowmvi

A Kotlin Multiplatform (KMP) architectural framework for building reactive, thread-safe, and extensible applications. FlowMVI utilizes a plug-in system for cross-cutting concerns like error recovery, analytics, and state preservation. It provides a store DSL for business logic, dedicated testing harnesses for stores and plugins, and integration modules for Compose Multiplatform, Android, and Essenty (Decompose).

Tokens
44.7K
Snippets
128
Records
168
Agent score
75%

What's inside FlowMVI

  1. Use Delegated ViewModels for KMP compatibility

    master

    For projects requiring Kotlin Multiplatform (KMP) compatibility, use the Delegated ViewModels approach. Instead of subclassing ViewModel to implement a Container, you use ContainerViewModel which delegates to a Store.

    Key characteristics:

    • Robustness: More multiplatform-friendly as it avoids subclassing Android ViewModels directly for logic.
    • Complexity: Slightly more boilerplate than the direct approach.
    • DI Requirement: Requires injecting your Container into a StoreViewModel, and then injecting that StoreViewModel into your Android components. Implementation details depend on your Dependency Injection (DI) framework.
  2. Evaluate Metrics performance overhead

    master

    Metric collection introduces a CPU overhead. Benchmarks on a MacBook Pro M1 2021 show:

    • Baseline: ~813 ns/intent
    • With Metrics: ~4382 ns/intent

    While this represents a ~5.39x increase in processing time per intent, the absolute impact is minimal for most applications. You can still process 1000 intents within a single 16ms frame. Metrics collection only becomes a significant bottleneck if your hot path processes tens of thousands of intents per second.

  3. Define and implement MVIState

    master

    In FlowMVI, application state must implement the MVIState marker interface. To ensure predictable behavior and compatibility with the framework, states must adhere to these three requirements:

    1. Immutable: State objects must never be mutated after creation. Always use copy() to create new instances. Avoid var properties and ensure collections are new instances rather than just upcasting a MutableList to a List.
    2. Comparable: State objects must implement a stable and valid equals and hashCode contract. Using Kotlin data class or data object is the recommended way to achieve this.
    3. Scoped: Avoid using unintended objects, such as 3rd party interfaces or raw network responses, directly as a State.

    If your store does not require a specific state, you can use the provided EmptyState object.

    data class CounterState(
        val counter: Int = 0,
        val isLoading: Boolean = false
    ) : MVIState
  4. How the FlowMVI core loop works

    master

    FlowMVI follows a structured MVI (Model-View-Intent) pattern consisting of four main components:

    1. Contract: Defines the data structures for MVIState (the current state), MVIIntent (inputs/events), and MVIAction (one-off side effects).
    2. Store: The central engine that uses a plugin pipeline to handle intents, update state, and emit actions.
    3. Plugins: An ordered chain of responsibility that can intercept, modify, or veto intents, actions, and states.
    4. Decorators: Wrappers that sit around the entire plugin chain and can short-circuit the execution flow.

    Subscribers (typically UI components) listen to state changes for rendering and handle emitted actions.

  5. How to test Stores with `test { }` and `subscribeAndTest { }`

    master

    FlowMVI provides two primary entry points for testing Stores, depending on whether you are testing lifecycle or state transitions.

    1. Store.test { ... } (Lifecycle-focused)

    Use this when you need to assert startup/shutdown behavior, subscription counts, or invariants regarding whether the store is alive. The receiver is a TestStore (a Store plus SubscriptionAware).

    2. Store.subscribeAndTest { ... } (State/Actions-focused)

    Use this to assert emitted state transitions and actions. The receiver is a StoreTestScope, which provides access to the subscription's states (StateFlow<S>) and actions (Flow<A>).

    Note on Transient Subscriptions: If your store is configured with debuggable = true, ensure allowTransientSubscriptions = true is set in your configuration. Otherwise, the store may flag the automatic unsubscription at the end of the subscribeAndTest block as an error.

    // Lifecycle testing
    store.test {
        isActive // StoreLifecycle
        subscriberCount.value // SubscriptionAware
        emit(MyIntent)
    }
    
    // State and Action testing (using Turbine)
    store.subscribeAndTest {
        states.test {
            awaitItem() shouldBe InitialState
            intent(MyIntent)
            awaitItem() shouldBe ExpectedState
        }
    
        actions.test {
            intent(MyIntentThatSendsAction)
            awaitItem() shouldBe ExpectedAction
        }
    }
  6. What are Decorators in FlowMVI

    master

    Concept

    Decorators are experimental "plugins for plugins" that wrap another plugin and manage it manually.

    While standard Plugins are executed automatically in a Chain of Responsibility, Decorators decide whether to call the plugin methods themselves. This allows you to:

    • Wrap the entire plugin chain.
    • Watch over the entire chain.
    • Skip execution of specific plugins entirely.

    Key Behaviors:

    • Manual Invocation: If you do not call the corresponding plugin method within the decorator, that plugin (and everything it wraps) is skipped. This can be dangerous if plugins rely on onStart for resource initialization.
    • Return Values: Unlike plugins where you return the "next" value, a decorator's return value is considered the "final" value. A safe pattern is to return whatever the chain invocation returns.
    • Transparency: If you don't define a particular decorator callback, it remains "transparent" (it won't skip the underlying logic).
    • Composition: You can decorate decorators, as decorating a plugin results in a new plugin.
    val plugin = plugin<State, Intent, Action> {
        onIntent { it }
    }
    
    val decorator = decorator<State, Intent, Action> {
        name = "FilterInvalidDecorator"
        onIntent { chain, intent ->
            if (intent is InvalidIntent) return@onIntent null
            chain.run { onIntent(intent) } // returns the result of the chain
        }
    }
    
    val decoratedPlugin = decorator decorates plugin
  7. Decide between using Plugins or Child Stores

    master

    When splitting logic in FlowMVI, choose based on these criteria:

    ConsiderationPrefer PluginsPrefer Child Stores
    State ComplexitySmall to medium, tightly coupledHigh complexity or loosely coupled
    Intent ProcessingAct on the same set of intents in different waysIntents modify distinct pieces of state and send subsets of Actions
    Subscriber TimingMostly same between all subscribersSubscribers need state at differing times
    Component LifecyclesSame lifecycle as the parentCan sometimes differ
    PerformanceWhen performance is criticalWhen async overhead is acceptable

    Key Takeaways:

    • Use Plugins for lightweight logic that acts on the same intents as the parent or when performance is critical (plugins avoid the coroutine/async overhead of child stores).
    • Use Child Stores when state is highly complex, when you need to isolate specific blocks of a page (like a settings drawer), or when different parts of the UI need to subscribe to different pieces of state at different times.
  8. Manage plugin installation order

    master

    The order in which you call plugin installation methods is critical because plugins can replace, veto, consume, or modify store events (intents, actions, or states).

    Key Rules:

    • Consumption: If a plugin (like a reduce plugin) consumes an intent, any plugin installed after it will not see that intent.
    • Vetoing: A plugin installed earlier can veto state changes or actions intended for later stages.
    • Lifecycle Timing: Plugins that hook into lifecycle events like onStart() may conflict with init blocks if the installation order is not carefully managed.

    Example of incorrect ordering (Logging after Reduce): If you install a logging plugin after a reduce plugin, the logger will not capture intents because the reduce plugin has already consumed them.

    // ❌ BROKEN: Logging plugin will not log any intents because they are consumed by reduce first
    val broken = store(Loading) {
        reduce { 
            // ... 
        }
        enableLogging()
    }
    
    // ✅ WORKING: Logging plugin gets the intent before reduce() runs
    val working = store(Loading) {
        enableLogging()
        reduce { 
            // ... 
        }
    }
  9. How FlowMVI works: Core Concepts

    master

    FlowMVI is built around a central architecture of Stores, Intents, States, and Actions. Understanding these relationships is key to using the library:

    • Stores: Classes that respond to Intents and update their State. The process of responding to an Intent is called reducing.
    • Intents (MVIIntent): Actions sent to the Store (e.g., user clicks, system broadcasts).
    • State (MVIState): The current data model used by the UI to render. States should be immutable; use copy() to create new states rather than mutating properties.
    • Actions (MVIAction): One-off, "fire and forget" side-effects sent from the Store to the subscriber (e.g., showing a snackbar or playing a sound).
    • Plugins: Logic added to Stores to form a Pipeline.
    • Contract: The combination of States, Intents, and Actions that defines a feature's interface.
  10. Processing order of Intents, Plugins, Actions, and States

    master

    FlowMVI processes components in the following order:

    • Intents: FIFO (First-In-First-Out) or undefined, depending on the parallelIntents configuration.
    • Actions: FIFO.
    • States: FIFO.
    • Plugins: FIFO (Chain of Responsibility) based on their installation order.
    • Decorators: FIFO, but processed after all regular plugins.
  11. Compare MVI and MVVM+ styles

    master

    FlowMVI supports two distinct development styles. It is recommended to choose one and use it consistently throughout your project.

    MVI Style (Strict Model-Driven)

    Recommended for full use of Plugins (logging, time travel, analytics).

    • Mechanism: Create an MVIIntent subclass for every event. The Store handles the logic.
    • Pros: High separation of concerns, verbose/readable contracts, supports intent decomposition and delegation.
    • Cons: More boilerplate, potential class explosion, harder IDE navigation.

    MVVM+ Style (Functional/Lambda-Driven)

    • Mechanism: Invoke functions containing business logic that send processing instructions to the store via lambdas.
    • Pros: Elegant syntax, easy navigation, avoids class explosion.
    • Cons: Requires the reduceLambdas Plugin, less performant than regular intents, some Plugins (logging/analytics) may not work, intents cannot be easily composed or delegated.

    Note for MVVM+: You must install the reduceLambdas Plugin. To prevent leaking the Store's context to subscribers, consider using the ImmutableStore and ImmutableContainer interfaces.

    // MVVM+ Style Intent Example
    fun onItemClick(item: Item) = store.intent {
        updateState {
            copy(selectedItem = item)
        }
    }
  12. Understand the metrics collected by FlowMVI

    master

    The metrics plugin provides over 67 numeric metrics per snapshot, covering the entire store lifecycle. The schema is versioned via MetricsSchemaVersion and rendered through a MetricSurface for compatibility.

    Key Metric Categories:

    • Intents: Totals, processed/dropped/undelivered counts, ops/sec, queue time, latency quantiles (p50/p90/p95/p99), inter-arrival times, bursts, buffer occupancy, and plugin overhead.
    • Actions: Sent/delivered/undelivered counts, ops/sec, delivery latency quantiles, queue time, buffer metrics, and plugin overhead.
    • State: Transition counts, vetoed transitions, started-in-initial-state, time-to-first state, reducer latency quantiles, and throughput.
    • Subscriptions: Subscribe/unsubscribe events, current/peak subscribers, average/median lifetimes, and sampled counts.
    • Lifecycle: Start/stop counters, total uptime, current/average/median lifetimes, and bootstrap latency.
    • Exceptions: Total/handled counts and recovery latency (average/median).
    • Meta: Schema version, window length, EMA alpha, generated-at timestamp, start time, store name/id, and run id.