Networking is an essential skill for Android developers. It allows you to communicate with remote servers and fetch data for your app. In this topic, we will get data from the Internet with HTTP protocol, and you will learn how to use some popular networking libraries and tools, such as Retrofit, how to parse received JSON data with kotlinx.serialization, and how this works with MVVM pattern and coroutines.
Getting started
We will create a small project to show us a list of drinks that were received from the server. Our UI will be separated from the logic with the help of the MVVM pattern.
We’ll start from a simple project that already has a View with a RecyclerView, Adapter, and ViewModel where we get the hardcoded data to show. By the end of the topic, we’ll tune it to get data from the Internet. In case you forgot how RecyclerView works, check out this topic.
Right now, we have a boring, empty list with some predefined text:
There are a lot of things we can fix and add, though. So, let's get to it!
Adding Flows
Currently, we have a ViewModel that looks like this:
class MainViewModel : ViewModel() {
var drinks: List<Drink> = listOf(
Drink(
name = "Cappuccino",
image = "",
id = 0,
type = "Coffee"
),
Drink(
name = "Latte",
image = "",
id = 1,
type = "Coffee"
),
Drink(
name = "Espresso",
image = "",
id = 2,
type = "Coffee"
),
Drink(
name = "Americano",
image = "",
id = 3,
type = "Coffee"
),
Drink(
name = "Mocha",
image = "",
id = 4,
type = "Coffee"
),
Drink(
name = "Macchiato",
image = "",
id = 5,
type = "Coffee"
),
Drink(
name = "Flat White",
image = "",
id = 6,
type = "Coffee"
)
)
}
And an activity where we fill the RecyclerView with the list of Drinks:
class MainActivity : AppCompatActivity() {
// Declare binding and view model variables
private lateinit var binding: ActivityMainBinding
private val vm by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val adapter = OrderAdapter()
binding.recyclerView.apply {
this.adapter = adapter
layoutManager = LinearLayoutManager(context)
}
adapter.items = vm.drinks
}
}
Later on, we will be getting data from the internet, which should be done asynchronously. Let's add flows to this app. Flow is a way of sending many values one after another in Kotlin.
class MainViewModel: ViewModel() {
private val _drinks = MutableStateFlow<List<Drink>>(generateDrinks())
val drinks: StateFlow<List<Drink>>
get() = _drinks
suspend fun generateDrinks(): List<Drink> {
return listOf(
Drink(
name = "Cappuccino",
image = "",
id = 0,
type = "Coffee"
),
...
The _drinks property is a private mutable state flow that can hold a list of Drink objects. The drinks property is a public state flow that exposes the _drinks property as a read-only flow.
A StateFlow is an observable flow, a way of sending and receiving a value that can change over time. The value is always available and can be read or updated at any time. A StateFlow is useful for classes that need to keep track of a value and share it with others. It is somewhat similar to LiveData, which was commonly used earlier.
This drinks variable can now be collected in the View:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
// Use the lifecycleScope to launch a coroutine when the activity is started
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.drinks.collect {
// Collect items flow and update the adapter
adapter.items = it
}
}
}
}
}
Collect the drinks from ViewModel and set it as the adapter.items.
UI state and server response
Although it works in this case, this isn’t a nice solution. Right now, we will always get the data — there are no failure points like an unstable internet connection or an unresponding server, we simply have all the data hardcoded. Let’s add some changes to update the UI in all scenarios.
We will create a sealed class that wraps the response. Whether you have a successful or error response, it will be returned as this class.
sealed class MainState {
object Loading : MainState()
class Success(val drinks: List<Drink>) : MainState()
class Error(val error: String) : MainState()
}
Because our UI depends on whether the app will be able to get a list of drinks, and we anyways now store everything in MainState, it makes sense to rename the variable. Let's rename it from drinks to uiState:
private var _uiState: MutableStateFlow<MainState>(MainState.Loading)
suspend fun getDrinks() {
try {
_uiState.value = MainState.Success(generateDrinks())
} catch (e: Exception) {
_uiState.value = MainState.Error("Error: $e")
}
}
suspend fun generateDrinks() = listOf(
Drink(
name = "Cappuccino",
image = "",
id = 0,
type = "Coffee"
),
...
Now, in the activity we can finally manage how the app acts in scenarios when we get data, get an error, or are waiting for data to load:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiState.collect {
when (it) {
is MainState.Success -> {
binding.progressBar.visibility = View.GONE
adapter.items = it.drinks
}
is MainState.Error -> {
binding.progressBar.visibility = View.GONE
showSnackbar(it.error)
}
is MainState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
}
}
}
}Adding Retrofit
Retrofit is a library that simplifies HTTP communication by turning remote APIs into declarative, type-safe interfaces. It allows you to customize your requests with annotations, convert JSON responses into Java objects, and handle asynchronous calls with callbacks or coroutines. To add Retrofit to your project, define these dependencies in your Gradle file:
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
implementation 'com.squareup.retrofit2:retrofit:2.9.0' // retrofit itself
implementation 'com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0' // support for kotlinx-serialization
Here's how the model for our drink item is defined:
@Serializable
class Drink(
val id: Int,
val image: String,
val name: String,
val type: String
)
/*
JSON example
[
{
"id": 1,
"image": "https://raw.githubusercontent.com/hatepoint/hyperskill-networking/main/images/5.jpg",
"name": "Caffè Americano",
"type": "americanos"
},
{
"id": 2,
"image": "https://raw.githubusercontent.com/hatepoint/hyperskill-networking/main/images/2.jpg",
"name": "Dark Roast Coffee",
"type": "brewed"
},
...
]
*/
The model is used to represent the JSON data that you will get (or send) from API. It’s a class that has the fields which will be mapped correspondingly to JSON properties you’ve got as an answer from a server. See that @Serializable annotation? Serialization is the process of converting an object's state into a format that can be stored or transported. This is especially useful in networking because it allows complex data structures to be sent over a network in a form that can be accurately reconstructed on the other side. This annotation marks the class as capable of being serialized or deserialized, enabling it to be converted to a format suitable for network transmission or persistent storage and later reconstructed back into an object with the original data.
Remember that the app should get a list of drinks from the server? That means we need to specify where Retrofit should make a call to. Let’s create an API class:
interface DrinksApi {
@GET("drinks.json")
suspend fun getDrinks(): Response<List<Drink>>
}
With @GET annotation, we specify the type of the request — it can also be POST, PUT, PATCH, etc. The GET method is used to retrieve data from a server. It's a read-only operation that doesn't affect the server's state. The POST method, on the other hand, sends data to a server to create a new resource. The PUT method is similar to POST but it's used to update an existing resource with new data. PATCH, like PUT, is also used to update resources, but it only changes the specific parts of the resource that the client specifies, making it more efficient for large resources or when only a small change is needed.
On the same line, in the annotation parameter, you should specify an endpoint. After that, you specify a function with the desired name and what it’s supposed to return. We can also wrap the return in the Response class, so we can later get different metadata about our response, like whether it was successful, the response code, etc.
We should also create a retrofit object:
object RetrofitClient {
private val BASE_URL = "https://coffee-38472-default-rtdb.firebaseio.com"
@OptIn(ExperimentalSerializationApi::class)
val retrofitClient = retrofit2.Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create(DrinksApi::class.java)
}
All we have to do now is call this new function we declared in the interface from the repository:
private suspend fun getDrinks() {
val response = retrofitClient.getDrinks()
if (response.isSuccessful) {
_drinks.value = response.body()!!
_uiState.value = MainState.Success(response.body()!!)
} else {
_uiState.value = MainState.Error(response.message())
}
}
Now we can launch the app and see how it acts in different scenarios. Here’s a loading screen while we wait for the data to load:
We get an error because the server denies the request:
And the successful response:
You can get the project source code at GitHub.
Conclusion
In this topic, we've covered some basic network requests with the help of Retrofit and created a small app that shows the list of coffee drinks. We've also incorporated all these things in MVVM architecture and used coroutines to manage these network operations asynchronously. Armed with this knowledge, you are now equipped to create your own networking-enabled apps that provide users with up-to-date and relevant information. So, keep practicing and exploring the possibilities of modifying this app further.