Jetpack Compose : MVI Architecture with Retrofit2, Dagger-Hilt (Full Guide)

Hello Composers !!šŸ‘‹šŸ», in this blog, weā€™ll learn what is app architecture and why it is important while developing apps. which is the best app architecture with a real example using Retrofit2, Dagger-Hilt, with the latest tech stack.

What is App Architecture? šŸ 

App architecture is like the blueprint of a house šŸ , but for a mobile or web application šŸ“±šŸŒ. Itā€™s the structural design that defines how all the components of an app interact and work together. Just as a well-designed house is crucial for comfort and functionality, a thoughtfully planned app architecture is essential for building a successful and scalable application.

Why is App Architecture needed? šŸ¤”

just as a solid foundation is crucial for a sturdy house, a well-defined app architecture is essential for creating a reliablemaintainable, and scalable application. Itā€™s the backbone of your app and organization as well.

List Of Top Architecture

  1. Model-View-Controller (MVC)
  2. Model-View-Presenter (MVP)
  3. Model-View-ViewModel (MVVM)
  4. Model-View-Intent (MVI)
  5. Clean Architecture

For this blog, we are using MVI(Model-View-Intent) which is currently widely used in mobile app development and very easy to implement.

What is MVI architecture?

MVI (Model-View-Intent) šŸ“Œ is a software architectural pattern used primarily in the development of user interfaces, especially in mobile and web applications. It is an evolution of the more widely known Model-View-Controller (MVC) and Model-View-ViewModel (MVVM) patterns, designed to address some of their limitations.

In the MVI architecture, the applicationā€™s components are organized as follows:

  1. Model: āœ… This represents the applicationā€™sĀ data and business logic. The Model is responsible for managing the data, making network requests, performing calculations, and maintaining the applicationā€™s state.
  2. View: āœ… The View is responsible forĀ rendering the user interfaceĀ and displaying the data to the user. It observes changes in the Model and updates the user interface accordingly. In the context of mobile app development, this often corresponds to the UI components.
  3. Intent: āœ… The Intent represents userĀ interactions or events. These can be user inputs, such as button clicks or gestures, or system events triggered by the application. Intents are dispatched to the Model, which processes them and updates the application state.

šŸ“ Note : Before Moving to Code We will get detailed knowledge of MVI folder structure.

šŸ“š Folder Structure

  1. Core: It will contain the common code some utility functions, dependency injections module, etc.
  • Common: It contains the general functions, common components, ads, etc
  • Util: Contains the Utility class required in the app
  • DI: It contains the dependency injection related stuff like modules and classes.

2. Data: it will contain data-related stuff like Network Calls, Mapper, API, Repository, Paging, Business Logic etc.

  • Local: It contains code related to the Room Database Or Any Local Database Used in the app
  • Mapper: This folder contains the mapper class which will map entity to model and vice versa.
  • Remote: Contains the API calling function and class.
  • Repository: This directory is used for the actual implementation of the abstract repository which is defined in domain folders.

3. Domain: The domain layer is responsible for encapsulating complex business logic, or simple business logic that is reused by multiple ViewModels like Usecase.

  • Model: It will contain the model class which is used by the app and the mapper class will be used to interact with DTO.
  • Repository: This directory used repository abstraction.

4. Presentation: This layer contains the Actual UI class. which will be shown to users.

Thatā€™s It. For The Folder Structure And Theory. Now Letā€™s Dive into Coding the App.


šŸ“ Note: create all above directory in your project and then start the coding. Or Fork MyšŸ“ Github Repo And āœ… Checkout to Starter Branch

Coding The MVI

We are using Dagger-Hilt for Dependency Injection. So letā€™s start with first thing first. We have created an app and assign it to <Application>tag to AndroidManifest.xmlfile.

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class App : Application() {
    override fun onCreate() {
        super.onCreate()
    }
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:name=".App"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.JetpackComposeMVIArchitecture"
        tools:targetApi="31">
        <activity
            android:name=".presentation.MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.JetpackComposeMVIArchitecture">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Add required annotation MainActivity so that we can Inject and use dependency values across the app.

šŸ“ @AndroidEntryPointMarks an Android component class to be set up for injection with the standard Hilt Dagger Android components. Currently

Creating Data Layer

We are using Retrofit2 for API calling. We make and Api Interface and Defines the required DTOs.

import com.md.jetpackcomposemviarchitecture.data.remote.dto.ImageListDto
import retrofit2.http.GET
import retrofit2.http.Query

interface ImageApi {
    @GET("search")
    suspend fun getInfiniteApiImages(
        @Query("q") q : String
    ): ImageListDto
}

šŸ“ Note: Find ImageListDto and ImageDto from my Github Repo.

Once You Define the API interface and DTOs now its time to define the Retrofit instance using dependency injection.

  • We have annotatedĀ AppModuleĀ class withĀ @ModuleĀ so whenever Dagger-Hilt need an instance it will find it from this module andĀ @InstallIn(SingletonComponent::class)Ā will ensure that Dagger creates a singleton instance for app lifetime.
  • @SingletonĀ ensues that it creates a singleton image which will be used throughout the app andĀ @ProvidesĀ will give an instance.
  • Created retrofit Intance for calling APIs.
import com.md.jetpackcomposemviarchitecture.data.remote.ImageApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideInfiniteImageApi(): ImageApi {
        return Retrofit.Builder()
            .baseUrl("https://lexica.art/api/v1/")
            .addConverterFactory(GsonConverterFactory.create())
            .client(
                OkHttpClient.Builder()
                    .connectTimeout(30, TimeUnit.SECONDS)
                    .readTimeout(30, TimeUnit.SECONDS)
                    .writeTimeout(30, TimeUnit.SECONDS)
                    .addInterceptor(HttpLoggingInterceptor().apply {
                        level = HttpLoggingInterceptor.Level.BODY
                    }).build()
            )
            .build()
            .create(ImageApi::class.java)
    }
}

Creating Repository (Domain Layer)

The domain layer will be responsible for business logic and use cases but we are not making use cases for this example you can make use cases as per your choice. we will directly use it in ViewModel

First Create a model class which we will use for the entire app rather than using the DTOs (raw data). For converting from DTO to model class we have made a mapper class which will use the Kotlin extension function concept.

data class Image(
    var id: String,
    var src: String,
    var srcSmall: String,
    var width: Int,
    var height: Int,
)

After making the model class, define mapper or converter class

fun ImageDto.toImage(): Image {
    return Image(
        id = id,
        width = width,
        height = height,
        srcSmall = srcSmall,
        src = src
    )
}

šŸ“Ā Note: Creating repository abstraction and we will use its concrete version on the data layer.


interface ImageRepository {
    fun getImages(
        text: String
    ): Flow<Resources<List<Image>>>
}

We have defined a function which will fetch data from APIs and convert it into the model using the mapper class and then use it in the app.We are using Resources Wrapper for convenient handling.

@Singleton
class ImageRepositoryImpl @Inject constructor(
    private val imageApi: ImageApi
) : ImageRepository {
    override fun getImages(text: String): Flow<Resources<List<Image>>> {
        return flow {

            emit(Resources.Loading(true))

            val remoteList = try {
                imageApi.getInfiniteApiImages(text)
            } catch (e: IOException) {
                e.printStackTrace()
                emit(Resources.Error("Could not load data"))
                null
            } catch (e: HttpException) {
                e.printStackTrace()
                emit(Resources.Error("Could not load data"))
                null
            }
            remoteList.let { listing ->
                emit(Resources.Success(data = listing?.images?.map { it.toImage() }))
                emit(Resources.Loading(false))
            }
        }
    }
}
  • We are returningĀ flow{}Ā of List so that we can handle itĀ asynchronouslyĀ and apply converting functions likeĀ map{}Ā etc.
  • First of all returning the Loading state to true will show theĀ CircularProgressIndicatorĀ in App UI.
  • Seconds fetch the list and check if the list is not empty then convert it to a list of Raw response toĀ Model Class.
  • and make loading false to stop showingĀ CircularProgressIndicator
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindImageRepository(
        imageRepositoryImpl: ImageRepositoryImpl
    ): ImageRepository
}
  • @BindsĀ methods are a drop-in replacement for Provides methods that simply return an injected parameter.Ā @BindsĀ methods are abstract methods without implementations
  • You can easily test by passingĀ FakeRepositoryĀ without changing the actual logic in ViewModel.

Creating UI Layer

We gonna create an actual UI which will consume the API using ViewModel and Dependency Injection.

Let’s First Define the View Model And Other Components required for showing UI.

@HiltViewModel
class HomeScreenViewModel @Inject constructor(
    private val imageRepository: ImageRepository
) : ViewModel() {

    var state by mutableStateOf(HomeScreenState())
        private set

    fun onEvent(event: HomeScreenEvents) {
        when (event) {
            is HomeScreenEvents.LoadImages -> {
                loadImages(event.q)
            }
            is HomeScreenEvents.UpdateText -> {
                updateTextField(event.q)
            }
        }
    }

    private fun updateTextField(str: String) {
        state = state.copy(text = str)
    }

    private fun loadImages(q: String) {
        viewModelScope.launch {
            imageRepository.getImages(q).collect { result ->
                when (result) {
                    is Resources.Loading -> {
                        state = state.copy(isLoading = result.isLoading)
                    }
                    is Resources.Success -> {
                        println("Data in View model  ${result.data}")
                        result.data?.let { listings ->
                            state = state.copy(
                                images = listings
                            )
                        }
                    }
                    is Resources.Error -> {
                        state = state.copy(isLoading = false)
                    }
                }
            }
        }
    }
}

šŸ“š Explanation

  • Firstly we have made a class andĀ annotatedĀ it withĀ @HiltViewModelĀ to enable it to use dependency andĀ @InjectĀ to fetch RepositoryĀ Instances.
  • Defined aĀ StateĀ instance which we will use throughout the screen for fetching the data and set private so that only modified by theĀ ViewModel.
  • onEventĀ the function is responsible for providing the event based on it we will operate Search, Update, Delete etc.
  • loadImagesĀ the function will fetch data from the repository and update theĀ stateĀ accordingly.

Itā€™s time to make a complete Screen

@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(state: HomeScreenState, onEvent: (HomeScreenEvents) -> Unit) {

    val keyboardManager = LocalSoftwareKeyboardController.current

    Scaffold(topBar = {
        TopAppBar(
            title = {
                Text(text = "MVI App", color = MaterialTheme.colorScheme.onPrimary)
            },
            colors = TopAppBarDefaults.topAppBarColors(
                containerColor = MaterialTheme.colorScheme.primary
            )
        )
    }) { pv ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(pv),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            OutlinedTextField(
                placeholder = {
                    Text(text = "Enter image prompt")
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                value = state.text, onValueChange = {
                    onEvent(HomeScreenEvents.UpdateText(it))
                },
                keyboardActions = KeyboardActions(
                    onDone = {
                        keyboardManager?.hide()
                        onEvent(HomeScreenEvents.LoadImages(state.text))
                    }
                ),
                keyboardOptions = KeyboardOptions(
                    imeAction = ImeAction.Done
                ),
                trailingIcon = {
                    if(state.text.isNotEmpty()){
                        IconButton(onClick = {
                            onEvent(HomeScreenEvents.UpdateText(""))
                        }) {
                            Icon(imageVector = Icons.Filled.Clear, contentDescription = "Clear")
                        }
                    }
                }
            )

            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .weight(1f),
                contentAlignment = Alignment.Center
            ) {
                if (state.isLoading) {
                    CircularProgressIndicator()
                } else if (state.images.isEmpty()) {
                    Text(text = "No images found")
                } else {
                    MyListView(images = state.images)
                }

            }
        }
    }
}

Here is our main screen letā€™s break it down so easy to understand what it does.

šŸ’”Tips : We are using Bets Practices of Updating The TextField State using ViewModel

šŸ“š Explanation

  • ScaffodĀ will hold theĀ TopAppbarĀ and our complete content inside it.
  • We have two main components in the screenĀ OutlineTextFieldĀ which will be used to give input andĀ LazyVerticalGridĀ to show the result in Grid form.
  • StateĀ onEventĀ these two variables are responsible for showing UI based on the state and when the user interacts with UI pass the events to theĀ ViewModelĀ respectively.
  • WhenĀ state.isLoading == trueĀ we gonna showĀ CircularoProgressIndicatorĀ and if No item is found we gonna show the Text else we will show theĀ LazyVerticalGridĀ with fetched items.
@Composable
fun MyListView(
    images: List<Image>
) {
    LazyVerticalGrid(
        contentPadding = PaddingValues(4.dp),
        columns = GridCells.Fixed(2),
        modifier = Modifier.fillMaxSize()
    ) {
        items(images, key = {
            it.id
        }) { img ->
            ImageItem(img.srcSmall)
        }
    }
}

@Composable
fun ImageItem(url: String) {
    Box(
        modifier = Modifier
            .padding(6.dp)
            .fillMaxWidth()
            .clip(shape = RoundedCornerShape(12.dp))
            .background(color = MaterialTheme.colorScheme.onPrimaryContainer),
    ) {
        Image(
            painter = rememberAsyncImagePainter(model = url),
            contentDescription = "",
            contentScale = ContentScale.FillWidth,
            modifier = Modifier
                .fillMaxWidth()
                .height(170.dp)
        )
    }
}

Here is the LazyGrid and Item which will be used for single-item editing.

āœ… ITā€™S DONE āœ…

āš ļø Note: Here I have just made a demo app for showing purposes only use can change some of the class structure as per your choice or requirements.

šŸ“Check out the complete code on my GitHub Project. āœļø Hope this project helps you. Hope you enjoy coding Jetpack Compose šŸ˜. Donā€™t forget to share šŸ“Ø and clap šŸ‘.

Any Suggestions are welcome. If you need any help or have questions for Code Contact Me. You can follow me on LinkedInStackOverflow and Twitter For More Updates šŸ””

Happy Compose !! šŸš€

https://github.com/chiragthummar/JetpackComposeMVIArchitecture

References

  1. https://lexica.art/docs
  2. https://developer.android.com/jetpack/compose/architecture
  3. https://developer.android.com/jetpack/compose/compositionlocal
  4. https://medium.com/androiddevelopers/effective-state-management-for-textfield-in-compose-d6e5b070fbe5

Leave a Reply

Your email address will not be published. Required fields are marked *