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 reliable, maintainable, and scalable application. Itās the backbone of your app and organization as well.
List Of Top Architecture
- Model-View-Controller (MVC)
- Model-View-Presenter (MVP)
- Model-View-ViewModel (MVVM)
- Model-View-Intent (MVI)
- 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:
- 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.
- 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.
- 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
- 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.xml
file.
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.
š
@AndroidEntryPoint
Marks 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
andImageDto
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 LinkedIn, StackOverflow and Twitter For More Updates š
Happy Compose !! š
https://github.com/chiragthummar/JetpackComposeMVIArchitecture
References