Master Kotlin Multiplatform with Decompose and MVI
Introduction
In this article, we will enhance our app by integrating MVIKotlin and making posts observable using coroutines flow.
What is MVIKotlin and Why?
MVIKotlin is a Kotlin Multiplatform framework for writing shared code using the Model-View-Intent (MVI) pattern.
- It provides powerful debugging tools like logging and time travel.
- The framework’s core is independent of any reactive or coroutines library, with extensions for Reaktive and Coroutines available as separate modules.
I chose MVIKotlin because it is developed and maintained by Arkadii Ivanov, the same author of Decompose, ensuring compatibility between the two.
Familiarizing with the Framework
- Store: The core container for business logic.
- Bootstrapper: Initializes the Store, useful for initial loading or data source subscriptions.
- Executor: Handles business logic and asynchronous operations.
- Reducer: Creates new states.
- Intent: UI events, such as button clicks or input changes.
- State: Represents the current state of the business logic.
- Label: One-time events, useful for navigation or showing alert dialogs.
- Action: Events occurring inside the Store, produced by Bootstrapper and Executor, handled by Executor.
- Message: Events passed to the Reducer to update the current state.
Initial Setup
Add Dependencies
Update the libs.versions.toml file:
[versions]
essenty = "2.1.0"
mvi = "4.1.0"
[libraries]
essenty-lifecycle-coroutines = { module = "com.arkivanov.essenty:lifecycle-coroutines", version.ref = "essenty" }
mvikotlin = { module = "com.arkivanov.mvikotlin:mvikotlin", version.ref = "mvi" }
mvikotlin-main = { module = "com.arkivanov.mvikotlin:mvikotlin-main", version.ref = "mvi" }
mvikotlin-logging = { module = "com.arkivanov.mvikotlin:mvikotlin-logging", version.ref = "mvi" }
mvikotlin-timetravel = { module = "com.arkivanov.mvikotlin:mvikotlin-timetravel", version.ref = "mvi" }
mvikotlin-extensions-coroutines = { module = "com.arkivanov.mvikotlin:mvikotlin-extensions-coroutines", version.ref = "mvi" }
Update the build.gradle.kts inside composeApp:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.essenty.lifecycle.coroutines)
implementation(libs.mvikotlin)
implementation(libs.mvikotlin.extensions.coroutines)
implementation(libs.mvikotlin.logging)
implementation(libs.mvikotlin.timetravel)
}
}
}
IDEA Live Templates
- Download the templates from here.
- Import templates following this guide.
- Restart IDEA.
Helper Extension
Add the following helper extension to transform Store into Value (observable):
fun <T : Any> Store<*, T, *>.asValue(): Value<T> =
object : Value<T>() {
override val value: T get() = state
override fun subscribe(observer: (T) -> Unit): Cancellation {
val disposable = states(observer(onNext = observer))
return Cancellation {
disposable.dispose()
}
}
}
Updating the Repository
Interface
Update the PostRepository interface:
interface PostRepository {
fun getAllPosts(): Flow<List<Post>> // 1
fun getPost(id: String): Flow<Post> // 2
// 3
suspend fun createPost(
title: String,
description: String,
author: String,
)
}
- Receive all posts as an observable stream.
- Receive a specific post by ID as an observable stream.
- Create a new post.
Default Implementation
Update DefaultPostRepository:
class DefaultPostRepository : PostRepository {
// 1
private val posts = MutableStateFlow(List(16) {
Post(
id = it.toString(),
title = "Title-#$it",
description = "Description-#$it",
author = "Author-#$it",
)
})
override fun getAllPosts(): Flow<List<Post>> = posts
override fun getPost(
id: String
): Flow<Post> = posts
.map { it.first { post -> post.id == id } }
.onEach { delay(500) } // 2
override suspend fun createPost(title: String, description: String, author: String) {
delay(500) // 3
val post = Post(
id = posts.value.size.toString(),
title = title,
description = description,
author = author,
)
posts.update { it + post } // 4
}
}
- Store all posts in a MutableStateFlow for demonstration.
- Add delay to simulate long processing.
- Add delay to simulate long processing.
- Update the MutableStateFlow with a new post.
Updating ListComponent.
Create ListStore:
internal interface ListStore : Store<Nothing, State, Any> { // 1
data class State( // 2
val items: List<Post> = emptyList()
)
}
internal class ListStoreFactory( // 3
private val storeFactory: StoreFactory, // 4
private val postRepository: PostRepository,
) {
fun create(): ListStore =
object : ListStore, Store<Nothing, State, Any> by storeFactory.create( // 5
name = "ListStore",
initialState = State(),
bootstrapper = BootstrapperImpl(postRepository),
executorFactory = ::ExecutorImpl,
reducer = ReducerImpl
) {}
private sealed interface Action {
data class NewItemsReceived(val items: List<Post>) : Action // 6
}
private sealed interface Msg {
data class UpdateItems(val items: List<Post>) : Msg // 7
}
private class BootstrapperImpl(
private val repository: PostRepository,
) : CoroutineBootstrapper<Action>() {
override fun invoke() {
scope.launch {
repository.getAllPosts()
.flowOn(Dispatchers.Default)
.collect { items ->
dispatch(Action.NewItemsReceived(items)) // 8
}
}
}
}
private class ExecutorImpl : CoroutineExecutor<Nothing, Action, State, Msg, Any>() {
override fun executeAction(action: Action) {
when (action) {
is Action.NewItemsReceived -> {
dispatch(Msg.UpdateItems(action.items)) // 9
}
}
}
}
private object ReducerImpl : Reducer<State, Msg> {
override fun State.reduce(message: Msg): State =
when (message) {
is Msg.UpdateItems -> copy(items = message.items) // 10
}
}
}
- Define a Store interface for the list of posts, specifying only the State.
- Create a State class containing a list of posts.
- Create a factory class for ListStore with a create method.
- Reference StoreFactory instance from MVIKotlin to create the Store implementation.
- Define the store creation process.
- Define an action to notify the executor of new items.
- Define a message to notify the reducer to update the state.
- Subscribe to post updates and dispatch actions.
- Forward new item actions to the reducer.
- Update the state with new items.
Update DefaultListComponent:
internal class DefaultListComponent(
componentContext: ComponentContext,
private val listStoreFactory: ListStoreFactory, // 1
private val postClicked: (postId: String) -> Unit,
private val createNewPostClicked: () -> Unit,
) : ListComponent, ComponentContext by componentContext {
private val store = instanceKeeper.getStore { listStoreFactory.create() } // 2
override val model: Value<List<Post>> = store.asValue().map { it.items } // 3
override fun onPostClicked(post: Post) = postClicked(post.id)
override fun fabClicked() = createNewPostClicked()
class Factory(
private val listStoreFactory: ListStoreFactory
) : ListComponent.Factory {
override fun invoke(
componentContext: ComponentContext,
postClicked: (postId: String) -> Unit,
createNewPostClicked: () -> Unit,
): ListComponent {
return DefaultListComponent(
componentContext = componentContext,
postClicked = postClicked,
listStoreFactory = listStoreFactory,
createNewPostClicked = createNewPostClicked,
)
}
}
}
- Get reference to ListStoreFactory instance.
- Create and store ListStore instance in InstanceKeeper.
- Map Store to Value.
Updating DetailComponent
Create DetailStore:
internal interface DetailStore : Store<Nothing, State, Any> {
sealed interface State { // 1
data object Loading : State
data class Success(val post: Post) : State
data class Error(val message: String) : State
}
}
internal class DetailStoreFactory(
private val storeFactory: StoreFactory,
private val postRepository: PostRepository,
) {
fun create(postId: String): DetailStore = // 2
object : DetailStore, Store<Nothing, State, Any> by storeFactory.create(
name = "DetailStore",
initialState = State.Loading, // 3
bootstrapper = BootstrapperImpl(
postId = postId, // 4
postRepository = postRepository,
),
executorFactory = ::ExecutorImpl,
reducer = ReducerImpl
) {}
private sealed interface Action {
data class NewPostLoaded(val post: Post) : Action
data class PostLoadFailed(val throwable: Throwable) : Action
}
private sealed interface Msg {
data class UpdatePost(val post: Post) : Msg
data class ShowError(val errorText: String) : Msg
}
private class BootstrapperImpl(
private val postId: String,
private val postRepository: PostRepository,
) : CoroutineBootstrapper<Action>() {
override fun invoke() {
scope.launch {
postRepository.getPost(postId)
.flowOn(Dispatchers.Default)
.catch { dispatch(Action.PostLoadFailed(it)) } // 5
.collect { dispatch(Action.NewPostLoaded(it)) }
}
}
}
private class ExecutorImpl : CoroutineExecutor<Nothing, Action, State, Msg, Any>() {
override fun executeAction(action: Action) {
when (action) {
is Action.NewPostLoaded -> {
dispatch(Msg.UpdatePost(action.post))
}
is Action.PostLoadFailed -> {
val errorText = action.throwable.message ?: "Failed to load the post" // 6
dispatch(Msg.ShowError(errorText))
}
}
}
}
private object ReducerImpl : Reducer<State, Msg> {
override fun State.reduce(message: Msg): State =
when (message) {
is Msg.ShowError -> State.Error(message.errorText)
is Msg.UpdatePost -> State.Success(message.post)
}
}
}
- State as a sealed interface.
- Receive post ID.
- Set Loading as Store’s initial state.
- Pass ID to bootstrapper to load post.
- Dispatch exceptions to executor.
- Handle exceptions and dispatch error to Reducer.
Update DefaultDetailComponent:
internal class DefaultDetailComponent(
componentContext: ComponentContext,
postId: String,
detailStoreFactory: DetailStoreFactory,
private val onFinished: () -> Unit,
) : DetailComponent, ComponentContext by componentContext {
private val store = instanceKeeper.getStore { detailStoreFactory.create(postId) }
override val model: Value<DetailComponent.Model> = store.asValue().map {
when (it) {
DetailStore.State.Loading -> DetailComponent.Model.Loading
is DetailStore.State.Error -> DetailComponent.Model.Error(it.message)
is DetailStore.State.Success -> DetailComponent.Model.Success(it.post)
}
}
override fun onBackPressed() = onFinished()
class Factory(
private val detailStoreFactory: DetailStoreFactory,
) : DetailComponent.Factory {
override fun invoke(
componentContext: ComponentContext,
postId: String,
onFinished: () -> Unit,
): DetailComponent = DefaultDetailComponent(
componentContext = componentContext,
postId = postId,
onFinished = onFinished,
detailStoreFactory = detailStoreFactory,
)
}
}
Updating CreateComponent
Create CreateStore:
internal interface CreateStore : Store<Intent, State, Label> { // 1
sealed interface Intent { // 2
data class ChangeTitle(val value: String) : Intent
data class ChangeDescription(val value: String) : Intent
data class ChangeAuthor(val value: String) : Intent
data object Save : Intent
}
data class State(
val title: String = "",
val description: String = "",
val author: String = "",
val canSave: Boolean = false,
val loading: Boolean = false,
)
sealed interface Label { // 3
data object PostCreated : Label
}
}
internal class CreateStoreFactory(
private val storeFactory: StoreFactory,
private val postRepository: PostRepository,
) {
fun create(): CreateStore =
object : CreateStore, Store<Intent, State, Label> by storeFactory.create(
name = "CreateStore",
initialState = State(),
executorFactory = { ExecutorImpl(postRepository) },
reducer = ReducerImpl
) {}
private sealed interface Msg {
data class TitleChanged(val title: String) : Msg
data class DescriptionChanged(val description: String) : Msg
data class AuthorChanged(val author: String) : Msg
data class EnableSaveButton(val enabled: Boolean) : Msg
data class Loading(val loading: Boolean) : Msg
}
private class ExecutorImpl(
private val postRepository: PostRepository,
) : CoroutineExecutor<Intent, Nothing, State, Msg, Label>() {
override fun executeIntent(intent: Intent) {
when (intent) {
is Intent.ChangeTitle -> {
dispatch(Msg.TitleChanged(intent.value))
dispatch(Msg.EnableSaveButton(canSave()))
}
is Intent.ChangeDescription -> {
dispatch(Msg.DescriptionChanged(intent.value))
dispatch(Msg.EnableSaveButton(canSave()))
}
is Intent.ChangeAuthor -> {
dispatch(Msg.AuthorChanged(intent.value))
dispatch(Msg.EnableSaveButton(canSave()))
}
Intent.Save -> scope.launch {
val state = state()
try {
dispatch(Msg.Loading(true))
withContext(Dispatchers.Default) {
postRepository.createPost( // 4
title = state.title,
description = state.description,
author = state.author
)
}
publish(Label.PostCreated)
} catch (e: Throwable) {
// handle error
// publish(Label.ShowAlertDialog) // 5
} finally {
dispatch(Msg.Loading(false))
}
}
}
}
private fun canSave(): Boolean = with(state()) {
title.isNotBlank() && description.isNotBlank() && author.isNotBlank()
}
}
private object ReducerImpl : Reducer<State, Msg> {
override fun State.reduce(message: Msg): State =
when (message) {
is Msg.TitleChanged -> copy(title = message.title)
is Msg.DescriptionChanged -> copy(description = message.description)
is Msg.AuthorChanged -> copy(author = message.author)
is Msg.EnableSaveButton -> copy(canSave = message.enabled)
is Msg.Loading -> copy(loading = message.loading)
}
}
}
- Define generic parameters.
- Intents for UI events.
- Label for one-time events.
- Create a new post.
- Notify of creation failure via label; show alert or navigate to screen.
Update DefaultCreateComponent:
internal class DefaultCreateComponent(
componentContext: ComponentContext,
private val createStoreFactory: CreateStoreFactory,
private val onFinished: () -> Unit,
) : CreateComponent, ComponentContext by componentContext {
private val store = instanceKeeper.getStore { createStoreFactory.create() }
init {
coroutineScope().launch { // 1
store.labels.collect { label -> // 2
when (label) {
CreateStore.Label.PostCreated -> onFinished() // 3
}
}
}
}
override val model: Value<CreateComponent.Model> = store.asValue().map {
CreateComponent.Model(
title = it.title,
description = it.description,
author = it.author,
canSave = it.canSave,
loading = it.loading,
)
}
override fun onBackPressed() = onFinished()
override fun onTitleChanged(value: String) {
store.accept(CreateStore.Intent.ChangeTitle(value)) // 4
}
override fun onDescriptionChanged(value: String) {
store.accept(CreateStore.Intent.ChangeDescription(value))
}
override fun onAuthorChanged(value: String) {
store.accept(CreateStore.Intent.ChangeAuthor(value))
}
override fun onSaveClicked() {
store.accept(CreateStore.Intent.Save)
}
class Factory(
private val createStoreFactory: CreateStoreFactory,
) : CreateComponent.Factory {
override fun invoke(
componentContext: ComponentContext,
onFinished: () -> Unit,
): CreateComponent = DefaultCreateComponent(
componentContext = componentContext,
createStoreFactory = createStoreFactory,
onFinished = onFinished,
)
}
}
- Create a new coroutine scope bound to the component lifecycle.
- Subscribe to labels.
- On post creation event, finish the component.
- Send intent to the store on UI event.
Update the dependency injection (DI) configuration.
val kodeinDI = DI {
// repository
bindSingleton<PostRepository> { DefaultPostRepository() }
// store
bindSingleton<StoreFactory> {
LoggingStoreFactory(TimeTravelStoreFactory())
}
// detail
bindSingleton<DetailComponent.Factory> {
DefaultDetailComponent.Factory(detailStoreFactory = instance())
}
bindSingleton {
DetailStoreFactory(
storeFactory = instance(),
postRepository = instance(),
)
}
// list
bindSingleton<ListComponent.Factory> {
DefaultListComponent.Factory(listStoreFactory = instance())
}
bindSingleton {
ListStoreFactory(
storeFactory = instance(),
postRepository = instance(),
)
}
// create
bindSingleton<CreateComponent.Factory> {
DefaultCreateComponent.Factory(createStoreFactory = instance())
}
bindSingleton {
CreateStoreFactory(
storeFactory = instance(),
postRepository = instance(),
)
}
// root
bindSingleton<RootComponent.Factory> {
DefaultRootComponent.Factory(
detailComponentFactory = instance(),
listComponentFactory = instance(),
createComponentFactory = instance(),
)
}
}
Run the app to see the results.
Conclusion
In this article, we successfully integrated MVIKotlin into our app, making our post list and detail components observable using coroutines flow. This setup ensures a scalable and maintainable architecture for our app.
The source code is on GitHub.
Thank you!