Part 2 of 8 GoF Patterns That Decide If Your Android App Scales
Observer — UI That Never Goes Stale
The Observer pattern defines a one-to-many dependency: when one object (the subject) changes state, all its observers are notified automatically.
In Android this used to mean manually calling notifyDataSetChanged(), toggling visibility in callbacks, and debugging why the UI showed old data after a rotation. StateFlow fixed all of that.
The Problem Without Observer
// Old approach — manual sync, guaranteed to driftclass LoginActivity : AppCompatActivity() {
private fun doLogin() { showLoading() api.login(email, password) { result -> hideLoading() if (result.isSuccess) { navigateToHome() } else { showError(result.error) // Did we hide loading? Did we re-enable the button? // What about rotation? Config changes? 🤯 } } }}Each callback adds a new branch. Rotation destroys the Activity mid-flight. Error states get forgotten.
Observer with StateFlow
// ViewModel is the subject — emits state changesclass LoginViewModel( private val authRepo: AuthRepository) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle) val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun login(email: String, password: String) { viewModelScope.launch { _uiState.value = LoginUiState.Loading authRepo.login(email, password) .onSuccess { _uiState.value = LoginUiState.Success(it) } .onFailure { _uiState.value = LoginUiState.Error(it.message) } } }}// Fragment is the observer — reacts to every emissionlifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> when (state) { is LoginUiState.Idle -> showForm() is LoginUiState.Loading -> showLoader() is LoginUiState.Success -> navigateToHome(state.user) is LoginUiState.Error -> showError(state.message) } } }}The Fragment never calls the API. The ViewModel never touches Views. Rotation, back stack, process death — all handled by the lifecycle-aware collector.
With Jetpack Compose
@Composablefun LoginScreen(viewModel: LoginViewModel = viewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle()
when (state) { is LoginUiState.Idle -> LoginForm(onLogin = viewModel::login) is LoginUiState.Loading -> CircularProgressIndicator() is LoginUiState.Success -> LaunchedEffect(Unit) { onNavigateHome() } is LoginUiState.Error -> ErrorSnackbar(state.message) }}One when expression. Zero manual View toggling.
State — The Compiler as Your Safety Net
The State pattern allows an object to alter its behaviour when its internal state changes. In Android terms: every screen has a finite set of states, and you should make impossible states impossible at the type level.
The Problem: Boolean Soup
// Which combinations are valid? Nobody knows.class LoginViewModel : ViewModel() { val isLoading = MutableLiveData<Boolean>() val isError = MutableLiveData<Boolean>() val errorMessage = MutableLiveData<String?>() val isSuccess = MutableLiveData<Boolean>() val user = MutableLiveData<User?>()
// Can isLoading and isError both be true? Can isSuccess be true with a null user? // The compiler has no idea. Runtime crashes will find out for you.}With 4 booleans you have 16 theoretical combinations. Maybe 3 are valid. The compiler won’t tell you.
Sealed Class UiState
// Every valid state is a type. Every invalid state is impossible.sealed class LoginUiState { object Idle : LoginUiState() object Loading : LoginUiState() data class Success(val user: User) : LoginUiState() data class Error(val message: String?) : LoginUiState()}Why this matters:
// The compiler enforces exhaustive handlingwhen (state) { LoginUiState.Idle -> ... LoginUiState.Loading -> ... is LoginUiState.Success -> ... // Compiler knows state.user is non-null is LoginUiState.Error -> ... // Compiler knows state.message exists // Forget a branch → compile error, not a runtime crash}You cannot access state.user when the state is Loading. You cannot have a Success with a null user. The type system prevents the bug before the test ever runs.
Real-World UiState Example
sealed class ProductDetailUiState { object Loading : ProductDetailUiState()
data class Content( val product: Product, val relatedProducts: List<Product>, val isFavorite: Boolean, val stockStatus: StockStatus ) : ProductDetailUiState()
data class Error( val message: String, val canRetry: Boolean ) : ProductDetailUiState()
// Add states as the screen grows — existing when() blocks will // produce compile errors if you forget to handle the new state object OutOfRegion : ProductDetailUiState()}One sealed class per screen. One collect in the Fragment or one when in the composable.
Observer + State Together
The real power comes from combining them:
// ViewModel combines both patternsval uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() // Observer// ^^^^^^^^^^^^^// Sealed class enforces valid states // StateObserver ensures the UI always reflects the latest state. State ensures that latest state is always valid.
Result: UI that never lies.
Next Up
Part 3: Proxy & Facade → — how to build a cache gate that’s invisible to callers, and how to keep your ViewModels thin.