Quiblo wiki

Architecture

How data reaches the screen

The browse path in detail, and the two performance defects that lived in it.

# The path

provider :source:* Room Repository ViewModel UI state screen M3U or Xtream parse, rate-limit channels table maps off-main combine, debounce one immutable object Compose a refresh writes once; everything after is a query
The provider is touched on a refresh and almost never again. Browsing, filtering, searching and favouriting are database reads — which is what makes the app usable offline and what keeps a 67,000-channel account from being a request storm.

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.