ReadThat 1: Client Architecture
Layered feature modules, MVVM with unidirectional data flow, offline-first Room, a two-tier cache contract, metered-aware background workers, and the full Jetpack inventory, including which pieces are Kotlin Multiplatform.
Part 1 of the ReadThat case study.
📲 Try it live: Download the ReadThat APK (8.3 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.
The client has one governing rule: render durable local state, then reconcile it with the network. Every other decision, the module graph, the cache tiers, the workers, exists to serve that rule.
Module graph
Android and iOS now have one product implementation: :feature:app-ui coordinates the KMP screen modules on both platforms, and :core:client owns the shared ViewModel, controllers, and offline-first repositories. The former Android-only UI/features and their rollback flag have been deleted; :app remains only where Android genuinely needs an APK/process host.
:app (Android APK, WorkManager, process lifecycle) └── :composeApp (Android entrypoint, process graph, saved-state adapter)
:iosApp (SwiftUI lifecycle + narrow Apple shims) └── :composeApp (exported iOS framework + process graph)
:composeApp └── :feature:app-ui (KMP application coordinator + navigation) ├── :feature:feed-ui / detail-ui / mediafeed-ui / search-ui / │ profile-ui / creation-ui / settings-ui / community-ui / │ auth-ui / ad-ui / shell-ui (KMP screens) ├── :core:design / :core:image-ui / :core:media-ui (shared UI + optimized target actuals) ├── :core:media-acquisition(+-ui) / :core:sharing(+-ui) (shared policy; platform actuals) ├── :core:navigation (shared destinations, bounded/versioned restoration) └── :core:client (shared MVVM controllers + offline-first repositories) ├── :core:data (Room 3 KMP schema/DAOs, commonMain) ├── :core:network (shared contract; HttpEngine/OkHttp + URLSession actuals) ├── :core:model / :core:deeplink └── :core:observability (KMP event contract)
Native capability modules keep platform engines behind shared contracts: :core:media / :core:media-ui / :core:image-ui :core:media-acquisition(+-ui) / :core:sharing(+-ui)settings.gradle.kts · docs/ARCHITECTURE.md
The allowed dependency direction is enforced, not aspirational:
UI -> domain <- data -> network/platformapp -> feature -> coreDomain code does not depend on Compose, Room, HTTP, or platform classes. UI never calls a network client or a DAO. Cross-feature navigation passes identifiers, never mutable objects: :feature:feed-ui and :feature:detail-ui stay independent while feed dwell-prefetch and detail share one retained repository through the application composition root.
MVVM and unidirectional data flow
Each screen has one observable state and explicit intents:
Compose event -> ViewModel intent -> repository command | vnetwork response -> Room transaction -> Flow/StateFlow -> ViewModel state -> ComposeRoom is the authoritative stream. A successful network response is committed before it becomes visible. Optimistic actions (votes, posts, community creation) write visible local state and an outbox row in one transaction; a worker reconciles later.
Paging 3 removes the usual hand-written page list, cursor, and isAppending state. SharedFeedController owns the lifecycle-independent feed behavior; the shared ReadThatViewModel retains its Room-backed pages for both Android and iOS:
/** * Lifecycle-independent feed controller shared by focused feature ViewModels * and the application ViewModel. Navigation and native rendering stay at the * host edge; Room Paging, refresh classification, mutations, comment prefetch * and media handoff have one implementation. */internal class SharedFeedController( private val repository: OfflineFirstRepository, ...) { val cards: Flow<PagingData<FeedCard>> = repository.pagedFeedFor(...)
fun toggleLike(itemId: String) = vote(itemId, 1) fun vote(itemId: String, value: Int) { if (itemId.isBlank() || value !in -1..1) return scope.launch { repository.votePost(itemId, value, performanceSurface) } }}
class ReadThatViewModel(...) : ViewModel() { private val cachedPagedFeed = feedController.cards.cachedIn(viewModelScope) val pagedFeedCells = cachedPagedFeed.map { paging -> paging.flatMap { card -> card.cells } }}SharedFeedController.kt · ReadThatViewModel.kt#L212-L221
Offline-first, by construction
The network↔database seam is a Paging 3 RemoteMediator. It never serves pages to the UI; Room’s PagingSource does. The mediator only runs when the DB runs out of cached pages:
/** * - Offline works by construction. With no network, APPEND fails and * Paging keeps serving whatever the DB already holds. * - Paging survives process death, because the cursor is a row. * - One writer. Both the mediator and an optimistic like write to Room; * Room invalidates the PagingSource and the UI re-emits. */private inner class SharedFeedRemoteMediator( private val account: String, private val feedId: String, private val subreddit: String?, ...) : RemoteMediator<Int, GroupWithState>()OfflineFirstRepository.kt#L461-L572
Startup follows the same rule. MainActivity renders a cached shell immediately and reports fully drawn at that boundary; no network request is on the first-frame path. Measured on a Pixel 10 Pro with a warm process: p50 = 11 ms across ten am start -W samples. A cold process was 575 ms.
The two-tier cache contract
Every user-visible data family has a bounded hot tier and a durable tier, and every structured key includes the account identity. A condensed view (the full contract covers the remaining families):
| Data | L1 | L2 / source of truth |
|---|---|---|
| SDUI feed | bounded Paging cache (ten pages) | Room feed_groups, item_state, remote_keys |
| Votes | current Room-backed page state | item_state + coalescing vote_outbox |
| Comments/post headers | retained controller StateFlow | bounded Room cached_documents rows |
| Images | bounded platform decoded cache | Coil/shared-client stable-key disk cache |
| Video | one native process player + preload queues | Media3 segment cache / AVFoundation facilities |
The data layer page covers sizing and eviction; the point here is the contract: L1 never independently decides correctness. It removes query and mapping work from the hot render path. Room decides what exists.
Background workers
On Android, deferred work runs through WorkManager; the mutation lanes are network-constrained and idempotent through client-minted mutation IDs. iOS calls the same shared maintenance/repository operations from a BGTaskScheduler host.
FeedRefreshWorker: one unique periodic worker; refreshes Room first, warms a bounded image/poster plan on a validated path, then (only on unmetered, non-Data-Saver networks with autoplay enabled) asks Media3’sPreCacheHelperto persist the first two seconds of at most one ready video.- Vote / post / community outbox workers: unique one-time workers that drain durable outboxes. A post targeting a locally pending community waits at an explicit ordering barrier; when the community reconciles, dependent posts re-enqueue immediately instead of sitting in backoff.
- Telemetry drain: up to 50 events per request, immediate when connected, 15-minute safety sweep.
FeedWorkers.kt · PostUploadWorker.kt · SubredditCreationWorker.kt
Concurrency is handled where it actually bites: Paging and WorkManager create independent sync objects, so feed serialization uses a process-wide account/feed mutex, and an append re-reads its cursor after acquiring it, so a cursor captured before a competing refresh can never append an older generation.
Jetpack inventory
| Library | Role | KMP? |
|---|---|---|
| Compose Multiplatform + Material 3 | All UI: :feature:*-ui KMP screens render Android and iOS from one codebase | KMP: Android + iOS |
| Room 3 (KMP) | L2 source of truth; one schema and DAO set in commonMain for Android and iOS | KMP |
Paging 3 + RemoteMediator | Feed/search/media paging over Room, implemented in :core:client | KMP |
| WorkManager | Outbox drains, refresh, telemetry | Android |
Media3 (ExoPlayer, DefaultPreloadManager, SimpleCache) | Video | Android |
| Coil 3 | Images, over the shared transport | Multiplatform library, used on Android |
:core:navigation + Compose saveable state | Destinations, bounded history, screen/pager restoration | KMP policy/UI; native state-registry adapters |
SavedStateHandle / @SceneStorage | Persist the same versioned navigation snapshot | Android / iOS |
| JankStats | Frame health telemetry | Android |
| kotlinx.serialization / coroutines / Flow | Wire models, all async | KMP |
:core:model (contracts, reducers) | Domain contracts | KMP: Android + iOS + JS |
:core:observability (events, timers) | Vendor-neutral telemetry contract | KMP: Android + iOS + JS |
The KMP boundary is deliberate: contracts, UI, ViewModels/controllers, Room schema/DAOs, repositories, and paging policy are shared. Platform code supplies the optimized engines and lifecycle hooks—Media3 vs AVPlayer, HttpEngine/OkHttp vs URLSession, WorkManager vs Apple background scheduling, secure storage, pickers, and system share sheets. Observability details live in part 7.
📷 Screenshot placeholder: module graph rendered, plus the app shell on first frame.
Next: Part 2: The SDUI feed →