H列表进阶

业务里最长的一页往往是列表。先选 Lazy,再补 key、下拉刷新(含自定义动画)、空态壳。Paging 3 按「从没写过」的路径拆成能跑通的最小闭环:PagingSource → Pager → 列表 → loadState,最后才提 Room 缓存。

先按场景选做法

常用业务页几乎天天写 偶尔对上场景再用
列表长什么样用这个别用频率
设置页 8 行、订单详情块Column + verticalScrollLazy / Paging常用
通讯录、本地已有完整 ListLazyColumn + items(key)Paging常用
资讯 / 商品 / 消息,接口按页返回Paging 3 + Lazy自己 page++ 拼 List常用
下拉重新拉第一页PullToRefreshBox顶栏按钮当唯一刷新常用
自定义下拉动画(品牌指示器)indicator,读 PullToRefreshState改 Lazy 的 contentPadding 冒充偶尔
网络 + Room 离线先看缓存RemoteMediator新手第一页就上偶尔
选型口诀:一屏内能画完 → Column会很长但内存已有全量 → Lazy接口分页 / 无限滑 → Paging。Paging 不是「更高级的 Lazy」,是「谁来要下一页」的库。

为何用 LazyColumn 常用

概念

Lazy 只组合可见附近条目。上千项用 Column + forEach 会一次全建,卡顿甚至 OOM。XML 里这就是 RecyclerViewScrollView + LinearLayout

和 XML 对照

XMLCompose
RecyclerView + AdapterLazyColumn + items { }
LinearLayoutManager默认纵向 LazyColumn
GridLayoutManagerLazyVerticalGrid
adapter.notify… / DiffUtil数据是 State / PagingData,列表自己重组
viewTypecontentType
LayoutManager.findFirstVisible…LazyListState

怎么用

@Composable
fun ContactList(contacts: List<Contact>) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(contacts, key = { it.id }, contentType = { "contact" }) { c ->
            ContactRow(c)
        }
    }
}

contentType 告诉 Compose「这类行可复用同一套测量」,多模板列表(广告卡 + 文本卡)值得写,纯一种行可省略。

易错点

  • Lazy 里再套可滚的 Column(verticalScroll) / 另一个 LazyColumn → 高度测不准、滑动抢手。横向条用 LazyRow 嵌在纵向 Lazy 的 item { } 里可以。
  • 父级已经 verticalScroll,子级又 Lazy → 先去掉外层滚。
▶ 联系人列表

items + key 常用

概念

key 给条目稳定身份,删改排序时减少错乱重组与状态串位。≈ Adapter 稳定 getItemId + DiffUtil 的 areItemsTheSame

怎么用

items(users, key = { it.id }) { user ->
    UserRow(user)
}

stickyHeader(key = "letter-$letter") { LetterBar(letter) }  // 通讯录字母头,偶尔

易错点

用 index 当 key → 删除中间项后,下面行「顶上来」却还带着旧行的开关/输入框状态。条目进出动画也依赖 key,见动画 · animateItem

▶ 删一项不错乱

下拉刷新 PullToRefreshBox 常用

概念

包在可滚动内容外面:用户下拉 → 你设 isRefreshing = true 并拉数 → 结束改回 false。指示器是否转圈完全由这个布尔驱动,不是组件自己猜。

和 XML 对照

SwipeRefreshLayoutisRefreshing / setOnRefreshListener。Accompanist 的 SwipeRefresh 已退役,新代码用 Material3 的 Box。

怎么用

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedWithRefresh(vm: FeedViewModel = viewModel()) {
    val refreshing by vm.refreshing.collectAsStateWithLifecycle()
    PullToRefreshBox(
        isRefreshing = refreshing,
        onRefresh = { vm.refresh() }
    ) {
        LazyColumn(Modifier.fillMaxSize()) { /* items… */ }
    }
}

VM 里:refresh() 开头 _refreshing.value = truefinally 里改回 false。和 Paging 对接时用 items.refresh(),见下面 Paging UI

易错点

  • Box 塞进 Lazy 的第一条 item → 嵌套滚动乱、指示器跟着条目走。必须包住整个列表。
  • 只改 UI 布尔、没真正发请求 → 转圈永不停,或闪一下就停但数据没变。
  • 列表没 fillMaxSize()、内容不足一屏时,有的版本下拉手势抢不到,给列表撑满。
▶ 下拉重新加载第一页

自定义下拉刷新动画 偶尔

概念

默认指示器是 PullToRefreshDefaults.Indicator(圆圈 + 容器)。品牌动画不要重写手势,只换 indicator 槽。手势进度在 rememberPullToRefreshState()distanceFraction 从 0 拉到 1(越过阈值可 > 1)。

XML 对照:SwipeRefreshLayout.setProgressViewOffset / setColorSchemeColors,或干脆藏系统圈、自己在 Header 里跟 getProgress() 画。Compose 把进度收成 State,用 animate* / InfiniteTransition 去画。

怎么用 · 先改颜色和阈值

val state = rememberPullToRefreshState()
PullToRefreshBox(
    isRefreshing = refreshing,
    onRefresh = onRefresh,
    state = state,
    indicator = {
        PullToRefreshDefaults.Indicator(
            modifier = Modifier.align(Alignment.TopCenter),
            isRefreshing = refreshing,
            state = state,
            color = MaterialTheme.colorScheme.primary,
            containerColor = MaterialTheme.colorScheme.surfaceContainer
        )
    }
) { LazyColumn(Modifier.fillMaxSize()) { /* … */ } }

怎么用 · 自己画(随下拉缩放旋转,刷新时连转)

@Composable
fun BoxScope.BrandRefreshIndicator(
    isRefreshing: Boolean,
    state: PullToRefreshState
) {
    val inf = rememberInfiniteTransition(label = "ptr")
    val spin by inf.animateFloat(
        0f, 360f,
        infiniteRepeatable(tween(900, easing = LinearEasing)),
        label = "spin"
    )
    val fraction = state.distanceFraction.coerceIn(0f, 1f)
    val rot = if (isRefreshing) spin else fraction * 180f
    val scale = 0.55f + 0.45f * fraction
    Icon(
        imageVector = Icons.Default.Refresh,
        contentDescription = "刷新",
        tint = MaterialTheme.colorScheme.primary,
        modifier = Modifier
            .align(Alignment.TopCenter)
            .padding(top = 8.dp)
            .size(28.dp)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                rotationZ = rot
                alpha = 0.35f + 0.65f * fraction
            }
    )
}

PullToRefreshBox(
    isRefreshing = refreshing,
    onRefresh = onRefresh,
    state = state,
    indicator = { BrandRefreshIndicator(refreshing, state) }
) { /* LazyColumn */ }

还想画弧线、点阵、插画:用 Canvas 读同一个 fraction。Lottie 也能喂 progress,但那是第三方,本页不展开。

更底层

布局不能用 Box 时,用 Modifier.pullToRefresh(isRefreshing, state, onRefresh) 挂在自己的容器上,指示器仍按 state.distanceFraction 绝对定位。手势阈值、嵌套滚动由 Modifier 处理,不要自己 pointerInput 再写一套。

易错点

  • 自定义 indicator 里忽略 isRefreshing:松手后 fraction 回 0,动画直接消失,用户以为没刷。
  • 刷新中仍用 Infinite 转圈,结束却不读 isRefreshing → 松手后还转。用 if (isRefreshing) spin else fraction * …
  • 在 indicator 里发请求或改 VM —— 槽位只负责画。
▶ 下拉放大、刷新连转的刷新图标

进度只来自 state.distanceFractionisRefreshing,不要用列表的 firstVisibleItemScrollOffset 冒充下拉距离。

Paging 3 是什么(给没用过的人) 常用

概念

接口按页返回(?page=3&size=20)。你若自己在 VM 里 list = list + newPage,就要手写:滑近底部再请求、失败重试、下拉作废旧数据、搜索词变了丢掉旧页。Paging 把这些收成一条 Flow<PagingData<T>>

和 XML 对照

XML:PagingDataAdapterRecyclerViewloadStateFlow 画页脚。Compose:collectAsLazyPagingItems() 接 Lazy。中间的 PagingSource / Pager / cachedIn 完全一样,换 UI 层即可。

什么时候先别用

  • 后台一次返回全部、总共几十条 → 普通 List + Lazy。
  • 还没接口、先写死 3 条假数据 → 普通 List。等接口分页了再换 Source。

新手第一页不要RemoteMediator(那是 Room 当唯一数据源)。先走「只打网络」的 PagingSource,能刷能翻就毕业。

五步最小闭环(照抄能跑)

  1. Gradle:androidx.paging:paging-runtime + androidx.paging:paging-compose(跟 BOM,别手写乱版本)。
  2. 写一个 PagingSource<页码类型, 行类型>load 里打接口,成功 LoadResult.Page,失败 LoadResult.Error
  3. VM:Pager(PagingConfig(pageSize = 20)) { YourSource(...) }.flow.cachedIn(viewModelScope)
  4. UI:val items = vm.pages.collectAsLazyPagingItems()LazyColumnitems.itemCount
  5. items.loadState 画首次加载 / 底部材加载 / 失败重试;下拉调 items.refresh()

下面三节把 2~5 展开。页码用 Int 最常见(page=1,2,3);有的接口用 nextCursor: String? 当 Key,把 PagingSource<Int, T> 改成 <String, T> 即可。

PagingSource:你真正要写的类

概念

库问你:「从 key=K 开始,给我最多 loadSize 条」。你只负责这一问。不要在 Source 里存列表、不要自己记住「当前页」——当前页就是这次的 params.key

怎么用

class NewsPagingSource(
    private val api: NewsApi,
    private val query: String
) : PagingSource<Int, News>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, News> {
        val page = params.key ?: 1
        return try {
            val resp = api.list(query = query, page = page, size = params.loadSize)
            LoadResult.Page(
                data = resp.items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (resp.items.isEmpty()) null else page + 1
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: HttpException) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, News>): Int? {
        val anchor = state.anchorPosition ?: return null
        val page = state.closestPageToPosition(anchor) ?: return null
        return page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
    }
}

三句话记住字段

  • params.key:这一页从哪开始。第一次刷新是 null,你改成起始页(常见 1 或 0)。
  • prevKey / nextKey:没有上一页/下一页就 null。空数组也要 nextKey = null,否则会无限打空页。
  • getRefreshKey:下拉刷新后,尽量让用户还停在附近。照抄上面「锚点邻页 ±1」即可,不要返回 null 以外的魔法数字除非你很清楚。

易错点

  • 把 Retrofit 的失败当成空列表 Page(emptyList()) → 用户以为没有数据,应 LoadResult.Error
  • Source 里持有可变页码字段 var page = 1 → 刷新/重试会乱。页码只来自 params.key
  • 搜索词变了仍用旧 Source:要在 VM 里新建 Pager(见下一节 flatMapLatest)。

ViewModel:Pager + cachedIn

概念

Pager 根据配置创建 Source。cachedIn(viewModelScope) 让分页数据活在 VM 里:旋转屏幕不会从头拉第一页。忘记 cachedIn,每次重组都新订阅,列表会闪、重复请求。

class NewsViewModel(private val api: NewsApi) : ViewModel() {
    private val query = MutableStateFlow("")

    val pages: Flow<PagingData<News>> = query
        .flatMapLatest { q ->
            Pager(
                config = PagingConfig(
                    pageSize = 20,
                    prefetchDistance = 5,
                    enablePlaceholders = false,
                    initialLoadSize = 20
                ),
                pagingSourceFactory = { NewsPagingSource(api, q) }
            ).flow
        }
        .cachedIn(viewModelScope)

    fun onQuery(q: String) { query.value = q }
}

PagingConfigpageSize 对齐接口;prefetchDistance 离底部还有几条就预加载(默认够用);enablePlaceholders 为 true 时没加载到的行是 null,可画骨架,新手先 false

和 XML 对照

同一段 Pager.flow.cachedIn 可以给 PagingDataAdapter.submitData(lifecycle, pagingData)。迁 Compose 只换收集端。

UI:collectAsLazyPagingItems + loadState

概念

LazyPagingItems 既是列表数据,也是加载状态机。三路 loadState

哪一路何时UI 怎么做
refresh第一次进页、或 refresh()全屏转圈 / 下拉圈;失败全屏重试
append滑到底加载下一页列表脚转圈;失败脚上「点击重试」
prepend向上加载(聊天史)列表头;资讯流通常不管

LoadState.Loading / Error / NotLoadingrefresh() = 作废当前 Source 重来;retry() = 只重试失败的那一次 load。

怎么用(和下拉刷新接好)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsFeed(vm: NewsViewModel = viewModel()) {
    val items = vm.pages.collectAsLazyPagingItems()
    val refreshing = items.loadState.refresh is LoadState.Loading
        && items.itemCount > 0

    PullToRefreshBox(
        isRefreshing = refreshing,
        onRefresh = { items.refresh() }
    ) {
        when {
            items.loadState.refresh is LoadState.Loading && items.itemCount == 0 -> {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    CircularProgressIndicator()
                }
            }
            items.loadState.refresh is LoadState.Error && items.itemCount == 0 -> {
                val err = items.loadState.refresh as LoadState.Error
                Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(err.error.localizedMessage ?: "加载失败")
                    TextButton(onClick = { items.retry() }) { Text("重试") }
                }
            }
            items.loadState.refresh is LoadState.NotLoading
                && items.itemCount == 0 -> {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    Text("暂无资讯")
                }
            }
            else -> {
                LazyColumn(Modifier.fillMaxSize()) {
                    items(
                        count = items.itemCount,
                        key = items.itemKey { it.id }
                    ) { index ->
                        val news = items[index] ?: return@items
                        NewsRow(news)
                    }
                    when (val append = items.loadState.append) {
                        is LoadState.Loading -> item {
                            Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
                                CircularProgressIndicator()
                            }
                        }
                        is LoadState.Error -> item {
                            TextButton(
                                onClick = { items.retry() },
                                modifier = Modifier.fillMaxWidth()
                            ) { Text("加载失败,点我重试") }
                        }
                        else -> {}
                    }
                }
            }
        }
    }
}

首次进入:itemCount == 0refresh Loading → 全屏圈(不要同时再转下拉圈)。已经有数据再下拉:itemCount > 0 才把 isRefreshing 置 true。

易错点

  • 在 VM 再抄一份 MutableStateList 去 addAll —— 和 Paging 抢数据源,翻页会重复或丢。
  • items[i] 可能为 null(占位开启时);先 false 占位也要习惯判空。
  • items.refresh() 当「加载下一页」——下一页是滑动触发的,refresh 是整表重来。
▶ 资讯流:下拉刷新 + 滑到底加载

把本节代码和 NewsPagingSource 接上假接口(或 delay + 假数据)就能掌握 80% 业务列表。深层链接进详情仍只传 id,见导航 · 传参

Paging 常用加料

map / filter(仍在 VM)

val pages = Pager(...) { NewsPagingSource(api, q) }
    .flow
    .map { paging -> paging.map { it.toUi() } }
    .cachedIn(viewModelScope)

mapcachedIn。在 UI 里对 LazyPagingItems 再 filter 会把分页计数搞乱。

Header / 混排

Lazy 里先 item { Banner() }items(count = items.itemCount)。不要把 Banner 塞进 PagingSource 当第 0 条,除非服务端就这么下发。

RemoteMediator + Room 偶尔

要离线先看上次缓存、网络只负责写库,再用 Pager(remoteMediator = …) { db.newsDao().pagingSource() }。UI 仍然 collectAsLazyPagingItems,Source 变成 DAO 的 PagingSource。这是第二阶段,官网「Paging 与 Room」专章再看。第一页闭环不要并行学它。

空态 / 加载 / 失败 常用

概念

非 Paging 列表用密封 UiState(见状态)三壳。Paging 列表用上一节 loadState,不要两套状态各画一遍圈。

▶ 三种壳怎么叠在普通 List 上
when {
    loading -> CircularProgressIndicator()
    error != null -> TextButton(onClick = onRetry) { Text("重试") }
    items.isEmpty() -> Text("暂无数据")
    else -> LazyColumn { /* … */ }
}

LazyRow · Grid · Pager · 滚动位置 常用

概念

纵向长列表用 LazyColumn;横向滑动条用 LazyRow;商品墙用 LazyVerticalGrid;Banner / 引导页用 HorizontalPager。规则相同:key、别和可滚父布局硬嵌套。

怎么用

LazyRow { items(banners, key = { it.id }) { BannerCard(it) } }

LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) {
    items(products, key = { it.id }) { ProductCell(it) }
}

val pager = rememberPagerState(pageCount = { pages.size })
HorizontalPager(state = pager) { page -> Page(pages[page]) }

val listState = rememberLazyListState()
LazyColumn(state = listState) { /* … */ }
LaunchedEffect(query) { listState.scrollToItem(0) }  // 换搜索词滚回顶

条目动画

增删重排时给行加上 Modifier.animateItem()(需稳定 key),详见扩展·动画。Paging 列表不适合拿 animateItem 做「新页飞入」,翻页是追加不是局部 Diff。

偶尔

LazyVerticalStaggeredGrid(瀑布流)、reverseLayout = true(聊天从底部长)场景更少,API 与 Column/Grid 相近。

易错合集

  • 长列表用 Column 硬滚;短设置页却上 Paging。
  • key 用 index;Paging 忘了 itemKey { it.id }
  • PullToRefresh 包在 Lazy 内部;自定义指示器不看 isRefreshing
  • Paging 不 cachedIn;失败当空页;搜索不变 Source。
  • 自己 page++ 同时又用 Paging。
下一章:主题与品质
Android Compose 现代开发知识体系 · 主线必读 / 扩展选读。
术语表常驻;组件与属性先看分讲,再查两张总表。案例默认收起。