Unit 3 - Notes
Unit 3: Advanced Networking with Retrofit
1. Retrofit Basics and Integration
1.1 What is Retrofit?
Retrofit is a type-safe HTTP client for Android, Java, and Kotlin, developed by Square. It simplifies the process of consuming RESTful web services by turning an HTTP API into a simple Kotlin/Java interface.
Key Features:
- Declarative: You define your API endpoints in an interface using annotations.
- Type-Safe: Reduces runtime errors by validating API definitions at compile time.
- Pluggable: Supports various converters for serialization/deserialization (e.g., JSON, XML) and call adapters for integration with asynchronous frameworks (e.g., Kotlin Coroutines, RxJava).
- Built on OkHttp: It leverages OkHttp, a powerful and efficient HTTP client, for all its networking operations. This means you can use OkHttp features like interceptors, caching, and more.
1.2 Core Components
-
Retrofit Class: The main entry point for your application. You use
Retrofit.Builder()to configure and create a Retrofit instance. Configuration includes setting the base URL, adding converters, and attaching a custom OkHttpClient. -
API Interface: A Kotlin/Java interface where you define the API's endpoints. Each method in the interface represents a single API call.
-
Annotations: Used within the API interface to describe the request details.
- HTTP Method Annotations:
@GET,@POST,@PUT,@DELETE,@PATCH,@HEAD. - URL Manipulation Annotations:
@Path,@Query,@QueryMap,@Url. - Request Body Annotation:
@Body. - Header Annotations:
@Headers,@Header.
- HTTP Method Annotations:
-
Converters (
Converter.Factory): Responsible for serializing request bodies and deserializing response bodies. They convert data between Kotlin/Java objects and a format the API understands (like JSON).- Common Converters:
GsonConverterFactory: For Google's Gson library.MoshiConverterFactory: For Square's Moshi library (often preferred for Kotlin due to better null safety and adapter support).SimpleXmlConverterFactory: For XML.
- Common Converters:
-
Call Adapters (
CallAdapter.Factory): Adapt theCall<T>object into other types. This is crucial for integrating with asynchronous frameworks. Retrofit has built-in support for Kotlin Coroutines'suspendfunctions, which is the modern standard.
1.3 Integration Steps
Step 1: Add Dependencies
Add the necessary dependencies to your app-level build.gradle.kts (or build.gradle) file.
// build.gradle.kts
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// Converter (choose one, Moshi is recommended for Kotlin)
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
// Or for Gson
// implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp (usually included by Retrofit, but good to have for interceptors)
implementation("com.squareup.okhttp3:okhttp3:4.10.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.10.0") // Optional, for logging
}
Step 2: Add Internet Permission
Add the INTERNET permission to your AndroidManifest.xml.
<!-- AndroidManifest.xml -->
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
...
</application>
</manifest>
Step 3: Create a Data Class (Model)
Define a Kotlin data class that matches the structure of the JSON response from the API.
Example JSON Response:
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit..."
}
Corresponding Kotlin Data Class:
// Using Moshi
import com.squareup.moshi.Json
data class Post(
@Json(name = "userId")
val userId: Int,
@Json(name = "id")
val id: Int,
@Json(name = "title")
val title: String,
@Json(name = "body")
val body: String
)
/*
// Using Gson
import com.google.gson.annotations.SerializedName
data class Post(
@SerializedName("userId")
val userId: Int,
...
)
*/
Note: The
@Json(Moshi) or@SerializedName(Gson) annotation is only necessary if your Kotlin property name differs from the JSON key.
Step 4: Create the API Service Interface
Define an interface with methods that map to the API endpoints.
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
interface ApiService {
@GET("posts")
suspend fun getPosts(): Response<List<Post>>
@GET("posts/{id}")
suspend fun getPostById(@Path("id") postId: Int): Response<Post>
}
Step 5: Create the Retrofit Instance
It's a best practice to create a singleton instance of Retrofit to be used throughout the app.
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
object RetrofitClient {
private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
// Moshi instance for JSON parsing
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
// OkHttp logging interceptor for debugging
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// OkHttpClient with the logger
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
// Lazy-initialized Retrofit instance
val instance: ApiService by lazy {
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
retrofit.create(ApiService::class.java)
}
}
2. Handling API Requests
Retrofit uses annotations to define the parameters of an HTTP request.
2.1 URL Manipulation
-
@Path: Replaces a placeholder in the URL path. The placeholder is defined by{...}.
KOTLIN@GET("users/{userId}/posts") suspend fun getPostsByUser(@Path("userId") id: Int): List<Post> -
@Query: Appends a parameter to the URL with a specified name.
KOTLIN// Request will be: /posts?userId=1&_sort=id&_order=desc @GET("posts") suspend fun getSortedPostsByUser( @Query("userId") userId: Int, @Query("_sort") sortBy: String, @Query("_order") order: String ): List<Post> -
@QueryMap: Appends multiple query parameters from aMap.
KOTLIN// options = mapOf("userId" to "1", "_sort" to "id") @GET("posts") suspend fun getPostsWithFilters(@QueryMap options: Map<String, String>): List<Post>
2.2 Request Body
@Body: Used with methods likePOSTorPUTto send a Kotlin/Java object as the request body. The converter (e.g., Moshi) will serialize the object into JSON.
KOTLIN// Defines a request to create a new post @POST("posts") suspend fun createPost(@Body post: Post): Response<Post>
2.3 Other HTTP Methods
@PUT: To completely update an existing resource.
KOTLIN@PUT("posts/{id}") suspend fun updatePost(@Path("id") id: Int, @Body post: Post): Response<Post>@PATCH: To partially update an existing resource. Often used with aMapfor dynamic fields.
KOTLIN@PATCH("posts/{id}") suspend fun patchPost(@Path("id") id: Int, @Body fields: Map<String, Any>): Response<Post>@DELETE: To delete a resource. Usually doesn't have a response body.
KOTLIN// A 200 or 204 No Content response indicates success @DELETE("posts/{id}") suspend fun deletePost(@Path("id") id: Int): Response<Unit>
2.4 Headers
@Header: Adds a single, dynamic header to a request.
KOTLIN@GET("user") suspend fun getUserProfile(@Header("Authorization") token: String): User@Headers: Adds static headers to a request method.
KOTLIN@Headers( "Cache-Control: max-age=640000", "User-Agent: My-Awesome-App" ) @GET("posts") suspend fun getPosts(): List<Post>Best Practice: For headers that are required on every request (like
Authorization), use an OkHttp Interceptor instead of annotating every method. This is covered in the Authentication section.
3. Parsing and Displaying Data
3.1 Parsing with Converters
When you add a converter factory like MoshiConverterFactory to your Retrofit instance, the magic happens automatically.
- Outbound (Serialization): When you use
@Body, Retrofit passes your Kotlin object to the converter. The converter (Moshi/Gson) serializes it into a JSON string for the HTTP request body. - Inbound (Deserialization): When an HTTP response is received, Retrofit gives the response body to the converter. The converter parses the JSON and creates instances of your specified Kotlin data class (e.g.,
List<Post>).
3.2 Handling API Responses (Response<T>)
Wrapping your return type with Response<T> gives you more metadata about the response, which is crucial for robust error handling.
isSuccessful: Returnstrueif the HTTP response code is in the range [200..300).code(): Returns the HTTP status code (e.g., 200, 404, 500).body(): Returns the deserialized response body if the request was successful (isSuccessfulis true). It's nullable.errorBody(): Returns the raw error body if the request was not successful. You can use this to parse custom error messages from the API.
Example: Error Handling in a ViewModel
class PostViewModel : ViewModel() {
// ...
fun fetchPost(id: Int) {
viewModelScope.launch {
try {
val response = RetrofitClient.instance.getPostById(id)
if (response.isSuccessful) {
val post = response.body()
// Update UI with success state and post data
} else {
val errorCode = response.code()
val errorBody = response.errorBody()?.string()
// Update UI with error state (e.g., "Post not found (404)")
}
} catch (e: Exception) {
// Handle network errors (e.g., IOException)
// Update UI with network error state
}
}
}
}
3.3 Displaying Data in the UI (MVVM Pattern)
A common pattern is to use a ViewModel with StateFlow or LiveData to communicate the network result to the UI (Activity/Fragment).
1. Define a UI State:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
2. ViewModel Fetches Data and Updates State:
class PostsViewModel : ViewModel() {
private val _postsState = MutableStateFlow<UiState<List<Post>>>(UiState.Loading)
val postsState: StateFlow<UiState<List<Post>>> = _postsState
init {
fetchPosts()
}
private fun fetchPosts() {
_postsState.value = UiState.Loading
viewModelScope.launch(Dispatchers.IO) {
try {
val response = RetrofitClient.instance.getPosts()
if (response.isSuccessful && response.body() != null) {
_postsState.value = UiState.Success(response.body()!!)
} else {
_postsState.value = UiState.Error("Error: ${response.code()}")
}
} catch (e: Exception) {
_postsState.value = UiState.Error("Network error: ${e.message}")
}
}
}
}
3. Fragment/Activity Observes State and Updates UI:
class PostsFragment : Fragment() {
private val viewModel: PostsViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.postsState.collect { state ->
when (state) {
is UiState.Loading -> {
// Show progress bar
}
is UiState.Success -> {
// Hide progress bar, update RecyclerView with state.data
}
is UiState.Error -> {
// Hide progress bar, show error message
}
}
}
}
}
}
4. Asynchronous Networking
Network operations are long-running and must never be performed on the main (UI) thread, as this would freeze the app and lead to an NetworkOnMainThreadException.
4.1 The Old Way: Call.enqueue()
Before coroutines became standard, Retrofit used a callback-based approach.
// Deprecated approach, shown for context
val call = someApiService.getPosts() // Returns Call<List<Post>>
call.enqueue(object : Callback<List<Post>> {
override fun onResponse(call: Call<List<Post>>, response: Response<List<Post>>) {
// Handle success or failure on the main thread
}
override fun onFailure(call: Call<List<Post>>, t: Throwable) {
// Handle network failure on the main thread
}
})
This can lead to nested callbacks ("callback hell") and makes sequential network calls difficult to manage.
4.2 The Modern Way: Kotlin Coroutines
Coroutines provide a much cleaner, sequential way to write asynchronous code. Retrofit has first-class support for them.
Key Concepts:
suspendFunctions: Asuspendfunction can be paused and resumed later. Retrofit service methods marked withsuspendwill perform their network request on a background thread without blocking the calling thread.viewModelScope: ACoroutineScopetied to a ViewModel's lifecycle. Any coroutine launched in this scope is automatically cancelled when the ViewModel is cleared.- Structured Concurrency: Coroutines are launched within a scope, ensuring they are not lost and can be cancelled together.
Implementation:
-
Modify the API Interface: Make your service methods
suspendfunctions. They can now directly return the data type (List<Post>) or aResponse<List<Post>>.KOTLINinterface ApiService { // Directly returns the body, throws HttpException on failure @GET("posts") suspend fun getPosts(): List<Post> // Returns Response object for manual status code checking @GET("posts/{id}") suspend fun getPostById(@Path("id") id: Int): Response<Post> } -
Call from a
CoroutineScope: UseviewModelScope.launchto call thesuspendfunction from your ViewModel.KOTLINclass PostViewModel : ViewModel() { // ... fun fetchPost(id: Int) { viewModelScope.launch { // Coroutine is launched on the main thread // Retrofit handles switching to a background thread for the network call try { // This call suspends the coroutine, but does NOT block the main thread val post = RetrofitClient.instance.getPostById(id) // Code here resumes on the main thread when the network call completes // Update UI with the result } catch (e: HttpException) { // Handle HTTP errors (e.g., 404, 500) } catch (e: IOException) { // Handle network errors (no internet connection) } catch (e: Exception) { // Handle other exceptions (e.g., parsing errors) } } } }
This approach is significantly more readable and easier to reason about than callbacks, especially for complex logic like chaining multiple API calls.
5. Authentication
Most APIs require some form of authentication. Retrofit, combined with OkHttp's Interceptors, provides a powerful and centralized way to handle this.
5.1 Common Authentication Strategies
- API Key: A simple key passed either as a query parameter or a request header.
- Basic Authentication: A username and password encoded in Base64 and sent in the
Authorizationheader. - OAuth 2.0 (Bearer Token): The most common method for modern apps. The app first obtains an access token (e.g., after user login) and then includes it in the
Authorizationheader for all subsequent API requests, prefixed withBearer.
5.2 Using an OkHttp Interceptor for Authentication
An Interceptor is a powerful OkHttp mechanism that can observe, modify, and even short-circuit requests and responses. It's the ideal place to add an Authorization header to every outgoing request.
Step 1: Create the Interceptor Class
Create a class that implements okhttp3.Interceptor. Its job is to grab the original request, add the auth header, and then let the request proceed.
import okhttp3.Interceptor
import okhttp3.Response
// Assume you have a class to manage the session token
// object SessionManager { var authToken: String? = "your_initial_token" }
class AuthInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// 1. Get the original request from the chain.
val originalRequest = chain.request()
// 2. Retrieve the auth token. In a real app, this would come from
// SharedPreferences, a database, or a session manager.
val authToken = "YOUR_BEARER_TOKEN_HERE" // Replace with actual token retrieval logic
// 3. If a token exists, add the Authorization header.
val newRequest = if (authToken != null) {
originalRequest.newBuilder()
.header("Authorization", "Bearer $authToken")
.build()
} else {
originalRequest
}
// 4. Let the modified request proceed and return the response.
return chain.proceed(newRequest)
}
}
Step 2: Add the Interceptor to OkHttpClient
Modify your Retrofit client setup to use an OkHttpClient instance that includes your custom interceptor.
// In your RetrofitClient object
object RetrofitClient {
// ...
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor()) // Add your custom auth interceptor
.addInterceptor(loggingInterceptor) // The logging interceptor is still useful
.build()
val instance: ApiService by lazy {
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient) // Use the custom client
.addConverterFactory(...)
.build()
retrofit.create(ApiService::class.java)
}
}
Now, every API call made through this Retrofit instance will automatically have the Authorization header added by the AuthInterceptor, keeping your API service interface clean and centralizing your authentication logic.
5.3 Handling Token Refresh (Advanced Concept)
For OAuth 2.0, access tokens expire. A robust app needs to handle this by refreshing the token. OkHttp provides a specialized Authenticator for this.
- An
Authenticatoris like an interceptor that runs only when the server responds with a401 Unauthorizedstatus code. - Its job is to:
- Make a synchronous API call to the token refresh endpoint.
- If successful, get the new access token and save it.
- Create a new request identical to the failed one, but with the new token in the
Authorizationheader. - Return this new request. OkHttp will then automatically re-try it.
- This is a more advanced pattern but is the standard for building production-quality apps with token-based authentication.