Skip to content

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

License

Notifications You must be signed in to change notification settings

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?

  • 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
  • Build fully async, reactive and parallel apps - with no manual thread synchronization required!
  • Create multiplatform business logic components with pluggable UI using 0 platform code
  • Automatically recover from any errors and prevent crashes
  • Automatic multiplatform system lifecycle handling
  • Out of the box debugging, logging, testing, undo/redo, caching and long-running tasks support
  • 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
  • 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 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, or disable side-effects based on your team's needs
  • Dedicated remote debugger IDEA/AS plugin and app for Windows, Linux, MacOS
  • The core library depends on kotlin coroutines. Nothing else
  • Integration with popular libraries, such as Decompose (Essenty)
  • Core library is fully covered by tests
  • Learn more by exploring the sample app in your browser

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> {
    
    onStart { }

    onStop { }

    onIntent { intent -> }

    onState { old, new -> }

    onAction { action -> }

    onSubscribe { subs -> }

    onUnsubscribe { subs -> }

    onException { e -> }

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

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 { 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 Plugin + App

Debugger.mp4

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.