Skip to content

respawn-app/FlowMVI

Repository files navigation

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

badge badge badge badge badge badge badge badge badge badge badge

FlowMVI is a Kotlin Multiplatform architectural framework based on coroutines with an extensive feature set, powerful plugin system and a rich DSL.

Quickstart:

  • Sample App badge-wasm: Static Badge
  • Documentation: Docs
  • KDoc: Javadoc
  • Latest version: Maven Central
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?

  • Fully async and parallel business logic - with no manual thread synchronization required!
  • Automatically recover from any errors and avoid runtime crashes with one line of code
  • Automatic platform-independent system lifecycle handling
  • Build fully-multiplatform business logic with pluggable UI
  • Out of the box debugging, logging, testing and long-running task management support
  • Restartable, reusable stores with no external dependencies or dedicated lifecycles.
  • Compress, persist, and restore state automatically with a single line of code - on any platform
  • Create compile-time safe state machines with a readable DSL. Forget about state as? ... casts
  • Decompose stores into plugins, split responsibilities, and modularize the project easily
  • No base classes, complicated interfaces or factories of factories - store is built using a simple DSL
  • Use both MVVM+ (functional) or MVI (model-driven) style of programming
  • Share, distribute, or disable side-effects based on your team's needs
  • Dedicated remote debugger app for Windows, Linux, MacOS.
  • Built for Compose: The library lets you achieve the best performance with Compose out of the box.
  • The core library depends on kotlin coroutines. Nothing else.
  • Integration with popular libraries, such as Decompose (Essenty)
  • 60+% unit test coverage of core library code

How does it look?

Define a contract
sealed interface CounterState : MVIState {
    data object Loading : CounterState
    data class Error(val e: Exception) : CounterState

    @Serializable
    data class DisplayingCounter(
        val timer: Int,
        val counter: Int,
    ) : CounterState
}

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

sealed interface CounterAction : MVIAction {
    data class ShowMessage(val message: String) : CounterAction
}
class CounterContainer(
    private val repo: CounterRepository,
) {
    val store = store<CounterState, CounterIntent, CounterAction>(initial = Loading) {

        configure {
            actionShareBehavior = ActionShareBehavior.Distribute()
            debuggable = true

            // makes the store fully async, parallel and thread-safe
            parallelIntents = true
            coroutineContext = Dispatchers.Default
            atomicStateUpdates = true
        }

        enableLogging()
        enableRemoteDebugging()

        // allows to undo any operation
        val undoRedo = undoRedo()

        // manages long-running jobs
        val jobManager = manageJobs()

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

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

        // handles any errors
        recover { e: Exception ->
            action(ShowMessage(e.message))
            null
        }

        // hooks into subscriber lifecycle
        whileSubscribed {
            repo.timer.collect {
                updateState<DisplayingCounter, _> {
                    copy(timer = timer)
                }
            }
        }

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

        reduce { intent: CounterIntent ->
            when (intent) {
                is ClickedCounter -> updateState<DisplayingCounter, _> {
                    copy(counter = counter + 1)
                }
            }
        }

        // builds custom plugins on the fly
        install {
            onStop { repo.stopTimer() }
        }
    }
}

Subscribe one-liner:

store.subscribe(
    scope = coroutineScope,
    consume = { action -> /* process side effects */ },
    render = { state -> /* render states */ },
)

Plugins:

Powerful DSL allows to hook into store events and amend any store's logic with reusable plugins.

val counterPlugin = lazyPlugin<CounterState, CounterIntent, CounterAction> {

    // access the store configuration
    if (config.debuggable) config.logger(Debug) { "Store is debuggable" }

    onStart { }

    onStop { }

    onIntent { intent -> }

    onState { old, new -> }

    onAction { action -> }

    onSubscribe { subs -> }

    onUnsubscribe { subs -> }

    onException { e -> }
}

Compose Multiplatform:

badge badge badge badge badge badge

@Composable
fun CounterScreen() {
    val store = inject<CounterContainer>().store

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

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

Android support:

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

val module = module {
    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

Test Stores

counterStore().subscribeAndTest {

    // turbine + kotest example
    ClickedCounter resultsIn {
        states.test {
            awaitItem() shouldBe DisplayingCounter(counter = 1, timer = 0)
        }
        actions.test {
            awaitItem().shouldBeTypeOf<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 App

Ready to try? Start with reading the Quickstart Guide.

Star History

Star History Chart

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.