Android Network Requests: A Practical Guide with Kotlin
Android Network Requests: A Practical Guide with Kotlin
Introduction
Network communication is the backbone of almost every modern Android application. Whether you are fetching a list of products, uploading a user avatar, or streaming live data, a robust networking layer is essential for a responsive and reliable user experience.
Historically, Android developers juggled HttpURLConnection, AsyncTask, and a fair amount of boilerplate. Today, the Kotlin ecosystem offers elegant, coroutine-friendly solutions that make network code concise, type-safe, and easy to test.
This article walks through the most widely used networking libraries on Android, compares them, and demonstrates how to build a production-ready networking layer using Retrofit + OkHttp + Kotlin Coroutines. We will also touch on Ktor as a modern pure-Kotlin alternative.
The Main Contenders
1. OkHttp
OkHttp is an efficient HTTP client that powers most Android networking stacks under the hood. It supports connection pooling, transparent GZIP, response caching, and HTTP/2. It is the foundation on which Retrofit is built.
2. Retrofit
Retrofit, created by Square, is a type-safe HTTP client for Java and Android. You define an interface describing your API endpoints, and Retrofit generates the implementation at runtime. Combined with coroutines, it turns asynchronous network calls into simple suspend functions.
3. Ktor
Ktor is a Kotlin-first framework from JetBrains. Its HTTP client is fully coroutine-based, multiplatform-ready, and configurable via a plugin pipeline. It is a great choice for new Kotlin Multiplatform projects or teams that want to avoid Java-centric libraries.
Which Should You Choose?
- Retrofit + OkHttp: The industry default. Massive community, excellent documentation, countless tutorials. Best when you want stability and familiarity.
- Ktor: Best for Kotlin Multiplatform projects or teams fully committed to a pure-Kotlin toolchain.
- Raw OkHttp: Best when you need fine-grained control over the wire protocol and do not want an abstraction layer.
Project Setup
Add the required dependencies to your module-level build.gradle.kts:
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
// OkHttp (logging interceptor)
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// ViewModel + LiveData (for lifecycle-aware state handling)
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
}
Ensure you have the internet permission in your AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Defining the API Interface
Retrofit maps an annotated Kotlin interface to HTTP calls. Each method describes one endpoint.
interface GithubApi {
@GET("users/{login}")
suspend fun getUser(@Path("login") login: String): UserDto
@GET("search/repositories")
suspend fun searchRepositories(
@Query("q") query: String,
@Query("page") page: Int = 1
): RepoSearchResultDto
@POST("repos/{owner}/{repo}/issues")
suspend fun createIssue(
@Path("owner") owner: String,
@Path("repo") repo: String,
@Body body: CreateIssueRequest
): IssueDto
}
Notice the suspend keyword. Retrofit natively supports coroutines: a suspend function returns the deserialized body directly (or throws an HttpException on failure), so there is no need to wrap the result in Call<T> or Response<T> unless you want access to the raw response.
Data Transfer Objects
data class UserDto(
val login: String,
val id: Long,
val avatarUrl: String,
val name: String?,
val publicRepos: Int
)
data class RepoSearchResultDto(
val totalCount: Int,
val items: List<RepoDto>
)
data class RepoDto(
val id: Long,
val name: String,
val fullName: String,
val stargazersCount: Int,
val owner: UserDto
)
data class CreateIssueRequest(
val title: String,
val body: String,
val labels: List<String> = emptyList()
)
data class IssueDto(
val number: Int,
val title: String,
val state: String,
val htmlUrl: String
)
Building the Retrofit Instance
Create a singleton object that holds the configured Retrofit instance. Centralizing configuration keeps your app maintainable and makes it easy to swap implementations in tests.
object NetworkModule {
private const val BASE_URL = "https://api.github.com/"
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
private val headerInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.header("Authorization", "Bearer ${BuildConfig.GITHUB_TOKEN}")
.build()
chain.proceed(request)
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(headerInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val githubApi: GithubApi = retrofit.create(GithubApi::class.java)
}
Key Points
- Logging interceptor: Use
BODYlevel only in debug builds to avoid leaking sensitive data in production logs. - Header interceptor: Centralizes authentication and content negotiation headers.
- Timeouts: Always set explicit timeouts. The defaults are generous; tighter limits prevent the UI from hanging on dead connections.
Calling the API from a ViewModel
Network calls should never run on the main thread. With coroutines, launch the request in a lifecycle-aware scope and deliver the result to the UI via StateFlow or LiveData.
class UserViewModel(
private val api: GithubApi = NetworkModule.githubApi
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(login: String) {
_uiState.value = UserUiState.Loading
viewModelScope.launch {
_uiState.value = try {
val user = api.getUser(login)
UserUiState.Success(user)
} catch (e: IOException) {
UserUiState.Error("Network error: ${e.message}")
} catch (e: HttpException) {
UserUiState.Error("Server error: ${e.code()}")
}
}
}
}
sealed interface UserUiState {
data object Loading : UserUiState
data class Success(val user: UserDto) : UserUiState
data class Error(val message: String) : UserUiState
}
Why a Sealed Interface for State?
A sealed state model forces the UI to handle every possible case at compile time. There is no way to forget the loading or error state, which is a common source of crashes and blank screens.
Handling Errors Gracefully
Network code fails in many ways: the device is offline, the server returns 500, the JSON schema changed, or the call timed out. A centralized error handler keeps your ViewModels clean.
sealed class NetworkError : Exception() {
data object NoConnectivity : NetworkError()
data class ServerError(val code: Int) : NetworkError()
data class ParseError(val cause: Throwable) : NetworkError()
data class Unknown(val cause: Throwable) : NetworkError()
}
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> {
return try {
Result.success(block())
} catch (e: IOException) {
Result.failure(NetworkError.NoConnectivity)
} catch (e: HttpException) {
Result.failure(NetworkError.ServerError(e.code()))
} catch (e: JsonSyntaxException) {
Result.failure(NetworkError.ParseError(e))
} catch (e: Throwable) {
Result.failure(NetworkError.Unknown(e))
}
}
Usage becomes a one-liner in the ViewModel:
viewModelScope.launch {
when (val result = safeApiCall { api.getUser(login) }) {
is Result.Success -> _uiState.value = UserUiState.Success(result.getOrThrow())
is Result.Failure -> _uiState.value = UserUiState.Error(result.exceptionOrNull().toString())
}
}
Interceptors: The Swiss Army Knife
Interceptors sit between your application code and the network. They are perfect for cross-cutting concerns.
A Retry Interceptor
class RetryInterceptor(
private val maxRetries: Int = 3
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var attempt = 0
var lastError: IOException? = null
while (attempt < maxRetries) {
try {
val response = chain.proceed(chain.request())
if (response.isSuccessful) return response
response.close()
} catch (e: IOException) {
lastError = e
}
attempt++
}
throw lastError ?: IOException("Max retries exceeded")
}
}
A Caching Interceptor
To benefit from OkHttp’s disk cache, you must opt in. Configure a cache directory and a Cache instance, then add an interceptor that respects cache control headers:
val cacheSize = 10L * 1024 * 1024 // 10 MB
val cache = Cache(File(context.cacheDir, "http-cache"), cacheSize)
val cacheInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
.header("Cache-Control", "public, max-age=60")
.build()
chain.proceed(request)
}
Uploading Files with Multipart
File uploads require multipart request bodies. Retrofit makes this straightforward with the @Multipart annotation.
interface UploadApi {
@Multipart
@POST("upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
@Part("description") description: RequestBody
): UploadResponseDto
}
Building the request:
suspend fun uploadAvatar(file: File, description: String) {
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val filePart = MultipartBody.Part.createFormData("file", file.name, requestFile)
val descPart = description.toRequestBody("text/plain".toMediaTypeOrNull())
api.uploadFile(filePart, descPart)
}
The Ktor Alternative
For teams embracing Kotlin Multiplatform, Ktor provides a unified, coroutine-native client that works on Android, iOS, and the JVM.
Setup
dependencies {
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-android:2.3.12")
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-gson:2.3.12")
implementation("io.ktor:ktor-client-logging:2.3.12")
}
Configuration and Usage
val client = HttpClient(Android) {
install(ContentNegotiation) {
gson()
}
install(Logging) {
level = LogLevel.ALL
}
engine {
connectTimeout = 15_000
socketTimeout = 20_000
}
}
suspend fun fetchUser(login: String): UserDto {
return client.get("https://api.github.com/users/$login").body()
}
Ktor’s pipeline architecture means features like authentication, logging, and serialization are installed as plugins, keeping configuration declarative and composable.
Best Practices Checklist
- Never block the main thread. Always use
viewModelScopeor a customCoroutineScopetied to a lifecycle owner. - Set explicit timeouts. Relying on defaults leads to unresponsive UIs on flaky networks.
- Use a sealed state model. Represent loading, success, and error explicitly so the compiler enforces complete handling.
- Centralize configuration. A single
NetworkModulemakes it trivial to swap mocks in tests. - Log responsibly. Disable verbose logging in release builds to avoid leaking tokens and personal data.
- Cache when possible. Disk caching reduces bandwidth and makes the app usable offline.
- Handle errors by category. Distinguish connectivity errors from server errors from parse errors so the user sees a meaningful message.
- Inject your client. Passing the API interface through the ViewModel constructor enables easy unit testing with fake implementations.
- Pin certificates in production. Use
CertificatePinnerfor apps handling sensitive data to prevent man-in-the-middle attacks. - Measure and monitor. Track latency and failure rates in your analytics pipeline to catch regressions early.
Conclusion
A well-structured networking layer is the difference between an app that feels solid and one that randomly breaks on the train. By combining Retrofit’s type safety, OkHttp’s reliability, and Kotlin coroutines’ structured concurrency, you get code that is concise, testable, and resilient.
Start with the basics: a configured Retrofit client, a sealed UI state, and centralized error handling. Add interceptors and caching as your requirements grow. And if you are heading into Kotlin Multiplatform territory, keep Ktor on your radar as a future-proof alternative.
The libraries will evolve, but the principles stay the same: keep it off the main thread, handle every failure mode, and make the network layer as boring and reliable as possible. Boring networking code is the best networking code.
- 点赞
- 收藏
- 关注作者
评论(0)