Infrastructure
FlowMVI is a Kotlin Multiplatform architectural framework based on coroutines with an extensive feature set, powerful plugin system and a rich DSL.
[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" }
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")
}
null
ssealed 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() }
}
}
}
store.subscribe(
scope = coroutineScope,
consume = { action -> /* process side effects */ },
render = { state -> /* render states */ },
)
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" }
}
@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}")
}
}
}
}
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
}
}
counterStore().subscribeAndTest {
// turbine + kotest example
ClickedCounter resultsIn {
states.test {
awaitItem() shouldBe DisplayingCounter(counter = 1, timer = 0)
}
actions.test {
awaitItem().shouldBeTypeOf<ShowMessage>()
}
}
}
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)
}
Ready to try? Start with reading the Quickstart Guide.
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.