Master Kotlin Multiplatform with Decompose and MVI

Yeldar Nurpeissov
9 min readJul 27, 2024

--

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

https://arkivanov.github.io/MVIKotlin/store.html
  1. Store: The core container for business logic.
  2. Bootstrapper: Initializes the Store, useful for initial loading or data source subscriptions.
  3. Executor: Handles business logic and asynchronous operations.
  4. Reducer: Creates new states.
  5. Intent: UI events, such as button clicks or input changes.
  6. State: Represents the current state of the business logic.
  7. Label: One-time events, useful for navigation or showing alert dialogs.
  8. Action: Events occurring inside the Store, produced by Bootstrapper and Executor, handled by Executor.
  9. 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

  1. Download the templates from here.
  2. Import templates following this guide.
  3. 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,

)

}
  1. Receive all posts as an observable stream.
  2. Receive a specific post by ID as an observable stream.
  3. 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

}

}
  1. Store all posts in a MutableStateFlow for demonstration.
  2. Add delay to simulate long processing.
  3. Add delay to simulate long processing.
  4. 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

}

}

}
  1. Define a Store interface for the list of posts, specifying only the State.
  2. Create a State class containing a list of posts.
  3. Create a factory class for ListStore with a create method.
  4. Reference StoreFactory instance from MVIKotlin to create the Store implementation.
  5. Define the store creation process.
  6. Define an action to notify the executor of new items.
  7. Define a message to notify the reducer to update the state.
  8. Subscribe to post updates and dispatch actions.
  9. Forward new item actions to the reducer.
  10. 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,

)

}

}

}
  1. Get reference to ListStoreFactory instance.
  2. Create and store ListStore instance in InstanceKeeper.
  3. 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)

}

}

}
  1. State as a sealed interface.
  2. Receive post ID.
  3. Set Loading as Store’s initial state.
  4. Pass ID to bootstrapper to load post.
  5. Dispatch exceptions to executor.
  6. 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)

}

}

}
  1. Define generic parameters.
  2. Intents for UI events.
  3. Label for one-time events.
  4. Create a new post.
  5. 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,

)

}

}
  1. Create a new coroutine scope bound to the component lifecycle.
  2. Subscribe to labels.
  3. On post creation event, finish the component.
  4. 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.

--

--