Posted by: Musab Alothman
I’m not going to explain how important Clean Architecture or Hilt are, there are too many posts about that. Today, I’m going to implement them in the simplest way, so you can get the idea and start using them in your own apps.
At the end I will share with you a GitHub repository for the whole project.
Now let’s get started to create a simple Android Clean Architecture project with the latest libraries.
Let’s start with Gradle and add all dependencies.
First: Project Gradle file
// Top-level build file where you can add configuration options common to all sub-projects/modules. | |
buildscript { | |
ext.kotlin_version = "1.5.10" | |
repositories { | |
google() | |
mavenCentral() | |
} | |
dependencies { | |
classpath "com.android.tools.build:gradle:4.2.1" | |
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" | |
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha' | |
} | |
} | |
allprojects { | |
repositories { | |
google() | |
mavenCentral() | |
} | |
} | |
task clean(type: Delete) { | |
delete rootProject.buildDir | |
} |
Second: Module Gradle file
plugins { | |
id 'com.android.application' | |
id 'kotlin-android' | |
id 'kotlin-kapt' | |
id 'dagger.hilt.android.plugin' | |
} | |
android { | |
compileSdkVersion 30 | |
buildToolsVersion "30.0.3" | |
defaultConfig { | |
applicationId "com.cleanarchitectkotlinflowhiltsimplestway" | |
minSdkVersion 26 | |
targetSdkVersion 30 | |
versionCode 1 | |
versionName "1.0" | |
} | |
buildTypes { | |
release { | |
minifyEnabled false | |
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' | |
} | |
} | |
compileOptions { | |
sourceCompatibility JavaVersion.VERSION_1_8 | |
targetCompatibility JavaVersion.VERSION_1_8 | |
} | |
kotlinOptions { | |
jvmTarget = '1.8' | |
} | |
} | |
dependencies { | |
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" | |
implementation 'androidx.core:core-ktx:1.5.0' | |
implementation 'androidx.appcompat:appcompat:1.3.0' | |
implementation 'com.google.android.material:material:1.3.0' | |
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' | |
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" | |
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' | |
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' | |
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' | |
implementation "androidx.fragment:fragment-ktx:1.3.5" | |
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2' | |
implementation 'com.google.dagger:hilt-android:2.35' | |
kapt 'com.google.dagger:hilt-android-compiler:2.35' | |
implementation("com.squareup.retrofit2:retrofit:2.9.0") { | |
exclude module: 'okhttp' | |
} | |
implementation "com.squareup.retrofit2:converter-gson:2.9.0" | |
implementation "com.squareup.okhttp3:okhttp:4.9.1" | |
implementation "com.squareup.okhttp3:okhttp-urlconnection:4.9.1" | |
implementation "com.squareup.okhttp3:logging-interceptor:4.9.1" | |
implementation "com.google.code.gson:gson:2.8.6" | |
} |
We will use a network calls so we need an internet permission in the manifest.
<uses-permission android:name="android.permission.INTERNET" />
And add a custom Application
subclass for the Hilt annotation we will see later on in this post.
Under the application tag in AndroidManifest.xml
add the custom app class:
android:name=".presentation.App"
Now we have everything ready let’s start adding some classes.
For Android Clean Architecture we need to sort our packages like below
As you can see we have three main layers :
Data
Here we put the logic of bringing data either from local source or server.
Domain
Here we put the logic of business: convert, filter, mix and sort raw data that comes from Data layer to be ready and easy to handle in Presentationlayer.
Presentation
Here we put the UI components and the logic that related to user interactions or navigation in order to get data from the user.
Utils
Constants
Contains constant variables, in this example we have only one constant:
const val BASE_URL = "https://reqres.in/"
Utils
package com.cleanarchitectkotlinflowhiltsimplestway.utils | |
import com.cleanarchitectkotlinflowhiltsimplestway.domain.AuthenticationException | |
import com.cleanarchitectkotlinflowhiltsimplestway.domain.NetworkErrorException | |
import com.cleanarchitectkotlinflowhiltsimplestway.domain.State | |
import retrofit2.HttpException | |
import java.net.ConnectException | |
import java.net.SocketTimeoutException | |
import java.net.UnknownHostException | |
class Utils { | |
companion object{ | |
fun resolveError(e: Exception): State.ErrorState { | |
var error = e | |
when (e) { | |
is SocketTimeoutException -> { | |
error = NetworkErrorException(errorMessage = "connection error!") | |
} | |
is ConnectException -> { | |
error = NetworkErrorException(errorMessage = "no internet access!") | |
} | |
is UnknownHostException -> { | |
error = NetworkErrorException(errorMessage = "no internet access!") | |
} | |
} | |
if(e is HttpException){ | |
when(e.code()){ | |
502 -> { | |
error = NetworkErrorException(e.code(), "internal error!") | |
} | |
401 -> { | |
throw AuthenticationException("authentication error!") | |
} | |
400 -> { | |
error = NetworkErrorException.parseException(e) | |
} | |
} | |
} | |
return State.ErrorState(error) | |
} | |
} | |
} |
The Utils class contains common used functions all over the app.
In this example we have only one function to resolve the errors from data layer, we will use it in ViewModel
DI
This class is for everything related to Hilt. I preferred to put it in one class which contains all components and modules to make it easy to reach and maintain.
In this example we are going to use only one Module for Network calls:
package com.cleanarchitectkotlinflowhiltsimplestway.utils | |
import android.content.Context | |
import com.cleanarchitectkotlinflowhiltsimplestway.data.APIs | |
import com.cleanarchitectkotlinflowhiltsimplestway.presentation.App | |
import com.google.gson.GsonBuilder | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.android.qualifiers.ApplicationContext | |
import dagger.hilt.components.SingletonComponent | |
import okhttp3.Cache | |
import okhttp3.Interceptor | |
import okhttp3.OkHttpClient | |
import retrofit2.Retrofit | |
import retrofit2.converter.gson.GsonConverterFactory | |
import java.io.File | |
import java.util.concurrent.TimeUnit | |
import javax.inject.Singleton | |
@Module | |
@Suppress("unused") | |
@InstallIn(SingletonComponent::class) | |
class NetworkModule { | |
@Singleton | |
@Provides | |
fun provideApplication(@ApplicationContext app: Context): App { | |
return app as App | |
} | |
@Provides | |
@Singleton | |
fun provideRetrofit(client: OkHttpClient): Retrofit { | |
return Retrofit.Builder().baseUrl(Constants.BASE_URL).client(client) | |
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) | |
.build() | |
} | |
private val READ_TIMEOUT = 30 | |
private val WRITE_TIMEOUT = 30 | |
private val CONNECTION_TIMEOUT = 10 | |
private val CACHE_SIZE_BYTES = 10 * 1024 * 1024L // 10 MB | |
@Provides | |
@Singleton | |
fun provideOkHttpClient( | |
headerInterceptor: Interceptor, | |
cache: Cache | |
): OkHttpClient { | |
val okHttpClientBuilder = OkHttpClient().newBuilder() | |
okHttpClientBuilder.connectTimeout(CONNECTION_TIMEOUT.toLong(), TimeUnit.SECONDS) | |
okHttpClientBuilder.readTimeout(READ_TIMEOUT.toLong(), TimeUnit.SECONDS) | |
okHttpClientBuilder.writeTimeout(WRITE_TIMEOUT.toLong(), TimeUnit.SECONDS) | |
okHttpClientBuilder.cache(cache) | |
okHttpClientBuilder.addInterceptor(headerInterceptor) | |
return okHttpClientBuilder.build() | |
} | |
@Provides | |
@Singleton | |
fun provideHeaderInterceptor(): Interceptor { | |
return Interceptor { | |
val requestBuilder = it.request().newBuilder() | |
//hear you can add all headers you want by calling 'requestBuilder.addHeader(name , value)' | |
it.proceed(requestBuilder.build()) | |
} | |
} | |
@Provides | |
@Singleton | |
internal fun provideCache(context: Context): Cache { | |
val httpCacheDirectory = File(context.cacheDir.absolutePath, "HttpCache") | |
return Cache(httpCacheDirectory, CACHE_SIZE_BYTES) | |
} | |
@Provides | |
@Singleton | |
fun provideContext(application: App): Context { | |
return application.applicationContext | |
} | |
@Provides | |
@Singleton | |
fun provideApi(retrofit: Retrofit): APIs { | |
return retrofit.create(APIs::class.java) | |
} | |
} |
SingletonComponent is ready-to-use component provided by Hilt so no need to create one.
We have finished the Hilt logic, now if we want to use it in our app there are a simple tiny things need to be added in our classes we will see them later in this post.
Data Layer
As you can see in the domain package we have sub packages:
- bodies for network calls that requires a body we put all bodies in this package.
- containers for network response bodies, we name it containers to differentiate between them and request bodies.
- enums optional class but I prefer to separate the enums to a sprit package.
- APIs this class for Retrofit calls:
package com.cleanarchitectkotlinflowhiltsimplestway.data | |
import com.google.gson.JsonObject | |
import retrofit2.http.GET | |
interface APIs { | |
@GET("api/users") | |
suspend fun sampleGet(): JsonObject | |
} |
You may notice “suspend” keyword for background thread and direct response object “JsonObject” without wrapping it with any retrofit object for this is part of flow implementation that we will see next in this post
Job Offers
Domain Layer
Contains only Use Cases. In this project we are going to need only one use case:
package com.cleanarchitectkotlinflowhiltsimplestway.domain | |
import com.google.gson.JsonObject | |
import com.cleanarchitectkotlinflowhiltsimplestway.data.APIs | |
import javax.inject.Inject | |
class SampleUseCase @Inject constructor( | |
private val apIs: APIs | |
) { | |
suspend operator fun invoke(): JsonObject { | |
val response = apIs.sampleGet() | |
//here you can add some domain logic or call another UseCase | |
return response | |
} | |
} |
“@Inject constructor” this to notify Hilt that we want to inject the variables in this constructor
Use cases can contain only one part of business logic. Keep it for single small tasks. This will be useful if you want to reuse some part of business logic in another use case, you can create a use case to call multiple use cases inside it.
Presentation Layer
State
We will use this sealed class for network calls and connection between ViewModel and UI
package com.cleanarchitectkotlinflowhiltsimplestway.presentation | |
sealed class State<out T> { | |
object LoadingState : State<Nothing>() | |
data class ErrorState(var exception: Throwable) : State<Nothing>() | |
data class DataState<T>(var data: T) : State<T>() | |
} |
NetworkExceptions.kt
package com.cleanarchitectkotlinflowhiltsimplestway.presentation | |
import org.json.JSONObject | |
import retrofit2.HttpException | |
open class NetworkErrorException( | |
val errorCode: Int = -1, | |
val errorMessage: String, | |
val response: String = "" | |
) : Exception() { | |
override val message: String | |
get() = localizedMessage | |
override fun getLocalizedMessage(): String { | |
return errorMessage | |
} | |
companion object { | |
fun parseException(e: HttpException): NetworkErrorException { | |
val errorBody = e.response()?.errorBody()?.string() | |
return try {//here you can parse the error body that comes from server | |
NetworkErrorException(e.code(), JSONObject(errorBody!!).getString("message")) | |
} catch (_: Exception) { | |
NetworkErrorException(e.code(), "unexpected error!!ً") | |
} | |
} | |
} | |
} | |
class AuthenticationException(authMessage: String) : | |
NetworkErrorException(errorMessage = authMessage) {} |
Contains Exceptions types that could comes from data layer, will use this class and parseException in Utils class where we mentioned before.
App
Our custom Application class:
package com.cleanarchitectkotlinflowhiltsimplestway.presentation | |
import android.app.Application | |
import dagger.hilt.android.HiltAndroidApp | |
@HiltAndroidApp | |
class App : Application() { | |
override fun onCreate() { | |
super.onCreate() | |
} | |
} |
As you can see ‘@HiltAndroidApp’ to tell Hilt this is our Application class
Splash
We put every part of the UI in a separate package for example in this package we have two classes:
SplashActivityViewModel
- ‘HiltViewModel’ to tell Hilt this is a ViewModel.
- “fun getSampleResponse() =” we use this shortcut in Kotlin if the function has only one line, it’s equal to “fun getSampleResponse(): Flow<State> {”
- “flow{” this is a global function to convert async to flow
As you can see just like LiveData we can emit to the collector these results.
SplashActivity
package com.cleanarchitectkotlinflowhiltsimplestway.presentation.splash | |
import android.os.Bundle | |
import android.widget.TextView | |
import androidx.activity.viewModels | |
import androidx.appcompat.app.AppCompatActivity | |
import androidx.lifecycle.lifecycleScope | |
import com.cleanarchitectkotlinflowhiltsimplestway.R | |
import com.cleanarchitectkotlinflowhiltsimplestway.presentation.State | |
import dagger.hilt.android.AndroidEntryPoint | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.flow.collect | |
import kotlinx.coroutines.launch | |
@AndroidEntryPoint | |
class SplashActivity : AppCompatActivity() { | |
private val viewModel: SplashActivityViewModel by viewModels() | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_splash) | |
val textView = findViewById<TextView>(R.id.textView1) | |
lifecycleScope.launch { | |
delay(500) | |
viewModel.getSampleResponse() | |
.collect { | |
when (it) { | |
is State.DataState -> textView.text = "success ${it.data}" | |
is State.ErrorState -> textView.text = "error ${it.exception}" | |
is State.LoadingState -> textView.text = "loading" | |
} | |
} | |
} | |
} | |
} |
activity_splash.xml is a simple xml file with TextView with id textview1 in the center
- ‘AndroidEntryPoint’ to tell Hilt this is an entry point.
- We get out our ViewModel by very useful Kotlin function ‘viewModels()’
- lifecycleScope: to launch the background thread of Flow in this certain scope, if this scope is not exist any more you don’t have to worry about the background threads.
- “.collect”: just like observe in LiveData or Subscribe in RxJava, to receive the emitted variables.
Done
That’s it! Now you have Android Clean Architecture with Kotlin Flow and Hilt.
Full Project
https://github.com/abos3d/CleanArchitectKotlinFlowHiltSimplestway
Things to know
This is a shortcut. There are missing parts you may need if you extend your project, for example:
Repositories: if you have more than one data source for example you have local database and server, then you have to add repositories and keep the responsibility of ‘where should I get this data from’ to them.
ViewBinding: a better approach is to use view binding instead of the legacy ‘findViewById’.
Thanks ?
If you have any comments please feel free.