Android Network Requests: A Detailed Technical Guide
Android Network Requests: A Detailed Technical Guide
Introduction
Network communication is the backbone of almost every modern Android application. Whether you are fetching data from a remote server, uploading user-generated content, or synchronizing local state with a backend, a robust and well-architected networking layer is essential. This article provides a detailed walkthrough of building a production-grade networking layer in Android using OkHttp, Retrofit, and RxJava for reactive programming.
1. The Evolution of Android Networking
Over the years, Android developers have moved through several paradigms for making network requests:
HttpURLConnection— The built-in SDK option. Powerful but verbose, with no built-in connection pooling, retry logic, or interceptor support.- Apache HttpClient — Deprecated and removed in API 23. Historically popular but no longer maintained on Android.
- Volley — Google’s library introduced at Google I/O 2013. Great for image loading and small JSON payloads, but less flexible for large data transfers or streaming.
- OkHttp — A modern, efficient HTTP client with connection pooling, transparent compression, response caching, and a powerful interceptor chain.
- Retrofit — A type-safe REST client built on top of OkHttp that turns HTTP APIs into Java/Kotlin interfaces through declarative annotations.
- RxJava adapters — Enable Retrofit to return
Observable,Single, orFlowabletypes, allowing you to compose asynchronous network operations reactively.
For this guide, we will use the OkHttp + Retrofit + RxJava stack, which provides a clean separation of concerns, excellent testability, and powerful error-handling capabilities.
2. Project Setup
2.1 Declaring Dependencies
In your module-level build.gradle, add the following dependencies. These are Maven coordinates, not web addresses:
dependencies {
// OkHttp for the underlying HTTP client
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Retrofit for declarative API definitions
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// RxJava adapter so Retrofit can return reactive types
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
// RxJava and RxAndroid
implementation 'io.reactivex.rxjava3:rxjava:3.1.8'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
// Gson for JSON parsing
implementation 'com.google.code.gson:gson:2.10.1'
}
2.2 Why These Libraries?
| Library | Responsibility |
|---|---|
| OkHttp | Low-level HTTP transport, connection pooling, interceptors, caching |
| Retrofit | Translates interface annotations into HTTP calls, parses responses |
| Gson Converter | Converts JSON response bodies into Java objects automatically |
| RxJava Adapter | Bridges Retrofit’s Call type into RxJava Observable/Single |
| RxAndroid | Provides a Scheduler that runs on the Android main thread |
3. Defining the Data Model
Before defining the API, we need data transfer objects (DTOs) that represent the JSON payloads. Suppose we are building an app that consumes a user-management API.
public class User {
private long id;
private String name;
private String email;
private String avatarUrl;
private boolean active;
// Getters and setters omitted for brevity.
// In practice, generate them or use Lombok.
}
For list endpoints, the API often wraps results in an envelope:
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public boolean isSuccess() {
return code == 200;
}
public T getData() {
return data;
}
}
This generic wrapper lets us reuse the same envelope shape across all endpoints.
4. Defining the Retrofit API Interface
Retrofit’s core idea is that you describe your API as a Java interface annotated with HTTP method information. Retrofit generates the implementation at runtime.
import retrofit2.http.Body;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.Path;
import retrofit2.http.Query;
import io.reactivex.rxjava3.core.Single;
public interface UserService {
@GET("users")
Single<ApiResponse<List<User>>> getUsers(
@Query("page") int page,
@Query("size") int size
);
@GET("users/{id}")
Single<ApiResponse<User>> getUserById(@Path("id") long id);
@POST("users")
Single<ApiResponse<User>> createUser(@Body User user);
}
4.1 Annotation Reference
| Annotation | Purpose |
|---|---|
@GET, @POST, @PUT, @DELETE, @PATCH |
HTTP method + relative path |
@Path |
Substitutes a value into the URL path |
@Query |
Appends a query parameter |
@QueryMap |
Appends multiple query parameters from a Map |
@Body |
Sends a serialized object as the request body |
@Header |
Adds a dynamic header |
@FormUrlEncoded + @Field |
Sends form-encoded data |
Notice that each method returns a Single<T>. This is an RxJava type that emits exactly one item or an error — a perfect fit for network calls that complete once.
5. Building the Retrofit Instance
Creating a Retrofit instance is expensive, so it should be done once and reused. A common pattern is a singleton holder class.
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory;
import retrofit2.converter.gson.GsonConverterFactory;
public class NetworkClient {
private static final String BASE_URL = "https://api.example.com/v1/";
private static volatile Retrofit retrofit;
private NetworkClient() {}
public static Retrofit getRetrofit() {
if (retrofit == null) {
synchronized (NetworkClient.class) {
if (retrofit == null) {
retrofit = buildRetrofit();
}
}
}
return retrofit;
}
public static <S> S createService(Class<S> serviceClass) {
return getRetrofit().create(serviceClass);
}
private static Retrofit buildRetrofit() {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.addInterceptor(new AuthInterceptor("your-token-here"))
.addInterceptor(logging)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build();
return new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.build();
}
}
5.1 The Double-Checked Locking Pattern
The getRetrofit() method uses double-checked locking to ensure thread-safe lazy initialization without the performance cost of synchronizing on every call. The volatile keyword guarantees the partially-constructed object is never published to other threads.
6. Custom Interceptors
Interceptors sit between your application code and the raw HTTP call. They are the ideal place for cross-cutting concerns like authentication, logging, and request rewriting.
6.1 Authentication Interceptor
import okhttp3.Interceptor;
import okhttp3.Response;
public class AuthInterceptor implements Interceptor {
private final String token;
public AuthInterceptor(String token) {
this.token = token;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request original = chain.request();
Request authenticated = original.newBuilder()
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.build();
return chain.proceed(authenticated);
}
}
6.2 Error-Unwrapping Interceptor
Sometimes the server returns HTTP 200 but embeds a business error code in the body. You can intercept and normalize this:
public class ErrorNormalizingInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Response response = chain.proceed(chain.request());
if (response.isSuccessful()) {
ResponseBody body = response.body();
if (body != null) {
String rawJson = body.string();
try {
JsonObject json = JsonParser.parseString(rawJson).getAsJsonObject();
int code = json.get("code").getAsInt();
if (code != 200) {
String msg = json.get("message").getAsString();
throw new ApiException(code, msg);
}
} catch (JsonSyntaxException ignored) {
// Not JSON, fall through.
}
// Rebuild the body because we consumed it.
return response.newBuilder()
.body(ResponseBody.create(rawJson, body.contentType()))
.build();
}
}
return response;
}
}
Important: Once you call
body.string(), the body is consumed and cannot be read again. You must rebuild the response with a freshResponseBodyif downstream code needs the content.
7. Making Requests with RxJava
This is where the reactive approach shines. Instead of using callbacks (which lead to nested, hard-to-read code), we compose operations declaratively.
7.1 A Simple Fetch
UserService service = NetworkClient.createService(UserService.class);
service.getUsers(1, 20)
.subscribeOn(Schedulers.io()) // Run network call on IO thread
.observeOn(AndroidSchedulers.mainThread()) // Emit result on main thread
.subscribe(
response -> showUsers(response.getData()),
error -> showError(error.getMessage())
);
7.2 Chaining Requests with flatMap
Suppose fetching a user requires first looking up their ID by email, then fetching the full profile:
service.getUserByEmail("alice@example.com")
.flatMap(userSummary -> service.getUserById(userSummary.getId()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
fullUser -> showProfile(fullUser),
error -> showError(error.getMessage())
);
flatMap takes the emission of the first Single and transforms it into a new Single — effectively chaining dependent asynchronous operations without callback nesting.
7.3 Combining Parallel Requests with zip
To load a user’s profile and their order history simultaneously:
Single<ApiResponse<User>> profileSingle = service.getUserById(userId);
Single<ApiResponse<List<Order>>> ordersSingle = service.getOrders(userId);
Single.zip(
profileSingle, ordersSingle,
(profileResponse, ordersResponse) -> {
ProfileData data = new ProfileData();
data.setUser(profileResponse.getData());
data.setOrders(ordersResponse.getData());
return data;
}
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
data -> renderProfile(data),
error -> showError(error.getMessage())
);
zip waits for all sources to complete and then combines their results. Both network calls run concurrently on the IO scheduler pool.
7.4 Retrying with retryWhen
Network conditions on mobile are unreliable. A transient failure should not immediately surface to the user:
service.getUsers(1, 20)
.retryWhen(errors -> errors
.zipWith(Flowable.range(1, 3), (error, attempt) -> attempt)
.flatMap(attempt -> {
long delay = (long) Math.pow(2, attempt); // Exponential backoff
return Flowable.timer(delay, TimeUnit.SECONDS);
})
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(...);
This retries up to 3 times with exponential backoff (1s, 2s, 4s), then propagates the error if all attempts fail.
8. Managing Subscriptions and Lifecycle
A critical mistake is forgetting to dispose of subscriptions. If a network call completes after the user has navigated away from an Activity or Fragment, updating the UI will crash or leak memory.
8.1 Using CompositeDisposable
public class UserListActivity extends AppCompatActivity {
private final CompositeDisposable disposables = new CompositeDisposable();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
loadUsers();
}
private void loadUsers() {
Disposable d = NetworkClient.createService(UserService.class)
.getUsers(1, 20)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
response -> showUsers(response.getData()),
error -> showError(error.getMessage())
);
disposables.add(d);
}
@Override
protected void onDestroy() {
disposables.clear();
super.onDestroy();
}
}
disposables.clear() cancels all active subscriptions, preventing callbacks from firing after the activity is destroyed.
8.2 Binding to Android Lifecycle (Alternative)
If you use Android’s Lifecycle components, you can create a custom ObservableTransformer that automatically disposes when the lifecycle reaches DESTROYED:
public class LifecycleTransformer<T> implements ObservableTransformer<T, T> {
private final LifecycleOwner lifecycleOwner;
public LifecycleTransformer(LifecycleOwner owner) {
this.lifecycleOwner = owner;
}
@Override
public Observable<T> apply(Observable<T> upstream) {
return upstream.takeUntil(
Observable.create(emitter -> {
LifecycleObserver observer = new LifecycleObserver() {
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
void onDestroy() {
emitter.onComplete();
}
};
lifecycleOwner.getLifecycle().addObserver(observer);
})
);
}
}
9. Centralized Error Handling
Different failure modes require different handling. A centralized error mapper keeps your subscription lambdas clean.
public class NetworkError extends RuntimeException {
public enum Kind {
NETWORK, // No connectivity, timeout, DNS failure
HTTP, // Server returned 4xx or 5xx
BUSINESS, // HTTP 200 but business code indicates failure
UNEXPECTED // Serialization error, null pointer, etc.
}
private final Kind kind;
private final int code;
private final String message;
public NetworkError(Kind kind, int code, String message) {
super(message);
this.kind = kind;
this.code = code;
this.message = message;
}
// Getters omitted.
}
A transformer that converts raw Throwables into NetworkErrors:
public class ErrorTransformer<T> implements SingleTransformer<T, T> {
@Override
public Single<T> apply(Single<T> upstream) {
return upstream.onErrorResumeNext(throwable -> {
NetworkError error;
if (throwable instanceof IOException) {
error = new NetworkError(
NetworkError.Kind.NETWORK, -1,
"Network unavailable. Please check your connection."
);
} else if (throwable instanceof HttpException) {
HttpException http = (HttpException) throwable;
error = new NetworkError(
NetworkError.Kind.HTTP, http.code(),
"Server error: " + http.code()
);
} else if (throwable instanceof ApiException) {
ApiException api = (ApiException) throwable;
error = new NetworkError(
NetworkError.Kind.BUSINESS, api.getCode(),
api.getMessage()
);
} else {
error = new NetworkError(
NetworkError.Kind.UNEXPECTED, -1,
"An unexpected error occurred."
);
}
return Single.error(error);
});
}
}
Usage:
service.getUsers(1, 20)
.compose(new ErrorTransformer<>())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
response -> showUsers(response.getData()),
error -> handleNetworkError((NetworkError) error)
);
10. Caching Strategies
OkHttp supports HTTP caching out of the box. To enable it, configure a Cache on the OkHttpClient:
int cacheSize = 10 * 1024 * 1024; // 10 MB
Cache cache = new Cache(context.getCacheDir(), cacheSize);
OkHttpClient client = new OkHttpClient.Builder()
.cache(cache)
.build();
10.1 Network-First, Cache-Fallback
For data that should be fresh but can tolerate staleness, use a request that prefers the network and falls back to cache:
Request networkRequest = new Request.Builder()
.url(url)
.cacheControl(CacheControl.FORCE_NETWORK)
.build();
If the network fails, retry with CacheControl.FORCE_CACHE.
10.2 Cache-First for Offline Support
Request cacheRequest = new Request.Builder()
.url(url)
.cacheControl(new CacheControl.Builder()
.maxStale(7, TimeUnit.DAYS)
.build())
.build();
This serves cached responses up to 7 days old, which is useful for rarely-changing reference data.
11. Testing the Network Layer
Retrofit’s interface-based design makes testing straightforward. You can point Retrofit at a MockWebServer from OkHttp’s test module:
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
Example test:
public class UserServiceTest {
private MockWebServer server;
private UserService service;
@Before
public void setUp() throws IOException {
server = new MockWebServer();
server.start();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(server.url("/").toString())
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.build();
service = retrofit.create(UserService.class);
}
@After
public void tearDown() throws IOException {
server.shutdown();
}
@Test
public void getUserById_parsesResponse() throws Exception {
server.enqueue(new MockResponse()
.setBody("{\"code\":200,\"message\":\"ok\",\"data\":{\"id\":1,\"name\":\"Alice\"}}")
.setResponseCode(200));
ApiResponse<User> response = service.getUserById(1)
.blockingGet();
assertEquals(200, response.getCode());
assertEquals("Alice", response.getData().getName());
}
}
MockWebServer lets you enqueue canned responses and assert on the requests your code actually made, giving you full control over the network layer in tests without touching a real server.
12. Best Practices Summary
- Initialize Retrofit once. Use a singleton. Rebuilding it on every call wastes resources and defeats connection pooling.
- Always dispose subscriptions. Use
CompositeDisposableand clear it inonDestroy()to prevent memory leaks and crashes. - Subscribe on IO, observe on main. Network calls must never run on the main thread; UI updates must never run on a background thread.
- Centralize error handling. A single
ErrorTransformerkeeps business logic clean and ensures consistent user-facing error messages. - Use interceptors for cross-cutting concerns. Auth tokens, logging, and header injection belong in interceptors, not scattered through your API calls.
- Enable logging only in debug builds.
HttpLoggingInterceptoratLevel.BODYleaks sensitive data and adds overhead — restrict it to non-release builds. - Set reasonable timeouts. Mobile networks are slow. 15–30 seconds is a reasonable range; shorter timeouts cause false failures on poor connections.
- Cache when possible. Reference data and images benefit enormously from caching, reducing bandwidth and improving perceived performance.
- Prefer
SingleoverObservablefor one-shot calls. It communicates intent clearly and prevents accidental multiple emissions. - Test with
MockWebServer. Do not rely on live servers in unit tests — they are slow, flaky, and non-deterministic.
13. Conclusion
A well-structured Android networking layer built on OkHttp, Retrofit, and RxJava provides:
- Declarative API definitions through annotated interfaces.
- Reactive composition that eliminates callback hell and makes complex async flows readable.
- Powerful middleware via the interceptor chain for auth, logging, and error normalization.
- Excellent testability through interface-based design and
MockWebServer.
By following the patterns in this guide — single-instance Retrofit, proper subscription management, centralized error handling, and thoughtful caching — you can build a networking layer that is resilient to the realities of mobile connectivity, easy to maintain, and a pleasure to test.
The key insight is that reactive programming is not just about threading — it is about treating network calls as composable streams of data, which lets you express complex operations like retries, parallel fetches, and dependent chains as clean, declarative pipelines.
- 点赞
- 收藏
- 关注作者
评论(0)