Awesome
FlowMVI is a Kotlin Multiplatform architectural framework based on coroutines with an extensive feature set, powerful plugin system and a rich DSL.
Quickstart:
<details> <summary>Version catalogs</summary>[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" }
</details>
<details>
<summary>Gradle DSL</summary>
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")
}
</details>
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
null
s - 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 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?
<details> <summary>Define a contract</summary>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
}
</details>
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:
@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 App
<img src="docs/images/debugger.gif" width="1200" alt="">Ready to try? Start with reading the Quickstart Guide.
Star History
<a href="https://star-history.com/#respawn-app/flowmvi&Date"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=respawn-app/flowmvi&type=Date&theme=dark" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=respawn-app/flowmvi&type=Date" /> <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=respawn-app/flowmvi&type=Date" /> </picture> </a>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.