FlowMVI

0

A KMP MVI library based on coroutines with a rich DSL and a powerful plugin system.

Infrastructure

architecture
android-library
android-architecture
android

CI License GitHub last commit Issues GitHub top language CodeFactor AndroidWeekly #563 Slack channel

badge badge badge badge badge badge badge badge badge badge badge

FlowMVI is a Kotlin Multiplatform architectural framework based on coroutines. It enables you to extend your business logic with reusable plugins, handle errors, achieve thread-safety, and more. It takes about 10 minutes to get started.

⚡️ Quickstart:

  • Latest version: Maven Central
  • Documentation: Docs
  • KDoc: Javadoc
  • Sample App (See features) badge-wasm: Static Badge
  • Ask questions on Slack
Version catalogs
[versions]
flowmvi = "< Badge above 👆🏻 >"

[dependencies]
# Core KMP module
flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" }
# Test DSL
flowmvi-test = { module = "pro.respawn.flowmvi:test", version.ref = "flowmvi" }
# Compose multiplatform
flowmvi-compose = { module = "pro.respawn.flowmvi:compose", version.ref = "flowmvi" }
# Android (common + view-based)
flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowmvi" }
# Multiplatform state preservation
flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" }
# Remote debugging client
flowmvi-debugger-client = { module = "pro.respawn.flowmvi:debugger-plugin", version.ref = "flowmvi" }
# Essenty (Decompose) integration
flowmvi-essenty = { module = "pro.respawn.flowmvi:essenty", version.ref = "flowmvi" }
flowmvi-essenty-compose = { module = "pro.respawn.flowmvi:essenty-compose", version.ref = "flowmvi" } 
Gradle DSL
dependencies {
    val flowmvi = "< Badge above 👆🏻 >"
    // Core KMP module
    commonMainImplementation("pro.respawn.flowmvi:core:$flowmvi")
    // compose multiplatform
    commonMainImplementation("pro.respawn.flowmvi:compose:$flowmvi")
    // saving and restoring state
    commonMainImplementation("pro.respawn.flowmvi:savedstate:$flowmvi")
    // essenty integration
    commonMainImplementation("pro.respawn.flowmvi:essenty:$flowmvi")
    commonMainImplementation("pro.respawn.flowmvi:essenty-compose:$flowmvi")
    // testing DSL
    commonTestImplementation("pro.respawn.flowmvi:test:$flowmvi")
    // android integration
    androidMainImplementation("pro.respawn.flowmvi:android:$flowmvi")
    // remote debugging client
    androidDebugImplementation("pro.respawn.flowmvi:debugger-plugin:$flowmvi")
}

🚀 Why FlowMVI?

Usually architecture frameworks mean boilerplate and support difficulty for marginal benefits of "clean code". FlowMVI does not dictate what your code should do or look like. Instead, this library focuses on building a supporting infrastructure to enable new possibilities for your app.

Here's what you get:

  • Powerful Plug-In system to automate processes and reuse any business logic you desire
    • Create automatic analytics handlers, websocket connections, error handling mechanisms, or anything else once and reuse them throughout your whole project automatically
  • Automatically recover from any errors and report them to analytics.
  • Build fully async, reactive and parallel apps - with no manual thread synchronization required!
  • Create multiplatform business logic components with pluggable UI
  • Automatic multiplatform system lifecycle handling
  • Out of the box debugging, logging, caching and long-running tasks support
  • Debounce, retry, batch, throttle, conflate, monitor, modify any operations automatically
  • Compress, persist, and restore state automatically on any platform
  • No base classes, complicated interfaces, or factories of factories - logic is declarative and built with a DSL
  • Build Restartable, reusable business logic components with no external dependencies or dedicated lifecycles
  • Create compile-time safe state machines with a readable DSL. Forget about casts, inconsistent states, and nulls
  • First class Compose Multiplatform support optimized for performance and ease of use
  • Use both MVVM+ (functional) or MVI (model-driven) style of programming
  • Share, distribute, disable, manage side-effects based on your team's needs
  • Dedicated IDE Plugin for debugging and codegen and app for Windows, Linux, MacOS
  • Integration with popular libraries, such as Decompose (Essenty)
  • The core library has no dependencies except kotlin coroutines.
  • Core library is fully covered by hundreds of tests
  • Minimal performance overhead, equal to using a simple Channel, with regular benchmarking
  • Collect, monitor and report performance metrics automatically (upcoming).
  • Test any business logic using clean, declarative DSL.
  • Learn more by exploring the sample app in your browser

👀 How to get started?

All you have to do is:

1. Define a Contract:

sealed interface State : MVIState {
    data object Loading : State
    data class Error(val e: Exception) : State
    data class Content(val counter: Int = 0) : State
}


sealed interface Intent : MVIIntent {
    data object ClickedCounter : Intent
}

sealed interface Action : MVIAction {
    data class ShowMessage(val message: String) : Action
}

2. Declare your business logic:

val counterStore = store(initial = State.Loading, scope = coroutineScope) {

    // install plugins you need
    install(analyticsPlugin)

    // recover from errors
    recover { e: Exception ->
        updateState { State.Error(e) }
        null
    }

    // load data
    init {
        updateState {
            State.Content(counter = repository.loadCounter())
        }
    }

    // respond to events
    reduce { intent: Intent ->
        when (intent) {
            is ClickedCounter -> updateState<State.Content, _> {
                action(ShowMessage("Incremented!"))

                copy(counter = counter + 1)
            }
        }
    }
}

store.intent(ClickedCounter)

3. Scale your app

With FlowMVI, complexity does not grow no matter how many features you add. Adding a new feature is as simple as calling a function.

Advanced configuration example
class CounterContainer(
    private val repo: CounterRepository, // inject dependencies
) {
    val store = store<CounterState, CounterIntent, CounterAction>(initial = Loading) {

        configure {
            // use various side-effect strategies
            actionShareBehavior = Distribute()
            
            // checks and verifies your business logic for you
            debuggable = true

            // make the store fully async, parallel and thread-safe
            parallelIntents = true
            coroutineContext = Dispatchers.Default
            stateStrategy = Atomic()
        }

        // out of the box logging
        enableLogging()
        
        // debug using the IDE plugin
        enableRemoteDebugging()

        // undo / redo any operation
        val undoRedo = undoRedo()

        // manage long-running jobs
        val jobManager = manageJobs<CounterJob>()

        // save and restore the state automatically
        serializeState(
            path = repo.cacheFile("counter"),
            serializer = DisplayingCounter.serializer(),
        )

        // perform long-running tasks on startup
        init {
            repo.startTimer()
        }

        // save resources when there are no subscribers
        whileSubscribed {
            repo.timer.collect {
                updateState<DisplayingCounter, _> {
                    copy(timer = timer)
                }
            }
        }

        // lazily evaluate and cache values, even when the method is suspending.
        val pagingData by cache {
            repo.getPagedDataSuspending()
        }

        // testable reducer as a function
        reduce { intent: CounterIntent ->
            when (intent) {
                // typed state update prevents races and allows using sealed class hierarchies for LCE
                is ClickedCounter -> updateState<DisplayingCounter, _> {
                    copy(counter = counter + 1)
                }
            }
        }

        // cleanup resources
        deinit {
            repo.stopTimer()
        }

        // and 30+ more options to choose from...
    }
}

Extend your logic with Plugins

Powerful DSL allows you to hook into various events and amend any part of your logic:

fun analyticsPlugin(analytics: Analytics) = plugin<MVIState, MVIIntent, MVIAction> {
    onStart {
        analytics.logScreenView(config.name) // name of the screen
    }
    onIntent { intent ->
        analytics.logUserAction(intent.name)
    }
    onException { e ->
        analytics.logError(e)
    }
    onSubscribe {
        analytics.logEngagementStart()
    }
    onUnsubscribe {
        analytics.logEngagementEnd()
    }
    onStop {
        analytics.logScreenLeave()
    }
}

Never write analytics, debugging, or state persistence code again.

Compose Multiplatform Support

badge badge badge badge badge badge

Using FlowMVI with Compose is a matter of one line of code:

@Composable
fun CounterScreen() {
    val store = counterStore

    // subscribe to store based on system lifecycle - on any platform
    val state by store.subscribe { action ->
        when (action) {
            is ShowMessage -> /* ... */
        }
    }

    when (state) {
        is DisplayingCounter -> {
            Button(onClick = { store.intent(ClickedCounter) }) {
                Text("Counter: ${state.counter}")
            }
        }
    }
}

Enjoy testable UI and free @Previews.

Android Support

No more subclassing ViewModel. Use generic StoreViewModel instead and make your business logic multiplatform.

val module = module { // Koin example
    factoryOf(::CounterContainer)
    viewModel(qualifier<CounterContainer>()) { StoreViewModel(get<CounterContainer>()) }
}

class ScreenFragment : Fragment() {

    private val vm by viewModel(qualifier<CounterContainer>())

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        subscribe(vm, ::consume, ::render)
    }

    private fun render(state: CounterState) {
        // update your views
    }

    private fun consume(action: CounterAction) {
        // handle actions
    }
}

Testing DSL

Finally stop writing UI tests and replace them with unit tests:

Test Stores

store.subscribeAndTest {
    // turbine + kotest example
    ClickedCounter resultsIn {
        states.test {
            awaitItem() shouldBe State(counter = 1)
        }
        actions.test {
            awaitItem() shouldBe ShowMessage
        }
    }
}

Test plugins

val timer = Timer()
timerPlugin(timer).test(Loading) {

    onStart()

    // time travel keeps track of all plugin operations for you
    assert(timeTravel.starts == 1) 
    assert(state is DisplayingCounter)
    assert(timer.isStarted)

    onStop(null)

    assert(!timer.isStarted)
}

Debugger IDE Plugin + App

IDE plugin generates code and lets you debug and control your app remotely: Plugin

https://github.com/user-attachments/assets/05f8efdb-d125-4c4a-9bda-79875f22578f

People love the library:

Star History Chart

Ready to try?

Begin by reading the Quickstart Guide.


License

   Copyright 2022-2024 Respawn Team and contributors

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.