0) Problem Restatement
Design the client-side architecture of an Android app screen that calls backend APIs, using MVVM (Model–View–ViewModel), as asked at OpenAI for a mobile role. For example: a list of conversations that loads from an API, supports pull-to-refresh, pagination and offline viewing, and survives screen rotation. Cover the HTTP request lifecycle, error handling, caching and testability.
1) The Layers
Architecture Diagram
flowchart LR
V["View - Activity / Compose UI"] -->|"user events"| VM["ViewModel - holds UiState"]
VM -->|"StateFlow of UiState"| V
VM --> UC["Use cases (optional)"]
UC --> R["Repository - single source of truth"]
R --> LOCAL[("Local DB - Room")]
R --> REMOTE["Remote API - Retrofit / OkHttp"]
REMOTE --> NET["Backend"]- View (Jetpack Compose or Activity/Fragment): draws the UI from state and sends user events (tap, refresh) to the ViewModel. No business logic.
- ViewModel: holds the screen's UiState, survives configuration changes (rotation), runs work in
viewModelScope, and exposesStateFlow<UiState>. - Repository: decides where data comes from (cache or network), and hides Retrofit and Room from the ViewModel.
- Data sources: Retrofit API (remote) and Room database (local).
- Dependency injection (Hilt) wires these together, which makes testing easy.
2) UI State
data class ConversationsUiState(
val items: List<ConversationUi> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null, // user-friendly message
val endReached: Boolean = false
)
One immutable state object per screen. The View just renders it. This avoids bugs where the loading spinner and error message disagree.
3) Request Lifecycle
class ConversationsViewModel @Inject constructor(private val repo: ConversationRepository) : ViewModel() {
private val _state = MutableStateFlow(ConversationsUiState(isLoading = true))
val state: StateFlow<ConversationsUiState> = _state
init {
viewModelScope.launch {
repo.observeConversations() // Room Flow: emits whenever the DB changes
.collect { list -> _state.update { it.copy(items = list.map(::toUi), isLoading = false) } }
}
refresh()
}
fun refresh() = viewModelScope.launch {
_state.update { it.copy(isRefreshing = true, error = null) }
when (val r = repo.refresh()) { // network -> write to Room
is Result.Error -> _state.update { it.copy(isRefreshing = false, error = r.userMessage) }
is Result.Success -> _state.update { it.copy(isRefreshing = false) }
}
}
}
- Single source of truth: the UI always reads from Room, and the network only writes into Room. So offline mode works automatically, and there's never a mismatch between the cache and the screen.
- Cancellation:
viewModelScopecancels requests when the screen is closed. No leaks, no wasted calls. - Threading: Retrofit suspend functions and Room run off the main thread, and state updates are collected on the main thread.
4) Networking Details
- Retrofit + OkHttp: an interceptor adds the auth token, and an authenticator refreshes expired tokens once and retries.
- Timeouts: connect/read timeouts (e.g., 10s/30s). For streaming responses (LLM tokens), use a streaming call and update the state incrementally.
- Retries: automatic retry with backoff only for idempotent GETs and network errors. Never auto-retry non-idempotent POSTs without an idempotency key.
- Error mapping: convert HTTP and IO errors into a
Result.Errorwith a user-friendly message (no connection, session expired, server error) in the repository, so the ViewModel stays simple. - HTTP caching: ETags /
If-None-Matchto save data on refresh.
5) Pagination and Offline
- Use Paging 3 with a RemoteMediator: it loads pages from the API into Room, and the UI pages from Room. Scrolling works offline for cached pages.
- Queue user actions made offline (e.g., "archive conversation") with WorkManager, and retry when back online.
6) Testing
- ViewModel tests: fake the repository, and assert UiState transitions (loading → items; refresh error → error message).
- Repository tests: MockWebServer for the API, an in-memory Room DB.
- UI tests: Compose testing with fake state.
7) Wrap-Up
Keep the View dumb, give each screen a ViewModel that exposes a single immutable UiState through StateFlow and launches work in viewModelScope, and put a repository in front of Retrofit and Room with Room as the single source of truth. Handle auth, timeouts, safe retries and error mapping in the data layer, use Paging 3 with RemoteMediator for pagination and offline, and inject dependencies with Hilt so every layer is testable in isolation.