Modern Multi-Screen Android Architecture Guide Using Java
Modern Multi-Screen Android Architecture Guide Using Java
Introduction
This guide explores the architecture for building robust, maintainable, and testable multi-screen Android applications using Java, following modern architectural principles and design patterns. We’ll cover the complete architecture from UI components to data persistence, focusing on a modular, layered approach that separates concerns and creates a clean, scalable codebase.
Table of Contents
- Architectural Overview
- Application Layers
- UI Layer
- Presentation Layer
- Domain Layer
- Data Layer
- Dependency Injection
- Navigation Between Screens
- Error Handling
- Testing Strategy
- Complete Architecture Example
- Best Practices
Architectural Overview
A well-designed Android application follows these key principles:
- Separation of concerns: Each component has specific responsibilities
- Driving UI from data models: UI reactively updates based on data changes
- Single source of truth: Data is stored and managed from one definitive source
- Unidirectional data flow: Data flows in one direction, making the app’s behavior predictable
- Testability: Architecture designed to make testing straightforward at all levels
The recommended architecture follows the MVVM (Model-View-ViewModel) pattern enhanced with Clean Architecture principles:
Application Layers
Our architecture consists of four main layers:
- UI Layer: Activities, Fragments, Views, and UI-related components
- Presentation Layer: ViewModels and UI state management
- Domain Layer: Business logic and use cases
- Data Layer: Repositories and data sources (local/remote)
Let’s explore each layer in detail.
UI Layer
The UI layer is responsible for displaying data to the user and capturing user interactions. It consists of Activities, Fragments, and custom Views.
Activities
In a multi-screen application, Activities typically serve as containers for Fragments rather than containing UI logic themselves.
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}
Fragments
Fragments represent distinct screens or UI components within your app:
public class ProductListFragment extends Fragment {
private ProductViewModel viewModel;
private ProductAdapter adapter;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_product_list, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Set up RecyclerView
RecyclerView recyclerView = view.findViewById(R.id.product_list);
adapter = new ProductAdapter(item -> {
// Navigate to product detail screen when item is clicked
navigateToProductDetail(item.getId());
});
recyclerView.setAdapter(adapter);
// Initialize ViewModel
viewModel = new ViewModelProvider(this).get(ProductViewModel.class);
// Observe LiveData from ViewModel
viewModel.getProducts().observe(getViewLifecycleOwner(), products -> {
adapter.submitList(products);
});
// Observe loading state
viewModel.getLoadingState().observe(getViewLifecycleOwner(), isLoading -> {
// Show/hide loading indicator
view.findViewById(R.id.progress_bar).setVisibility(
isLoading ? View.VISIBLE : View.GONE);
});
}
private void navigateToProductDetail(long productId) {
Bundle args = new Bundle();
args.putLong("productId", productId);
// Using Navigation Component
NavHostFragment.findNavController(this)
.navigate(R.id.action_productList_to_productDetail, args);
}
}
Navigation Component
For multi-screen applications, the Navigation Component provides a framework for implementing navigation:
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"
android:id="@+id/nav_graph"
app:startDestination="@id/productListFragment">
<fragment
android:id="@+id/productListFragment"
android:name="com.example.app.ui.ProductListFragment"
android:label="Products">
<action
android:id="@+id/action_productList_to_productDetail"
app:destination="@id/productDetailFragment" />
</fragment>
<fragment
android:id="@+id/productDetailFragment"
android:name="com.example.app.ui.ProductDetailFragment"
android:label="Product Details">
<argument
android:name="productId"
app:argType="long" />
</fragment>
</navigation>
Presentation Layer
The presentation layer contains ViewModels and UI state management logic.
ViewModel
ViewModels store and manage UI-related data, surviving configuration changes:
public class ProductViewModel extends ViewModel {
private final ProductRepository repository;
private final MutableLiveData<List<Product>> products = new MutableLiveData<>();
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
public ProductViewModel() {
// In real app, use dependency injection
repository = ProductRepository.getInstance();
loadProducts();
}
public LiveData<List<Product>> getProducts() {
return products;
}
public LiveData<Boolean> getLoadingState() {
return isLoading;
}
private void loadProducts() {
isLoading.setValue(true);
// Execute on a background thread
new Thread(() -> {
try {
List<Product> result = repository.getProducts();
products.postValue(result);
} catch (Exception e) {
// Handle error
} finally {
isLoading.postValue(false);
}
}).start();
}
public void refreshProducts() {
loadProducts();
}
}
LiveData
LiveData is a lifecycle-aware observable data holder:
// In ViewModel
private final MutableLiveData<User> userData = new MutableLiveData<>();
public LiveData<User> getUserData() {
return userData;
}
// In Fragment or Activity
viewModel.getUserData().observe(getViewLifecycleOwner(), user -> {
// Update UI with user data
});
State Management
For more complex UIs, consider using a state object to represent the complete UI state:
public class ProductListState {
private final List<Product> products;
private final boolean isLoading;
private final String errorMessage;
// Constructor, getters, etc.
}
// In ViewModel
private final MutableLiveData<ProductListState> uiState = new MutableLiveData<>();
public LiveData<ProductListState> getUiState() {
return uiState;
}
// Update state
private void updateState(List<Product> newProducts, boolean loading, String error) {
ProductListState newState = new ProductListState(newProducts, loading, error);
uiState.setValue(newState);
}
Domain Layer
The domain layer contains business logic and use cases, independent of the Android framework.
Use Cases / Interactors
Use cases encapsulate specific business operations:
public class GetProductsUseCase {
private final ProductRepository repository;
public GetProductsUseCase(ProductRepository repository) {
this.repository = repository;
}
public List<Product> execute() throws Exception {
return repository.getProducts();
}
}
public class AddProductToCartUseCase {
private final CartRepository cartRepository;
private final ProductRepository productRepository;
public AddProductToCartUseCase(CartRepository cartRepository,
ProductRepository productRepository) {
this.cartRepository = cartRepository;
this.productRepository = productRepository;
}
public void execute(long productId, int quantity) throws Exception {
Product product = productRepository.getProductById(productId);
if (product != null && product.isInStock()) {
cartRepository.addItem(product, quantity);
} else {
throw new OutOfStockException("Product is out of stock");
}
}
}
Domain Models
Domain models represent core business entities:
public class Product {
private final long id;
private final String name;
private final String description;
private final double price;
private final int stockQuantity;
// Constructor, getters, etc.
public boolean isInStock() {
return stockQuantity > 0;
}
}
Data Layer
The data layer handles data operations using the Repository pattern and various data sources.
Repository Pattern
Repositories abstract the data sources and provide clean APIs to the rest of the app:
public class ProductRepository {
private final ProductRemoteDataSource remoteDataSource;
private final ProductLocalDataSource localDataSource;
public ProductRepository(ProductRemoteDataSource remoteDataSource,
ProductLocalDataSource localDataSource) {
this.remoteDataSource = remoteDataSource;
this.localDataSource = localDataSource;
}
public List<Product> getProducts() throws Exception {
try {
// Try to fetch fresh data from the network
List<ProductDto> networkResult = remoteDataSource.getProducts();
List<Product> products = mapToDomainModel(networkResult);
// Cache the results locally
localDataSource.saveProducts(products);
return products;
} catch (IOException e) {
// Network error, try to fetch from local cache
return localDataSource.getProducts();
}
}
public Product getProductById(long id) throws Exception {
try {
ProductDto dto = remoteDataSource.getProductById(id);
return mapToDomainModel(dto);
} catch (IOException e) {
// Fallback to local cache
return localDataSource.getProductById(id);
}
}
// Helper methods for mapping between DTOs and domain models
private List<Product> mapToDomainModel(List<ProductDto> dtos) {
// Mapping logic
}
private Product mapToDomainModel(ProductDto dto) {
// Mapping logic
}
}
Data Sources
Data sources handle specific data operations:
public interface ProductDataSource {
List<ProductDto> getProducts() throws Exception;
ProductDto getProductById(long id) throws Exception;
}
public class ProductRemoteDataSource implements ProductDataSource {
private final ProductApiService apiService;
public ProductRemoteDataSource(ProductApiService apiService) {
this.apiService = apiService;
}
@Override
public List<ProductDto> getProducts() throws IOException {
Response<List<ProductDto>> response = apiService.getProducts().execute();
if (response.isSuccessful()) {
return response.body();
} else {
throw new IOException("Error fetching products: " + response.errorBody());
}
}
@Override
public ProductDto getProductById(long id) throws IOException {
Response<ProductDto> response = apiService.getProductById(id).execute();
if (response.isSuccessful()) {
return response.body();
} else {
throw new IOException("Error fetching product: " + response.errorBody());
}
}
}
public class ProductLocalDataSource implements ProductDataSource {
private final ProductDao productDao;
public ProductLocalDataSource(ProductDao productDao) {
this.productDao = productDao;
}
@Override
public List<ProductDto> getProducts() {
List<ProductEntity> entities = productDao.getAll();
return mapToDataModel(entities);
}
@Override
public ProductDto getProductById(long id) {
ProductEntity entity = productDao.getById(id);
return mapToDataModel(entity);
}
public void saveProducts(List<Product> products) {
List<ProductEntity> entities = mapToEntityModel(products);
productDao.insertAll(entities);
}
// Mapping methods
private List<ProductDto> mapToDataModel(List<ProductEntity> entities) {
// Mapping logic
}
private ProductDto mapToDataModel(ProductEntity entity) {
// Mapping logic
}
private List<ProductEntity> mapToEntityModel(List<Product> products) {
// Mapping logic
}
}
Room Database
Room provides an abstraction layer over SQLite for local data persistence:
ProductEntity.java:
@Entity(tableName = "products")
public class ProductEntity {
@PrimaryKey
private long id;
private String name;
private String description;
private double price;
private int stockQuantity;
// Constructor, getters, setters
}
ProductDao.java:
@Dao
public interface ProductDao {
@Query("SELECT * FROM products")
List<ProductEntity> getAll();
@Query("SELECT * FROM products WHERE id = :id")
ProductEntity getById(long id);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertAll(List<ProductEntity> products);
@Update
void update(ProductEntity product);
@Delete
void delete(ProductEntity product);
}
AppDatabase.java:
@Database(entities = {ProductEntity.class, UserEntity.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract ProductDao productDao();
public abstract UserDao userDao();
private static volatile AppDatabase INSTANCE;
public static AppDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (AppDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
AppDatabase.class,
"app_database")
.build();
}
}
}
return INSTANCE;
}
}
Network with Retrofit
Retrofit is used for making API calls:
ProductApiService.java:
public interface ProductApiService {
@GET("products")
Call<List<ProductDto>> getProducts();
@GET("products/{id}")
Call<ProductDto> getProductById(@Path("id") long id);
@POST("products")
Call<ProductDto> createProduct(@Body ProductDto product);
}
ProductDto.java:
public class ProductDto {
@SerializedName("id")
private long id;
@SerializedName("name")
private String name;
@SerializedName("description")
private String description;
@SerializedName("price")
private double price;
@SerializedName("in_stock")
private boolean inStock;
// Constructor, getters, setters
}
RetrofitClient.java:
public class RetrofitClient {
private static final String BASE_URL = "https://api.example.com/";
private static Retrofit retrofit = null;
public static Retrofit getClient() {
if (retrofit == null) {
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
.build();
retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build();
}
return retrofit;
}
}
Dependency Injection
For a production application, use a dependency injection framework like Dagger or Hilt to manage dependencies. For a simpler approach, you can create factory classes:
ViewModelFactory.java:
public class ViewModelFactory implements ViewModelProvider.Factory {
private final ProductRepository productRepository;
public ViewModelFactory(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
if (modelClass.isAssignableFrom(ProductViewModel.class)) {
return (T) new ProductViewModel(productRepository);
}
throw new IllegalArgumentException("Unknown ViewModel class");
}
}
Navigation Between Screens
For a multi-screen application, the Navigation Component is recommended:
Setup in Activity:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager()
.findFragmentById(R.id.nav_host_fragment);
NavController navController = navHostFragment.getNavController();
// Set up ActionBar with NavController
AppBarConfiguration appBarConfiguration = new AppBarConfiguration
.Builder(navController.getGraph())
.build();
NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
}
@Override
public boolean onSupportNavigateUp() {
NavController navController = Navigation.findNavController(
this, R.id.nav_host_fragment);
return navController.navigateUp() || super.onSupportNavigateUp();
}
}
Navigating Between Fragments:
// Navigate with arguments
Bundle args = new Bundle();
args.putLong("productId", product.getId());
NavHostFragment.findNavController(this)
.navigate(R.id.action_productList_to_productDetail, args);
// Simple navigation
NavHostFragment.findNavController(this)
.navigate(R.id.action_productDetail_to_cart);
Error Handling
Implement a comprehensive error handling strategy:
public class Result<T> {
private final T data;
private final Exception error;
private Result(T data, Exception error) {
this.data = data;
this.error = error;
}
public static <T> Result<T> success(T data) {
return new Result<>(data, null);
}
public static <T> Result<T> error(Exception error) {
return new Result<>(null, error);
}
public boolean isSuccess() {
return error == null;
}
public T getData() {
return data;
}
public Exception getError() {
return error;
}
}
// In Repository
public Result<List<Product>> getProductsWithResult() {
try {
List<Product> products = getProducts();
return Result.success(products);
} catch (Exception e) {
return Result.error(e);
}
}
// In ViewModel
public void loadProductsWithErrorHandling() {
isLoading.setValue(true);
new Thread(() -> {
Result<List<Product>> result = repository.getProductsWithResult();
if (result.isSuccess()) {
products.postValue(result.getData());
error.postValue(null);
} else {
error.postValue(result.getError().getMessage());
}
isLoading.postValue(false);
}).start();
}
Testing Strategy
A good architecture makes testing easier:
Unit Testing ViewModels:
@RunWith(JUnit4.class)
public class ProductViewModelTest {
// Test rule for LiveData
@Rule
public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();
private ProductRepository mockRepository;
private ProductViewModel viewModel;
@Before
public void setup() {
mockRepository = mock(ProductRepository.class);
viewModel = new ProductViewModel(mockRepository);
}
@Test
public void loadProducts_success() throws Exception {
// Arrange
List<Product> expectedProducts = Arrays.asList(
new Product(1, "Product 1", "Description 1", 10.0, 5),
new Product(2, "Product 2", "Description 2", 20.0, 10)
);
when(mockRepository.getProducts()).thenReturn(expectedProducts);
// Act
viewModel.loadProducts();
// Assert
verify(mockRepository).getProducts();
assertEquals(expectedProducts, viewModel.getProducts().getValue());
assertEquals(false, viewModel.getLoadingState().getValue());
}
@Test
public void loadProducts_error() throws Exception {
// Arrange
Exception expectedException = new IOException("Network error");
when(mockRepository.getProducts()).thenThrow(expectedException);
// Act
viewModel.loadProducts();
// Assert
verify(mockRepository).getProducts();
assertEquals(expectedException.getMessage(), viewModel.getError().getValue());
assertEquals(false, viewModel.getLoadingState().getValue());
}
}
Testing Repositories:
@RunWith(JUnit4.class)
public class ProductRepositoryTest {
private ProductRemoteDataSource mockRemoteDataSource;
private ProductLocalDataSource mockLocalDataSource;
private ProductRepository repository;
@Before
public void setup() {
mockRemoteDataSource = mock(ProductRemoteDataSource.class);
mockLocalDataSource = mock(ProductLocalDataSource.class);
repository = new ProductRepository(mockRemoteDataSource, mockLocalDataSource);
}
@Test
public void getProducts_remoteSuccess() throws Exception {
// Arrange
List<ProductDto> remoteDtos = Arrays.asList(
new ProductDto(1, "Product 1", "Description 1", 10.0, true),
new ProductDto(2, "Product 2", "Description 2", 20.0, true)
);
when(mockRemoteDataSource.getProducts()).thenReturn(remoteDtos);
// Act
List<Product> products = repository.getProducts();
// Assert
verify(mockRemoteDataSource).getProducts();
assertEquals(2, products.size());
assertEquals(1, products.get(0).getId());
assertEquals("Product 1", products.get(0).getName());
}
@Test
public void getProducts_remoteFails_fallbackToLocal() throws Exception {
// Arrange
when(mockRemoteDataSource.getProducts()).thenThrow(new IOException("Network error"));
List<ProductDto> localDtos = Arrays.asList(
new ProductDto(1, "Product 1", "Description 1", 10.0, true)
);
when(mockLocalDataSource.getProducts()).thenReturn(localDtos);
// Act
List<Product> products = repository.getProducts();
// Assert
verify(mockRemoteDataSource).getProducts();
verify(mockLocalDataSource).getProducts();
assertEquals(1, products.size());
}
}
Complete Architecture Example
Here’s how all the pieces come together in a multi-screen application:
Application Flow
- User interacts with the UI (Fragment/Activity)
- UI passes events to ViewModel
- ViewModel executes use cases/interacts with repositories
- Repository fetches data from local or remote data sources
- Data flows back through the chain: Repository → ViewModel → UI
- UI updates based on the new data
Sample Multi-Screen Application
A typical multi-screen e-commerce app might have:
-
Product List Screen
- Shows a list of products
- Allows filtering and sorting
- Navigate to product details on click
-
Product Detail Screen
- Shows detailed product information
- Allows adding to cart
- Shows related products
-
Shopping Cart Screen
- Shows items in cart
- Allows changing quantities
- Calculates total price
- Proceeds to checkout
-
Checkout Screen
- Enter shipping information
- Select payment method
- Complete order
Each screen would have its own Fragment, ViewModel, and potentially domain-specific use cases, all sharing common repositories and data sources.
Best Practices
-
Use Single-Activity Architecture
- One main activity with multiple fragments
- Use Navigation Component for transitions
-
ViewModels Should Be Independent
- Don’t share ViewModels between unrelated screens
- Use SavedStateHandle for process death
-
Repository as Single Source of Truth
- All data operations go through repositories
- Repositories decide which data source to use
-
Room for Local Persistence
- Use Room for structured data storage
- Define clear entities and relations
-
Main Thread Safety
- Use background threads or coroutines for I/O operations
- Update LiveData on main thread
-
Clear Separation Between Layers
- UI layer shouldn’t know about data sources
- Domain layer shouldn’t depend on Android
-
Use DTOs for Network/Database
- Separate data models for network/database
- Map to domain models in repositories
-
Error Handling Strategy
- Define clear error types
- Provide user-friendly error messages
-
Unit Tests for Each Layer
- Test ViewModels, repositories, and use cases independently
- Use mock dependencies for isolation
-
UI Testing for Critical Flows
- Test end-to-end user journeys
- Verify UI behavior and navigation
By following these architecture patterns and best practices, you can build a robust, maintainable, and testable multi-screen Android application that can scale with your requirements.