Comprehensive Guide to Network Calls using Hilt-MVVM-Flow-Coroutines-Retrofit in Android
As you all know, there is a stereotypical architecture and flow in Android development, and I’ll show you this :) I must emphasize that our priority in this application is not visuality, but the flow and process behind the scenes!
If you want to see the code directly without reading the article, you can access it from the link below
However clichéd I may call it, the bottom line is that it’s a best practice at the same time. Moreover, during understanting this structure, I read lots of medium article and check on plenty of GitHub repos. I realized that there is really a lot of infollution on this issue. Therefore,I truly want this article to be a solid resource for those who want to learn. I think I’ve said enough. Let’s move on to the app we’ll create.
In this article we will build a PokeApp on MVVM architecture using the recommended technologies.
Rotrofit is a type-safe HTTP client for Android and Java
Hilt provides a standard way to incorporate Dagger dependency injection into an Android application.
Coroutines is a concurrency design pattern that you can use on Android to simplify code that executes asynchronously
Flow is conceptually a stream of data that can be computed asynchronously.
Dependencies
Let’s start by adding dependencies we need for the project.
android {
...
viewBinding{
enabled=true
}
}
dependencies {
...
def nav_version = "2.6.0"
def retrofit_version = "2.9.0"
// navigaiton
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// retrofit
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0'
//gson
implementation 'com.google.code.gson:gson:2.10.1'
// hilt
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
//coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
}
In addition, we need to add Hilt to both build.gradle(Project) and build.gradle(Module:app)
In build.gradle(Project)
plugins {
...
id 'com.google.dagger.hilt.android' version '2.44' apply false
}
In build.gradle(Module:app)
plugins {
...
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
Hilt Integration
There are a few more steps to follow to integrate Hilt into our project. First, create a application class as follows
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApp : Application()
And give its path to application as name in Androidmenifest.xml file.
<application
android:name=".utils.MyApp"
...
</application>
Create a package named di and create AppModule class under it. Here we can say that we have done required initializations such as Retrofit, Service classes, Gson and so on. Somewhere there will be annotations we need to add, such as activities, fragments, view models and some constructors.
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
private val httpLoggingInterceptor by lazy {
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(httpLoggingInterceptor)
.build()
}
@Singleton
@Provides
fun provideRetrofit(): Retrofit =
Retrofit.Builder()
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(Constants.BASE_URL)
.build()
@Provides
fun providePokeService(retrofit: Retrofit): PokeService =
retrofit.create(PokeService::class.java)
@Provides
fun provideGson(): Gson = GsonBuilder().create()
}
File Structure
The project file structure will be as follows. If you are confused about which class to write where, you can check here or look directly at the GitHub repository.
API Call
We’ll use PokeApi for this project. It provides lots of information
To fetch data from the api, we need this url https://pokeapi.co/api/v2/pokemon
We split our url into two, base url and end point. Now, create a object class named Constants under utils package. We also have a constant variable LIMIT_POKEMONS that allows us to specify how many pokemon to show.
object Constants {
const val BASE_URL = "https://pokeapi.co/api/v2/"
const val END_POINT_POKEMONS = "pokemon"
const val LIMIT_POKEMONS = 20
}
This url retuns us json object as follows
{
count:1281,
next:"https://pokeapi.co/api/v2/pokemon/?offset=20&limit=20",
previous:null,
results: [] 20 items,
}
Response Models
Based on the json object, we need to create a reponse model classes. We’ve two response classes, PokemonsResponse for a list of pokemons and SinglePokemonResponse for a pokemon detail.
data class PokeListResponse(
@SerializedName("count")
val count: Long? = null,
@SerializedName("next")
val next: String? = null,
@SerializedName("previous")
val previous: String? = null,
@SerializedName("results")
val results: List<SinglePokeResponse>? = null,
)
data class SinglePokeResponse(
@SerializedName("name")
val name: String,
@SerializedName("url")
val url: String,
)
Service
After the response classes are created, we can create the service class PokeService. The variable offset specify the range of the pokemons
interface PokeService {
@GET("${Constants.BASE_URL}${Constants.END_POINT_POKEMONS}")
suspend fun getPokemons(): PokeListResponse
@GET("pokemon/")
suspend fun getPokemonsByOffset(
@Query("offset") offset: Int,
@Query("limit") limit: Int
): PokeListResponse
}
State Mangement
Create a custom state class ,ViewState, to manage data fetch status. I will address this issue again in the next steps.
enum class Status {
SUCCESS,
ERROR,
LOADING
}
data class ViewState<out T>(val status: Status, val data: T?, val message: String?) {
companion object {
fun <T> success(data: T?): ViewState<T> {
return ViewState(Status.SUCCESS, data, null)
}
fun <T> error(msg: String): ViewState<T> {
return ViewState(Status.ERROR, null, msg)
}
fun <T> loading(): ViewState<T> {
return ViewState(Status.LOADING, null, null)
}
}
}
Repository
class PokeRepository @Inject constructor(private val pokeService: PokeService)
{
suspend fun fetchPokemonData(): Flow<ViewState<PokemonsResponse>> {
return flow {
val comment = pokeService.getPokemons()
emit(ViewState.success(comment))
}.flowOn(Dispatchers.IO)
}
suspend fun fetchPokemonsByOffset(offset: Int): Flow<ViewState<PokemonsResponse>> {
return flow {
val comment = pokeService.getPokemonsByOffset(limit = Constants.LIMIT_POKEMONS, offset = offset)
emit(ViewState.success(comment))
}.flowOn(Dispatchers.IO)
}
}
This is a repository class, which is a common architectural pattern in Android development. It acts as an intermediary between the data sources (in this case, the PokeService
) and the rest of the application. It provides methods for fetching Pokémon data. A Flow
is a Kotlin coroutine-based asynchronous data stream. In simpler terms, it's a sequence of values that can be emitted over time.
- Inside a
flow { ... }
builder, it callspokeService.getPokemons()
andpokeService.getPokemonsByOffset()
to fetch the Pokémon data. - It then emits a
ViewState.success
with the fetched data usingemit(ViewState.success(comment))
. - The code also uses
flowOn(Dispatchers.IO)
to ensure that the work is done on the IO dispatcher (a separate thread) to prevent blocking the main UI thread.
Display Data on Screen
We perform these operations under the presentation package. But first, let’s create our layout files of our pages and items.
Layouts
Our app consist of two pages PokeListFragment where pokemons are listed and PokeDetailFragment that will be opened when we click on each pokemon in the pokemon list.
activity_main.xml
We just have a FragmentContainerView for our fragments
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/navHost"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
fragment_list.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.ui.list.PokeListFragment">
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:elevation="10dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/recycler"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.485" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/directionButtons"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/directionButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent">
<Button
android:id="@+id/btnLeft"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:enabled="false"
android:text="Left" />
<Button
android:id="@+id/btnRight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="Right" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
ProgressBar is visible just in case the data is fetching controlled by ViewState class. After fetching, progressbar will be gone.
fragment_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.ui.detail.PokeDetailFragment">
<TextView
android:id="@+id/nameDetailTextView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textSize="30sp"
android:text="@string/hello_blank_fragment" />
</FrameLayout>
Navigation Graph
After the creation of fragments layouts we need to create a navigation file under navigation folder in res folder as follows
nav_graph.xml
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/listFragment">
<fragment
android:id="@+id/listFragment"
android:name="com.example.day_5.presentation.ui.list.PokeListFragment"
android:label="fragment_list"
tools:layout="@layout/fragment_list" >
<action
android:id="@+id/action_listFragment_to_detailFragment"
app:destination="@id/detailFragment" />
</fragment>
<fragment
android:id="@+id/detailFragment"
android:name="com.example.day_5.presentation.ui.detail.PokeDetailFragment"
android:label="fragment_detail"
tools:layout="@layout/fragment_detail" />
</navigation>
PokeListFragment
Let’s start with list page we’ll utilize view models to manuputale our remote data.
That’s where ViewState class I mentioned before comes in handy. In view model classes, wheter you use LiveData or Flow, typically state variables are created and is observed in Activity/Fragment classes.
@HiltViewModel
class PokeListViewModel @Inject constructor(private val pokeRepository: PokeRepository) :
ViewModel() {
val pokemonsState = MutableStateFlow(
ViewState(
Status.LOADING,
PokeListResponse(), ""
)
)
init {
fetchPokemons()
}
fun fetchDataByOffset(offset: Int) {
pokemonsState.value = ViewState.loading()
viewModelScope.launch {
pokeRepository.fetchPokemonsByOffset(offset)
.catch {
pokemonsState.value =
ViewState.error(it.message.toString())
}
.collect { pokemonsResponseViewState ->
pokemonsState.value = ViewState.success(pokemonsResponseViewState.data)
}
}
}
fun fetchPokemons() {
pokemonsState.value = ViewState.loading()
viewModelScope.launch {
pokeRepository.fetchPokemonData()
.catch {
pokemonsState.value =
ViewState.error(it.message.toString())
}
.collect {
pokemonsState.value = ViewState.success(it.data)
}
}
}
}
In our scenario, we give an offset to fetchDataByOffset(offset: Int) to fetch pokemons in a certain range. And for the first fetch, we use the fetchPokemons() method that fetch the first part of the pokelist.
@AndroidEntryPoint
class PokeListFragment : Fragment() {
lateinit var binding: FragmentListBinding
val listFragmentViewModel: PokeListViewModel by viewModels()
var offset = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentListBinding.inflate(inflater, container, false)
binding.apply {
recycler.apply {
layoutManager = LinearLayoutManager(requireContext())
}
}
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listFragmentViewModel.fetchPokemons()
observeData()
binding.apply {
btnRight.setOnClickListener {
callNext()
}
btnLeft.setOnClickListener {
callPrevious()
}
}
}
private fun observeData() {
viewLifecycleOwner.lifecycleScope.launch {
listFragmentViewModel.pokemonsState.collect {
when (it.status) {
Status.LOADING -> {
binding.progressBar.visibility = View.VISIBLE
binding.recycler.visibility = View.GONE
Log.i("PokeListFragment", "Loading...")
}
Status.SUCCESS -> {
binding.progressBar.visibility = View.GONE
binding.recycler.visibility = View.VISIBLE
it.data?.let { pokeResponse ->
val list: List<Pokemon> = pokeResponse.results!!.map { singlePokemon ->
Pokemon(
name = singlePokemon.name,
url = singlePokemon.url,
)
}
if (offset <= 0) {
binding.apply {
btnLeft.isEnabled = false
}
} else {
binding.btnLeft.isEnabled = true
}
binding.recycler.adapter = PokeListAdapter(list) { pokemon ->
val bundle = bundleOf("name" to pokemon.name)
view?.findNavController()
?.navigate(R.id.action_listFragment_to_detailFragment, bundle)
}
Log.i("PokeListFragment", "Received poke list.")
}
?: run {
Log.e("PokeListFragment", "Error: Failed to fetch poke list.")
}
}
// error occurred status
else -> {
binding.progressBar.visibility = View.GONE
binding.recycler.visibility = View.GONE
Toast.makeText(requireContext(), "${it.message}", Toast.LENGTH_SHORT)
.show()
Log.e("PokeListFragment", it.message.toString())
}
}
}
}
}
private fun callNext() {
offset += 20
listFragmentViewModel.fetchDataByOffset(offset = offset)
}
private fun callPrevious() {
if (offset <= 0) {
binding.apply {
btnLeft.isEnabled = false
btnRight.isEnabled = true
}
} else {
offset -= 20
listFragmentViewModel.fetchDataByOffset(offset = offset)
}
}
}
We change offset with callPrevious()
and callNext()
functions. In oberseData()
we set which layout component will be visible and which will not based on ViewState status.
Observation
In Android, Fragments have a lifecycle, and the viewLifecycleOwner is an object that represents the lifecycle of the Fragment’s view. It’s a LifecycleOwner, and its lifecycle is tied to the creation and destruction of the Fragment’s view.
The collect function is used to collect values from a Flow (or a StateFlow). A Flow is a type that emits a sequence of values over time, and collect allows you to consume these values as they are emitted. In this case, the pokemonsState Flow emits instances of some Status class, which represents the loading status of the data being fetched.
Launch is a function that starts a new coroutine in the specified CoroutineScope. When launch is used with viewLifecycleOwner.lifecycleScope, the coroutine will be canceled automatically when the Fragment’s view is destroyed. This ensures that any asynchronous tasks started within the observeData function will be properly managed with respect to the Fragment’s lifecycle.
PokeDetailFragment
@AndroidEntryPoint
class PokeDetailFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_detail, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<TextView>(R.id.nameDetailTextView).text =
requireArguments().getString("name")
}
}
We just get the name of the pokemon come from bundle. However, you can fetch more detailed data via the url we get from response.
Full Project
You can access the source code of the project from the link below.
In this article I just wanted to show you how to make api call in a way that is suitable for modern architectures. What was important in this article was not the visuals, but to better understand the work behind the scenes with recommended technologies such as Flow, Hilt, Retrofit, MVVM.
I hope the article helps you guys. Stay tuned and give it a clap if you want more!
- cobanalitalha@gmail.com
- github.com/carpodok
- linkedin.com/alitalhacoban