Minimalistic MVI implementation for Kotlin Multiplatform.
Key highlights:
- Easy to write and maintain: Start from the template and build your way through. Scheduling declarations like
RunIfNotRunning
andCancelCurrentThenRun
ensure safety when executing multiple intents of the same type. If the file gets too big, the components can be easily split across multiple files. - Easy to navigate: Just use your IDE's Go to Definition action to jump easily between sections, call sites and declaration sites - there is no intermediate interface.
- Easy to document: Definitions live with the declarations, so documentation has to be done only in one place.
- Easy to test: You can test the viewmodel altogether as well as individual intents.
- Easy to use in UI and previews: You just pass the
state
and thesendIntent
function in@Composable
s. No need for more callbacks orState
variables. Also, since it's always just one state that contains default values, it's trivial to create previews for different cases. - Easy to observe and debug: With the provided callbacks you can always respond to sent intents, state changes and unhandled errors, wherever they originate from - at runtime as well as during debugging.
- Easy to customize and upgrade: The simple design creates room for custom extensions and components, while the dependency on just two libraries makes it easy to upgrade at your own pace: Kotlin Standard Library and Kotlinx Coroutines.
Note
The library implementation is stable and safe to use in production.
API is still unstable and might change with newer versions, but there is no need to update.
Tip
At the moment, only JVM and Android artifacts are provided.
Support for the other platforms will be added soon.
In build.gradle.kts
add:
dependencies {
implementation("ro.horatiu-udrea:mvi:0.1.0")
}
In libs.versions.toml
add:
[versions]
mvi = "0.1.0"
[libraries]
mvi = { module = "ro.horatiu-udrea:mvi", version.ref = "mvi" }
and in build.gradle.kts
add:
dependencies {
implementation(libs.mvi)
}
Find full implementation in android-demo sources and tests for the components in android-demo tests.
A template for Android Studio can be found here.
// Use "Go to definition" to easily navigate sections
typealias S = ProductsState
typealias I = ProductsIntent
typealias D = ProductsDependencies
class ProductsViewModel(dependencies: D) : MVIViewModel<S, I, D>(initialState = ProductsState(), dependencies) {
// Put a breakpoint on the line with "Unit" and observe all received intents
// You can also send intents here, for example when you want to start associated processes
override fun onIntent(
intent: I,
sendIntent: (I) -> Unit
) = Unit
// Put a breakpoint on the line with "Unit" and observe all state changes
// Can also send intents for changes that can occur from multiple sources
override fun onStateChange(
description: String,
sourceIntent: I,
oldState: S,
newState: S,
sendIntent: (I) -> Unit
) = Unit
// Override this for debugging or reporting critical errors to users via an intent
override fun onException(
intent: I,
exception: Throwable,
sendIntent: (I) -> Unit
) {
super.onException(intent, exception, sendIntent)
}
}
data class ProductsState(
val products: List<Product> = emptyList(),
val productsLoading: Boolean = false,
val purchaseError: Boolean = false
)
data class Product(val id: Int, val name: String, val price: Double)
sealed interface ProductsIntent : IntentHandler<S, I, D> {
data object RefreshProducts : I, RunIfNotRunning<S, I, D>({ state ->
// Change the state and provide a description
state.change("Products are loading") { oldState -> oldState.copy(productsLoading = true) }
// Access dependencies from scope
val products = getProductsUseCase()
// Change the state and provide a description
state.change("Products updated") { oldState ->
oldState.copy(products = products, productsLoading = false)
}
})
data class BuyProduct(val product: Product) : I, Run<S, I, D>(run@{ state ->
// Access the current state and use it.
// Do this only when you need up-to-date info,
// otherwise include values in the intent data class and send them from UI
val productNumber = state.read("Read current number of products") { it.products.size }
trackProductNumberUseCase(productNumber)
val successful = buyProductUseCase(product)
if (!successful) {
// Change state to loading products, no description provided when it's trivial
state.change("Product purchase not successful") { it.copy(purchaseError = true) }
return@run
}
// Express the reason for which the state was not changed
state.keep("Bought product, refreshing products now")
// Use other intents if needed. Does not suspend, only schedules intent handling.
state.schedule(RefreshProducts)
})
data object DismissPurchaseError : I, Run<S, I, D>({ state ->
// Change state with no description provided when it's trivial
state.change { it.copy(purchaseError = false) }
})
}
// Group all dependencies here. This can be injected using your favorite DI tool.
class ProductsDependencies(
val getProductsUseCase: suspend () -> List<Product>,
val buyProductUseCase: suspend (Product) -> Boolean,
val trackProductNumberUseCase: suspend (Int) -> Unit
)