Architecture
How data reaches the screen
The browse path in detail, and the two performance defects that lived in it.
# The path
A refresh is the only time a provider is touched in bulk. It parses the catalogue and writes it into Room in a single transaction, chunked because SQLite binds a limited number of variables per statement and a twenty-thousand-entry playlist exceeds it comfortably.
Everything after that is a database query. Browsing, filtering, searching and favouriting never touch the network — which is what makes the app offline-tolerant and what keeps a large account from being a request storm.
# One state object per screen
BrowseViewModel combines several streams into a single immutable
BrowseUiState: the catalogue rows, the categories, the current filter and
search text, what is on now, scores, artwork, and continue-watching entries.
Two details in that combination are load-bearing:
- Search is debounced, so a fast typist does not issue a query per keystroke — but only when the text is non-empty, so clearing a search is instant.
- Each feed subscribes only to what it can display. The guide query asks for every programme airing now across a whole source; Movies and Series render no programme anywhere, so they do not subscribe to it. Favourites does, because it is built with the Live kind and shows a programme line for the live channels in it.
"Loading" and "no source configured" are separate states, deliberately. They were conflated once, so every browse screen opened by telling the user to add a playlist — including when they had one and it was still loading. Advice that is wrong for the first second is worse than a spinner.
# Two defects that lived here
Both were found from a single bug report — "the app froze when I scroll" — and both scaled with the size of the account. The account they were found on holds 67,567 channels.
Nothing indexed the browse query
The channels table was indexed on the source, on source-and-category, and on
source-and-identity. The browse query filters on source and kind and sorts by the
provider's order. Nothing covered that combination, so SQLite matched every row belonging to
the source, tested the kind one row at a time, then built a temporary B-tree to sort the
survivors — on every emission.
The fix is a composite index in the order the query asks its questions, with the sort column included so the temporary B-tree disappears too. Filtering alone would still have sorted tens of thousands of rows by hand.
The mapping ran on the main thread
This one is subtler and worth internalising, because it is a property of Flow rather than a mistake anyone would spot by reading the repository in isolation.
A Flow operator runs in its collector's context.
The repository mapped database rows to domain objects with a map and no
flowOn. Every collector in the app is a stateIn(viewModelScope, …),
and viewModelScope is the main dispatcher — so 67,567 objects were allocated on
the UI thread every time Room re-emitted, which is on every write to the table.
Keeping the SQL cheap was never enough, because the work after the SQL was the larger half. The dispatcher is now injected rather than hardcoded, so a test can hold the mapping to leaving the caller's thread — a thread assertion rather than a timing one, because a timing assertion passes on fast hardware with the bug fully reintroduced.
# Prefetching, and the rule for adding any
Two things are fetched lazily as you browse: a live channel's guide entry, and a title's score and artwork. Both are guarded, and the guards are described in the source layer.
The rule for adding any new per-item network call:
Check what renders the result — if nothing on screen shows it, do not fetch it — and check what happens when a finger flings the list, or when a D-pad is held down.
A held D-pad flies through a list far faster than a finger ever scrolls, which is why the television fetches on focus settling and the phone on scroll settling, rather than either fetching on composition.