Andoroid MVI Example: Difference between revisions
Line 17: | Line 17: | ||
*orbit 7.2.0 | *orbit 7.2.0 | ||
I also had to add the plugin for serialization with | I also had to add the plugin for serialization with | ||
<syntaxhighlight lang="groovy" highlight=" | <syntaxhighlight lang="groovy" highlight="5"> | ||
plugins { | plugins { | ||
alias(libs.plugins.android.application) | alias(libs.plugins.android.application) |
Revision as of 06:15, 8 March 2025
Introduction
Wanted to revisit the MVI pattern to just have another look using another approach this example uses ktor, an asyncronous client, where I was previously using Retrofit, and Oribit MVI which provides the container and the Store elements. (See Below)
The Pattern
Last time I looked at this we had this diagram
For this example I will be using this one
Setup
The Good
You can do the following and it seems to work which is good
implementation("org.orbit-mvi:orbit-core:<latest-version>")
The Bad
What I did not realize is that the serialization and Orbit seem to have change since the demo. To get them to work I had to use the following
- ktor 3.1.1
- orbit 7.2.0
I also had to add the plugin for serialization with
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.1.10"
}
The Ugly
So got it working with the following
implementation(libs.orbit.core)
implementation(libs.orbit.compose)
implementation(libs.orbit.viewmodel)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
Resource
This is a class which is called in our case DataState but lots of people call this Resource. In this class we create a function for each state we are managing and the data we need to create this State.
package nz.co.bibble.mviexample
sealed class DataState<T> {
data class Loading<T>(val isLoading: Boolean) : DataState<T>()
data class Success<T>(val data: T) : DataState<T>()
data class Error<T>(val uiComponent: UIComponent) : DataState<T>()
}
sealed class UIComponent {
data class Total(val text: String) : UIComponent(),
}
Post API
This not part of MVI, it is a service used for the data. Previously I have used Retrofit to do this job and it would live in the Data Layer.
Post API Interface
Here we create an interface which would normally be in the Domain Layer. It has a companion object to create the http client, this would normally be injected using Dagger or some other DI.
interface PostApi {
suspend fun getPosts(): List<Post>
companion object {
val httpClient = HttpClient(Android) {
install(ContentNegotiation) {
Json {
this.ignoreUnknownKeys = true
}
}
}
fun providePostApi(): PostApi {
return PostApiImpl(httpClient)
}
}
}
Post API Implementation
And here is the implementation
class PostApiImpl(
private val httpClient :HttpClient
):PostApi {
override suspend fun getPosts(): List<Post> {
return httpClient.get(
"https://jsonplaceholder.typicode.com/posts"
).body()
}
}
Use Case
This is the use case to get the posts
class GetPosts(
private val postApi: PostApi
) {
fun execute(): Flow<DataState<List<Post>>> {
return flow {
emit(DataState.Loading(true))
try {
val posts = postApi.getPosts()
emit(DataState.Success(posts))
} catch (e: Exception) {
e.printStackTrace()
emit(DataState.Error(UIComponent.Total("Failed to get posts")))
}
finally {
emit(DataState.Loading(false))
}
}
}
}
MVI Components
View State
We need to hold the state of the view so we make a data class to hold this. This is like the state in react.
class PostViewState {
val progressBar: Boolean = false
val posts: List<Post> = emptyList()
val error: String? = null
}
ViewModel
Simple ViewModel
Remember this section is just for this part.
Before providing the view model for the example let just look at a simple example. Here are the components that make up the ViewModel when using the Orbit MVI and ContainerHost
- View: The UI layer that displays the current state and sends user actions (intents) to the ViewModel. The View observes the state and reacts to it.
- ViewModel: Implements the ContainerHost interface and manage the state and side effects. It processes intents from the View, updates the state, and handles side effects.
- State: A data class that represents the current state of the UI. It is immutable and can only be modified by the ViewModel.
- Intent: Represents user actions or events that trigger state changes or side effects. Intents are handled by the ViewModel.
- Side Effects: Actions that do not directly affect the state, such as navigation, showing a toast, or logging. They are managed separately to keep the state management clean.
The side effect was perhaps the thing I struggle with but really it just something we my like to construct as a consequence of the state changing but does not influence the state.
This is very similar to the way Redux works in that you make a reducer for each state the model is managing. Here is a simple example.
data class MyState(val count: Int = 0)
We can modify the status with a view model like this.
class MyViewModel : ViewModel(), ContainerHost<MyState, Nothing> {
override val container = container<MyState, Nothing>(MyState())
fun increment() = intent {
reduce {
state.copy(count = state.count + 1)
}
}
fun decrement() = intent {
reduce {
state.copy(count = state.count - 1)
}
}
}
We create our ViewModel and for each State we have we define what should happen.
Our Example ViewModel
So here is the View Model. This initiates the use case. In our case this is getPosts(). The Post API would normally be injected into to Data Layer.
class PostViewModel: ViewModel(), ContainerHost<PostViewState, UIComponent > {
val getPosts = GetPosts(PostApi.providePostApi())
override val container: Container<PostViewState, UIComponent> = container(PostViewState())
fun getPosts() {
intent {
val posts = getPosts.execute()
posts.onEach { dataState ->
when (dataState) {
is DataState.Loading -> {
reduce {
state.copy(progressBar = dataState.isLoading)
}
}
is DataState.Success -> {
reduce {
state.copy(posts = dataState.data)
}
}
is DataState.Error -> {
when (dataState.uiComponent) {
is UIComponent.Toast -> {
reduce {
state.copy(error = dataState.uiComponent.text)
}
}
}
}
}
}
}
}
}
The View
This is now really easy thanks to Compose. No more xml.