Skip to content
academia.sh

Lesson 13 / 14

Paged Data Loading

A long list is loaded not all at once but page by page; each page is requested with a page key. The lesson measures the origin of a read of an item on a page that has not yet been loaded, how the origin changes when the page is loaded, and the same item coming from the network or from disk.

Published Updated

In this lesson

  • Explains why a long list is loaded page by page and what the page key does
  • Separates the division of work between the paging source and the pager
  • Measures the origin of a read of an item on a page that has not yet been loaded as default
  • Shows that once the page is loaded, the origin of the same item is network or disk
Contents

In the previous lesson the screen read a single product, and the disk could hold it in one go. A product list can be thousands of rows, and asking for all of it at once is too much for both the network and memory. For this reason the list is loaded page by page: as the user scrolls down, the next page is requested. In this lesson we look at that moment, that is, at the part of the list that has not yet been loaded. The question of the lesson is the same as in the earlier lessons: where does the answer to this read come from?

// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}

Loading Page by Page

Paged loading has two roles. The paging source, given a page key, fetches the items of that page and says the key of the next page. The pager calls the paging source as the user moves through the list and passes the arriving pages to the UI as a flow. The page key can be a page number, a cursor, or the identity of the last item.

// Platform code: ProductPagingSource
class ProductPagingSource(private val api: ProductApi) : PagingSource<Int, ProductDto>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ProductDto> {
        val page = params.key ?: 0
        val items = api.products(page = page, size = params.loadSize)
        return LoadResult.Page(
            data = items,
            prevKey = if (page == 0) null else page - 1,
            nextKey = if (items.isEmpty()) null else page + 1,
        )
    }
}

load asks for the page whose key is given; if the list comes back empty, the next key is null and loading ends. This piece is platform code, and here we make no claim about output, page size, or duration. The model builds this rule of the platform; the model is not the platform itself.

// Platform code: ProductPager
val products: Flow<PagingData<ProductDto>> = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = { ProductPagingSource(api) },
).flow

The pager builds the paging source together with a configuration and gives the result as a flow; the UI collects this flow. This piece is also platform code, and we make no claim about output. The model builds this rule of the platform; the model is not the platform itself.

In the model, a page is a map of consecutive keys taken from the source. Given page n and size s, the items between n*s+1 and (n+1)*s are loaded. An item that is not in the source does not enter the map.

// Pages.kt — takes the items of a page from the source and turns them into a map
fun loadPage(source: Tier, page: Int, size: Int): Map<String, String> =
    ((page * size + 1)..((page + 1) * size)).mapNotNull { n ->
        source.lookup("product.$n")?.let { "product.$n" to it }
    }.toMap()

The screen reads sequential items. The fallback value of each item is the same placeholder.

// List.kt — the first n items the list screen reads
fun readList(chain: List<Tier>, count: Int): List<Read> =
    (1..count).map { n -> Read("item$n", "product.$n", chain, "loading") }

An Unloaded Page Is Default

The server has nine products and the page size is three. Only the first page was loaded from the network; the screen draws a list of six rows.

// Main.kt — an item on an unloaded page is read from the placeholder
fun main() {
    val names = listOf("Pencil", "Notebook", "Eraser", "Ruler", "Scissors", "Tape", "Stapler", "Candle", "Clip")
    val server = Tier("network", names.withIndex().associate { (i, name) -> "product.${i + 1}" to name })
    val loaded = Tier("network", loadPage(server, 0, 3))
    val reads = readList(listOf(loaded), 6)
    reads.forEach { it.show() }
    countOrigins(reads)
}
item1: key=product.1 | value=Pencil -> network
item2: key=product.2 | value=Notebook -> network
item3: key=product.3 | value=Eraser -> network
item4: key=product.4 | value=loading -> default
item5: key=product.5 | value=loading -> default
item6: key=product.6 | value=loading -> default
memory: 0
disk: 0
network: 3
default: 3

The three items on the loaded page came from the network, and the three items on the page that was not loaded came from the placeholder. This lesson’s origin is default: even if product.4 exists on the server, no tier carries an item whose page has not been loaded. The rule looks not at whether the item exists on the server but at the content of the chain.

DL1. The model counts a page as loaded in one step and entirely: the three items of a page either all arrive together or do not arrive at all. A page returning an error while loading, being retried, an insertion in the middle of the list, and how placeholders are drawn in the UI are absent from the model.

As the User Scrolls

The user scrolled down the list and the second page was also loaded. The network tier in the chain now carries the items of two pages.

// Main.kt — when the second page is loaded, the placeholders become network
fun main() {
    val names = listOf("Pencil", "Notebook", "Eraser", "Ruler", "Scissors", "Tape", "Stapler", "Candle", "Clip")
    val server = Tier("network", names.withIndex().associate { (i, name) -> "product.${i + 1}" to name })
    val loaded = Tier("network", loadPage(server, 0, 3) + loadPage(server, 1, 3))
    val reads = readList(listOf(loaded), 8)
    reads.forEach { it.show() }
    countOrigins(reads)
}
item1: key=product.1 | value=Pencil -> network
item2: key=product.2 | value=Notebook -> network
item3: key=product.3 | value=Eraser -> network
item4: key=product.4 | value=Ruler -> network
item5: key=product.5 | value=Scissors -> network
item6: key=product.6 | value=Tape -> network
item7: key=product.7 | value=loading -> default
item8: key=product.8 | value=loading -> default
memory: 0
disk: 0
network: 6
default: 2

The fourth, fifth, and sixth items now come from the network. The page of the seventh and eighth items has still not been loaded and remains as a placeholder. As pages are loaded, the default count falls and the network count rises.

The Bounding Measurement

Two tiers can carry the same item. The first page was written to disk from the previous session, and as the user scrolls both pages are loaded from the network. Which tier comes first determines the origin of the same item.

// Main.kt — if the same item is on both disk and network, the order in the chain determines the origin
fun main() {
    val names = listOf("Pencil", "Notebook", "Eraser", "Ruler", "Scissors", "Tape", "Stapler", "Candle", "Clip")
    val server = Tier("network", names.withIndex().associate { (i, name) -> "product.${i + 1}" to name })
    val cached = Tier("disk", loadPage(server, 0, 3))
    val fetched = Tier("network", loadPage(server, 0, 3) + loadPage(server, 1, 3))
    val chains = listOf("disk-first" to listOf(cached, fetched), "network-first" to listOf(fetched, cached))
    for ((name, chain) in chains) {
        println("chain: $name")
        val reads = readList(chain, 6)
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
chain: disk-first
item1: key=product.1 | value=Pencil -> disk
item2: key=product.2 | value=Notebook -> disk
item3: key=product.3 | value=Eraser -> disk
item4: key=product.4 | value=Ruler -> network
item5: key=product.5 | value=Scissors -> network
item6: key=product.6 | value=Tape -> network
memory: 0
disk: 3
network: 3
default: 0
chain: network-first
item1: key=product.1 | value=Pencil -> network
item2: key=product.2 | value=Notebook -> network
item3: key=product.3 | value=Eraser -> network
item4: key=product.4 | value=Ruler -> network
item5: key=product.5 | value=Scissors -> network
item6: key=product.6 | value=Tape -> network
memory: 0
disk: 0
network: 6
default: 0

The values are the same in both chains; only the origin changes. When disk comes first, the three items of the first page are read from disk and the three items of the second page from the network. When the network comes first, six of the six items are from the network. After the page is loaded, the origin of the same item depends on which tier the page was loaded from and on the order of the chain.

What the measurement does not say is which of the two chains is correct, and whether the one on disk is older than the one on the network. The rule sees the order, not the freshness.

DL2. The model assumes that the two tiers use the same page boundaries. The page sizes on the network and on disk being different, the paging source combining a remote and a local source, and a refresh loading the list from the start are absent from the model.

Summary

  • This lesson’s origin: default. No tier carries an item on a page that has not yet been loaded, and the screen shows the placeholder.
  • The paging source fetches a page with a page key; the pager calls the source as the user scrolls and passes the pages on as a flow.
  • With only the first page loaded, a list of six rows gave network 3, default 3.
  • When the second page is loaded, the fourth through sixth items become network: in eight rows, network 6, default 2.
  • If the same item is on both disk and network, the order of the chain determines its origin: if disk comes first, disk 3, network 3; if the network comes first, network 6.

Next Step

In this lesson the pages were loaded as the user scrolled, while the application was open. Some data, however, must be refreshed even when the user is not looking at the screen: at a moment when the network is available, during charging, or while the application is closed. The next lesson, Background Work, measures under which constraint these jobs run and which origin a job that writes to disk brings the read to when it runs.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close