The UI layer in Android serves two key purposes: it shows your app’s data on screen and handles user interactions. When users tap buttons or when data updates happen, the UI must reflect these changes immediately.
Transforming Data for Display
Raw data rarely fits what your UI needs. The UI layer transforms backend data into UI-ready formats. For example, a NewsUiState
combines article content, bookmark status, and user messages into one package the UI can easily display.
How Unidirectional Data Flow Works
Modern Android apps use Unidirectional Data Flow (UDF) for state management. Here’s how it works:
- Your ViewModel creates and owns the UI state
- The UI observes this state through StateFlow or LiveData
- When users act, the UI sends events to the ViewModel
- The ViewModel updates the data and creates a new state
- The UI displays the new state
This clear, one-way flow prevents confusing state updates and bugs.
Why Immutable State Matters
Always use immutable data classes for UI state. This means:
data class NewsUiState(
val isSignedIn: Boolean = false,
val newsItems: List<NewsItemUiState> = emptyList(),
val userMessages: List<String> = emptyList()
)
Immutable states can’t be changed after creation. This gives you:
- Consistent views based on complete state snapshots
- A single source of truth
- Easier debugging
- Fewer unnecessary UI updates
Jetpack Compose Integration
Compose was built for immutable state. It thrives with UDF, automatically updating the UI when state changes. Here’s a simple example:
class NewsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState = _uiState.asStateFlow()
fun bookmarkArticle(articleId: String) {
_uiState.update { currentState ->
// Create a new state with the updated article
val updatedItems = currentState.newsItems.map { item ->
if (item.id == articleId) item.copy(bookmarked = true) else item
}
currentState.copy(newsItems = updatedItems)
}
}
}
@Composable
fun NewsScreen(viewModel: NewsViewModel = viewModel()) {
// Convert StateFlow to Compose state
val uiState by viewModel.uiState.collectAsState()
LazyColumn {
items(uiState.newsItems) { newsItem ->
NewsItemCard(
newsItem = newsItem,
onBookmarkClick = { viewModel.bookmarkArticle(newsItem.id) }
)
}
}
}
The UI automatically updates when state changes. No manual refresh needed.
State Hoisting
Lift state up to the highest component that needs it. This keeps child components simpler and more reusable. Let parent components manage state and pass down only what children need.
Testing Benefits
UDF makes testing easy. You can create state snapshots and verify your UI shows them correctly. No complex setup or mocking needed—just create state objects and test.
Error Handling
Always include error states in your UI model:
data class NewsUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val newsItems: List<NewsItemUiState> = emptyList()
)
This lets you show loading spinners or error messages naturally.
One-Time Events
Some actions don’t change state but need to happen once, like navigation or showing a toast. Use a Channel or Event wrapper:
sealed class NewsEvent {
data class Navigate(val articleId: String) : NewsEvent()
data class ShowToast(val message: String) : NewsEvent()
}
Saving State
Use SavedStateHandle in your ViewModel to preserve state across screen rotations and process death. Here’s how:
class NewsViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
// Define keys for values to save
private companion object {
const val UI_STATE_KEY = "ui_state_key"
const val LAST_REFRESH_KEY = "last_refresh_key"
}
// Initialize state from saved state or default
private val _uiState = MutableStateFlow(
savedStateHandle.get<NewsUiState>(UI_STATE_KEY) ?: NewsUiState()
)
val uiState = _uiState.asStateFlow()
init {
// Save state automatically when it changes
viewModelScope.launch {
_uiState.collect { newState ->
savedStateHandle[UI_STATE_KEY] = newState
}
}
}
// Save simple values directly
fun setLastRefreshTime(timeMillis: Long) {
savedStateHandle[LAST_REFRESH_KEY] = timeMillis
}
}
This automatically preserves your state when the app goes into the background or during configuration changes.
Performance Tips
Be careful with large immutable states. Here’s how to optimize:
// PROBLEM: This will cause recomposition of the entire list when any field changes
class NewsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState = _uiState.asStateFlow()
}
// SOLUTION: Split state into smaller, more focused flows
class NewsViewModel : ViewModel() {
// Authentication state rarely changes
private val _authState = MutableStateFlow(AuthState())
val authState = _authState.asStateFlow()
// News items might change frequently
private val _newsItems = MutableStateFlow<List<NewsItemUiState>>(emptyList())
val newsItems = _newsItems.asStateFlow()
// Error states are transient
private val _errorState = MutableStateFlow<ErrorState?>(null)
val errorState = _errorState.asStateFlow()
.distinctUntilChanged() // Prevent duplicate emissions
}
This approach lets you update just what changed without rebuilding everything. Use distinctUntilChanged()
to filter out duplicate states.
Real-World Implementation
This approach fits perfectly with MVVM or MVI patterns. In MVVM, the ViewModel owns state. In MVI, the Intent processor creates new states based on user actions.
Remember the Lifecycle
ViewModels outlive configuration changes but die with their associated UI components. Use appropriate scopes for your ViewModels to avoid memory leaks.
By following these principles, you’ll build Android apps that are easier to understand, test, and maintain.