Skip to content

列表、滚动与自动加载更多 ​

插件里九成的页面都是「一列东西」。这一页解决四件事:懒列表怎么写、key / contentType 为什么必须给、滚到底怎么自动拉下一页、拉得太快怎么不被接口按死。不做的事也写清楚:不写「点击按钮加载更多」。分页优先用官方 Paging 3 —— 它天生就是滚到底自动 append,失败重试、到底判定、配置变更存活都不用自己写;本页这套手写方案是「接口不适配 PagingSource」时的退路,把分页状态机、失败重试、去重、阈值触发四件事自己管住。

列表项长什么样看 UI 组件库(ListItem、LoadingIndicator、PullToRefresh);这一页讲怎么排、什么时候拉、拉错了怎么办。

一、三种懒布局的基本用法 ​

Column / Row 一次把全部子项都组合出来:1000 条数据就是 1000 个节点进内存。Lazy* 只组合视区里那几条,滚出去的节点被回收。

布局轴向用在哪
LazyColumn竖向单列主列表:作品、历史、消息、设置项
LazyRow横向单行横向卡片流、标签条、横向筛选
LazyVerticalGrid竖向多列封面墙、图库、宫格
kotlin
LazyColumn(modifier = Modifier.fillMaxSize()) {
    item { Header() }                                            // 头、页脚、广告位
    items(videos, key = { it.id }) { video -> VideoRow(video) }  // items(list, key) 是主用法
    // itemsIndexed(videos) { i, v -> NumberedRow(i, v) }        // 只有真要显示序号时才用
}

items(list, key = …) 优先,itemsIndexed 只在真要显示序号(排行榜、步骤)时用 —— 带 key 的 items 让每一项有身份,增删 / 重排时状态不会跟着下标漂移。反过来,一屏内的固定 5 项别用懒布局,Column 就够,别为它白付调度成本。

kotlin
LazyVerticalGrid(columns = GridCells.Fixed(2)) { /* … */ }   // 固定两列;列数自适应用 GridCells.Adaptive(148.dp)

GridCells.Adaptive 在横屏 / 大屏上不用改代码。格子高度不齐(有的带副标题、有的带角标)时不要用 LazyVerticalGrid —— 它按行对齐会留出空洞;要么统一格子高度,要么改用 LazyVerticalStaggeredGrid。

1.1 key 必须给 —— 不给会怎样 ​

key 是「这是哪一条」的答案。框架靠它决定:数据更新后哪个节点是原来的那一条、哪个是新来的、哪个被删了。要求一句话:稳定、唯一、原始类型或 String,用后端 id,不用下标,不新建对象。

kotlin
items(videos, key = { it.id }) { video -> VideoRow(video) }        // ✅
items(videos, key = { index }) { video -> VideoRow(video) }        // ❌ 下标当 key = 声明「我按位置认人」
items(videos, key = { Video(it.id, it.title) }) { video -> … }     // ❌ 每次组合都是新实例,永远不相等
items(liveItems, key = { "live_${it.id}" }) { … }                  // ✅ 同一 id 跨数据源会撞 key 崩,加前缀
items(archivedItems, key = { "archived_${it.id}" }) { … }
不给 key 的症状根因
删掉第一条后,下面每一行的勾选态 / 展开态 / 输入框里的字整体前移一格,像「状态串位」没有身份只能按位置认,位置挪了一格状态就跟着挪
分页追加时不该重组的行重组了,该播的新增动画不播认不出「这条是新的」
直接崩:IllegalArgumentException: Key "42" was already used同一屏里出现两条相同 key:后端 id 重复,或同一 id 既有正文又有占位

另外两条:items{} 里用 indexOf() 是 O(n²),且对象被重建时 equals() 不成立 → 返回 -1 → IndexOutOfBoundsException;后端保证不了 id 唯一时,给数据类加一个不在主构造里的 dedupIndex,key 用 "${it.id}_${it.dedupIndex}" 兜底。有了稳定 key,Modifier.animateItem() 的增删动画才认得出谁是谁。

1.2 contentType 必须给 —— 不给会怎样 ​

key 回答「这是谁」,contentType 回答「这是什么形状」。框架的复用池按类型分槽,类型相同的 item 才能互相复用已经搭好的结构。单类型列表不用给;多类型列表不给,症状是:

症状根因
滚动时条目首次出现「闪一下」,长列表肉眼可见掉帧池子只有一个槽,该复用的槽正被另一种形状占着,到点了只能拆掉重搭
条目位置抖一下、高度跳一帧同槽要容纳不同高度的 item,过渡帧用的是上一个的尺寸
kotlin
sealed interface Row                    // SectionHeader / VideoItem / AdSlot
val contentType: (Row) -> String = { row -> when (row) {   // 粒度只按形状分,不按 id 分
    is SectionHeader -> "header"
    is VideoItem -> "video"
    is AdSlot -> "ad"
} }

items(rows, key = { row -> row.stableKey() }, contentType = contentType) { row ->
    RowContent(row)                     // 内部再 when(row) 分派三种行
}

按 id 分 contentType 等于没有,按内容分等于每项一个槽,都白给。

1.3 LazyListState 怎么读 ​

kotlin
val listState = rememberLazyListState()
LazyColumn(state = listState) { /* … */ }     // state 不传进去就自己造一个,你读不到任何东西
成员含义
firstVisibleItemIndex / firstVisibleItemScrollOffset第一条可见项的序号 / 它被滚掉了多少 px
canScrollBackward / canScrollForward能不能往回 / 往前滚,驱动 ScrollToTopButton 的显隐
layoutInfo.visibleItemsInfo / layoutInfo.totalItemsCount视区内的项(index / offset / size / key)/ 列表一共几项

scrollToItem(0) 直接跳,animateScrollToItem(0) 带一段动画。

别在 composable 体里直接读滚动状态

if (listState.firstVisibleItemIndex > 0) { … } 写在函数体里 = 每滚一像素重组一次。这类「随滚动显隐」的判断用 remember { derivedStateOf { listState.canScrollBackward } } 包一层,只有 true / false 真翻转才通知(原理见状态与副作用)。另外记住:空列表时 visibleItemsInfo.lastOrNull() 是 null —— 2.1 那个 totalItemsCount > 0 守卫就是为它存在的。

1.4 stickyHeader:分组吸顶 ​

kotlin
LazyColumn {
    stickyHeader(key = "h_today", contentType = "header") { DateHeader("今天") }
    items(todayItems, key = { it.id }) { MessageRow(it) }
}

只适合真有分组语义的列表(日期、字母、分类),上面的 stickyHeader 一样要 key 和 contentType,理由和 1.1 / 1.2 完全一样;它是 foundation 的实验 API,编译需要 @OptIn(ExperimentalFoundationApi::class)。只是想要一个普通标题,用 item { }。

二、★ 自动加载更多:滚到接近底部就请求下一页 ​

规定做法就是这一节。 下方是可直接抄的完整实现。

2.1 完整实现 ​

kotlin
private const val PAGE_SIZE = 20
private const val THRESHOLD = 3                    // 离底部还剩 3 项就预加载;必须小于一页条数

/** 连续失败 n 次的退避:1s → 2s → 4s → 8s */
private fun backoffMillis(streak: Int): Long = (1L shl (streak - 1).coerceIn(0, 3)) * 1000L

@Composable
fun App(onFinish: () -> Unit) {
    val scope = rememberCoroutineScope()
    val listState = rememberLazyListState()                   // ★ 1
    val items = remember { mutableStateListOf<Video>() }
    var nextPage by remember { mutableIntStateOf(1) }
    var loading by remember { mutableStateOf(false) }
    var hasMore by remember { mutableStateOf(true) }
    var footerError by remember { mutableStateOf<String?>(null) }
    var failStreak by remember { mutableIntStateOf(0) }
    val gate = remember { Mutex() }                            // 并发互斥:同一时刻只有一个分页请求在飞
    // 拉一页。reset = true 表示刷新(清空重来),false 表示追加下一页
    suspend fun load(target: Int, reset: Boolean = false) {
        if (!reset && (loading || !hasMore)) return            // ★ 入口守卫(见 2.2)
        gate.withLock {
            if (!reset && (loading || !hasMore)) return        // 拿到锁再看一眼:排队期间状态可能已变
            loading = true
            footerError = null
            try {
                val page = withContext(Dispatchers.IO) { api.fetch(target, PAGE_SIZE) }
                if (reset) items.clear()
                items.addAll(page.items.filter { n -> items.none { it.id == n.id } })   // 去重
                nextPage = target + 1
                hasMore = page.items.isNotEmpty()              // 空页 = 到底
                failStreak = 0
            } catch (e: CancellationException) { throw e      // 取消异常原样抛
            } catch (e: Exception) { failStreak += 1; footerError = e.message ?: "网络异常"
            } finally { loading = false }
        }
    }

    fun requestNext() {                                        // 滚动触发与页脚重试的唯一入口
        if (loading || !hasMore || footerError != null) return  // ★ 入口守卫
        scope.launch {
            if (failStreak > 0) delay(backoffMillis(failStreak))   // ★ 失败退避
            load(nextPage)
        }
    }

    val onRefresh: () -> Unit = { scope.launch { load(1, reset = true) } }   // 刷新 = 页码回到第一页
    val onRetryClick: () -> Unit = { footerError = null; requestNext() }    // 先清错误态,否则守卫不放行

    // ★ 2 滚到接近底部 → 自动请求下一页。这一页的核心
    LaunchedEffect(listState) {
        snapshotFlow {
            val info = listState.layoutInfo
            val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
            // ★ totalItemsCount > 0 不能省:列表还空着时 lastVisible = -1,
            //   -1 >= 0 - 3 成立 → 不守卫就会一进页面白发一次请求
            info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - THRESHOLD
        }
            .distinctUntilChanged()          // ★ 每帧布局都发值,必须去重
            .filter { it }                   // ★ 只处理「真的接近底部」
            .collect { requestNext() }       // loading / hasMore 的守卫在 requestNext 里
    }
    LaunchedEffect(Unit) { load(1, reset = true) }              // 首屏

    TopTitleScaffold(title = "作品列表", onBack = onFinish) {
        LazyColumn(state = listState) {
            items(items, key = { it.id }, contentType = { "video" }) { video ->
                ListItem(title = video.title, subtitle = video.author, onClick = { /* … */ })
            }
            item(key = "footer", contentType = "footer") {
                PagingFooter(loading, hasMore, footerError, onRetryClick)
            }
        }
    }
}

2.2 那三行守卫为什么一个字都不能省 ​

① totalItemsCount > 0 —— 首屏还没回来时列表是空的,lastVisible 兜底成 -1,而 -1 >= 0 - 3 恰好成立。少了这一句,用户一进页面就在「还没拉首屏」的状态下被判定为「已经到底」,凭空多打一次请求。

② distinctUntilChanged() + filter { it } —— snapshotFlow 追踪 layoutInfo,滚动时每一帧布局都会让它重新求值,不配 distinctUntilChanged() 就是一帧发一次;filter { it } 在另一头收口,false 表示「还没到底」,什么都不做。

③ loading / hasMore 入口守卫 —— 这个条件会在短时间内反复变回 true:一页填不满一屏时(最后一页、大屏上 20 条只占半屏),加载完成的瞬间列表仍接近底部,条件立刻再成立,不守卫会一路连拉到 hasMore = false;页脚在「加载中 / 没有更多」之间切换高度时 lastVisible 下标会抖,false → true 来回翻转(distinctUntilChanged 只去连续重复,去不掉来回翻转);用户快速下滑时,上一页还没落地就已经滚进下一个阈值区。

守卫写在函数入口而不是 LaunchedEffect 的 key 里:触发点不止一个(滚动、页脚重试、下拉刷新),每个入口各自校验一遍才不会有漏网。一次成功加载后 totalItemsCount 增加一页,条件自然回到 false,用户再滚到接近底部才重新翻转 —— 这就是自动加载天然「一次一页」的原因。

2.3 derivedStateOf 的等价写法与取舍 ​

kotlin
val shouldLoadMore by remember {
    derivedStateOf {
        val info = listState.layoutInfo
        val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
        info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - THRESHOLD
    }
}
LaunchedEffect(shouldLoadMore) {          // key 是 Boolean:只在真翻转时重启
    if (shouldLoadMore) requestNext()     // 守卫仍然在 requestNext 里,一行都不省
}
derivedStateOf + LaunchedEffect(key)snapshotFlow { } + distinctUntilChanged()
触发语义key 从 false 变 true 时重启 effect值翻转时发一个 true 下来
能不能和别的流合并不能,条件只能自己算能:combine 筛选 / 搜索词、debounce 去抖、套限流操作符

取舍:单条件、单消费者 → derivedStateOf 更短,也不用写 distinctUntilChanged;这个条件以后要和筛选、搜索词、登录态一起算,或者要加去抖 → 用 snapshotFlow,它多两行但「状态 → 事件流」的意图更直白。本页推荐后者,因为分页条件迟早会跟筛选条件合流。

derivedStateOf 不能拿来算「有几条」

它适合滚动方向、可见性这类布尔 / 轻量派生值。用它求出条数、再拿条数去 items(count),会在数据变化的同一帧读到过期数量 → IndexOutOfBoundsException。要渲染的列表就派生整个列表,然后交给 items(list, key = …)。

2.4 页脚三态:加载中 / 加载失败 / 没有更多 ​

kotlin
@Composable
private fun PagingFooter(loading: Boolean, hasMore: Boolean, error: String?, onRetry: () -> Unit) {
    Box(Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
        when {
            // ① 加载失败:必须可点,这是唯一的人工入口
            error != null -> TonalButton(text = "加载失败,点击重试", onClick = onRetry)
            // ② 加载中:组件库 LoadingIndicator,页脚用小档,不是整页那一档
            loading -> LoadingIndicator(label = "正在加载…")
            // ③ 没有更多了;还没触发时什么都不显示(别写「上拉加载更多」这种要用户配合的文案)
            !hasMore -> Text("没有更多了", style = MaterialTheme.typography.bodySmall)
        }
    }
}

自动加载失败后,它不会自己再来一次

这是整页最容易漏的一条。请求失败后条件仍停在 true,框架不会有第二次机会;没有重试入口 = 用户永远看不到下一页,而且他不知道为什么拉不动(页面没崩、没报错、就是不再长)。

所以「加载失败,点击重试」不是可选项,是自动加载的必备件。它也不是「点击加载更多按钮」(见 2.5):正常路径全自动,按钮只出现在失败恢复这一条路径上。三态之外别再堆花样:列表还在、只是下一页没拉到,就别用整页 ErrorState 把已经看过的内容盖掉(首屏为空才用 EmptyHint);三态高度也尽量一致,否则到底部时高度一跳,lastVisible 下标跟着抖。

2.5 不要做「点击按钮加载更多」 ​

自动加载(规定做法)点击加载更多(禁止)
用户操作下滑就继续每看完一页都要伸手点一次
出错后的语义页脚就地变成重试入口,位置就在他手指停的地方按钮变成「加载失败,重试」,还得重新找它
心智模型 / 代价无限流:内容取之不尽,自动 + 重试入口分页器:告诉用户「这是一批一批的数据」,多一次点击就是多一次摩擦

正常路径上不存在「加载更多」按钮。 唯一允许出现的按钮是「加载失败,点击重试」—— 它解决的是错误恢复,不是分页本身。

三、限流与退避:自动加载不等于裸奔 ​

自动加载最危险的地方在于它替用户按得比用户自己快。用户只是随手快速下滑,插件就连着十几个请求 —— 接口有频率限制时这就足以把自己打进冷却:返回 429 之后再请直接 403 / 404,冷却 60~90 秒,这期间下拉刷新也是失败,用户会以为插件坏了。必须配两样东西。

并发互斥:同一时刻只允许一个分页请求在飞。 Mutex 一个就够(2.1 里的 gate):

kotlin
val gate = remember { Mutex() }
suspend fun load(target: Int, reset: Boolean = false) {
    if (!reset && (loading || !hasMore)) return
    gate.withLock {
        if (!reset && (loading || !hasMore)) return   // 拿到锁再看一次:排队期间 hasMore 可能已变
        loading = true
        try { /* …请求、追加、去重… */ } finally { loading = false }
    }
}

失败退避:连续失败要等越来越久(1 次 → 1s,2 次 → 2s,3 次 → 4s,4 次及以上 → 8s 封顶)。点「加载失败,点击重试」也走同一条路 —— failStreak 没清,用户连点也只会按这个节奏排队,不会真的把接口打爆。

分工:distinctUntilChanged 防同一帧重复发值;THRESHOLD = 3 防「最后一条出现才请求,白等一轮」,它取 3~5 且必须小于一页条数(20 条一页 → 3~5,阈值 ≥ 一页时追加完一页条件仍成立,等于每次自动连拉两三页);loading 守卫防一个请求在飞时再发第二个;Mutex 防并发出网,是限流第一防线;delay 退避防失败后密集重试。这些手段都防不住「用户成功后自己疯狂滑动」,所以阈值和退避要一起用。

四、懒列表嵌套:会崩的写法与两种替代 ​

同轴向嵌套必崩

LazyColumn 里塞 LazyVerticalGrid(或 LazyColumn 里塞 Modifier.verticalScroll),测量阶段就会抛 IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints。根因是竖向可滚动容器给子项的高度约束是无限大,而子项自己也要竖向滚动,等于要求「在无限高的空间里决定我多高」。异轴向(LazyRow 放进 LazyColumn 的 item)是允许的。

替代一:整页就是网格 → 直接把 LazyVerticalGrid 当根,外面不要再套 LazyColumn。

kotlin
// ❌ 崩:两个竖向滚动容器叠在一起
LazyColumn { item { LazyVerticalGrid(columns = GridCells.Fixed(2)) { items(list) { … } } } }
// ✅ 整页网格:网格自己就是那个滚动容器
LazyVerticalGrid(columns = GridCells.Fixed(2)) { items(list, key = { it.id }) { GridCell(it) } }

替代二:页面上还有别的内容、只有一块是网格 → 用非懒 Column,按 N 列 chunked 成 Row。 外层仍然只有 LazyColumn 一个滚动容器。

kotlin
LazyColumn(state = listState) {
    items(
        items = list.chunked(2),                                  // 每 2 个一组变成一行
        key = { row -> "r_" + row.joinToString("_") { it.id } },
        contentType = { "grid_row" },
    ) { row ->
        Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            row.forEach { cell -> GridCell(cell, Modifier.weight(1f)) }
            if (row.size == 1) Spacer(Modifier.weight(1f))        // 补占位,最后一格不被拉宽
        }
    }
}

代价要认:chunk 出来的每一行都是普通 item,一行里的 N 个格子会一起组合 —— 但行数很少(一页 20 条只有 10 行),和懒网格的性能差距可以忽略。真懒网格只该用在「整页都是网格」时,也就是替代一。另外两条:横向嵌套必须给高度(LazyRow 放进 LazyColumn 的 item 里时外层不知道它该多高,要自己给 Modifier.height(...),否则会跟竖滑抢手势);整页状态视图不要塞进 item { }(LoadingState / ErrorState / EmptyHint 自带 fillMaxSize(),按「整页」算尺寸,放进无限高的 item 里尺寸算不对、也拿不到居中效果,它们该出现在 LazyColumn 外面)。

五、长列表性能:三件事 ​

固定 item 高度。 高度每帧都在变(换行数不定、图片按比例撑开、副标题时有时无),滚动时布局要不断重排,列表还会自己往上跳。做法:封面宽高比写死(aspectRatio(16f / 9f) 或固定 height),图片用固定尺寸容器 + ContentScale.Crop,加载完成不要顶高行;标题 / 副标题行数限制住(maxLines + 省略号),组件库 ListItem 的单行标题 / 两行副标题就是按这个定的;页脚三态等高。

别在 item 里做重活。 过滤、排序、格式化、new 对象都在上一层算好;非要在 item 里推导就按身份记住。这条最致命:在 item 里读整条列表的滚动状态,列表一滚每一项都重组 —— 「列表一滑动就卡」的经典根因。

kotlin
items(prepared, key = { it.id }) { row -> VideoRow(row) }      // ✅ 数据在上一层备好
items(videos, key = { it.id }) { video ->
    val subtitle = remember(video.id) { buildSubtitle(video) } // ✅ 只有这一条变了才重算
    VideoRow(video, subtitle)
}
// ❌ 列表一滚,每一项都重组一次
items(videos) { video -> VideoRow(video, highlight = listState.firstVisibleItemIndex == 0) }

用 contentType 复用。 回到 1.2:多类型列表不给 contentType,池子只有一个槽,每碰到一种新形状就得拆了重搭。key 用原始类型(Long / String),不要在 key 里 new 对象 —— key 每次都不相等时,框架会把整列表当成全新的。

六、下拉刷新与自动加载怎么共存 ​

PullToRefresh 的 content 放 LazyColumn。刷新时必须把分页状态整套重置,只重新拉第一页是不够的:

刷新时必须重置不重置的后果
items 清空新第一页追加在旧数据后面,列表出现重复内容
nextPage 回到 1下一次滚动去拉「第 5 页」,中间两页永远看不到
hasMore = true / footerError = null / failStreak = 0上一轮到底后被守卫拦住再也拉不动;页脚永远停在「加载失败」,或被上一次的退避卡着
kotlin
var refreshing by remember { mutableStateOf(false) }
val onRefresh: () -> Unit = {
    refreshing = true                                         // 必须自己置 true
    scope.launch {
        load(1, reset = true)                                 // 清空 + 页码归 1 + 状态复位
        refreshing = false                                    // 没有超时兜底,必须自己置回 false
    }
}
PullToRefresh(isRefreshing = refreshing, onRefresh = onRefresh) {
    LazyColumn(state = listState) { /* … */ }
}

三个必守细节:onRefresh 里要自己把 isRefreshing 置 true(不置的话指示器落位后又立刻收到「已刷完」,看起来像压根没刷);刷新和分页共用同一把 Mutex(load 内部那一把),否则刷新正清空时一个在飞的「第 3 页」落地,会把旧数据追加回新列表,顺序完全错乱;刷新中不要放行分页请求,loading 守卫会拦住,但别在 onRefresh 里顺手滚列表,要滚等刷新完成后再 scrollToItem(0)。

换页签 / 换筛选词和刷新是同一件事(PillTabsRow 切分类、搜索词变化):换分类 = 换数据源 = 页码回 1 + 清空 + hasMore = true。还有一个跨分类的坑:切换后前一个分类的请求可能还在飞,落地后会追加到新分类的列表里 —— 用一个自增的 generation 丢弃过期结果:

kotlin
var generation by remember { mutableIntStateOf(0) }   // 每次切分类 +1
suspend fun load(target: Int, reset: Boolean = false) {
    val gen = generation
    /* …请求… */
    if (gen != generation) return                     // 期间切过分类 → 这次结果作废
    items.addAll(fresh)
}

七、HorizontalPager:一页一屏的横向翻页 ​

HorizontalPager 不是「列表」,是「一页一屏」,所以它有明确的页码概念,和 LazyRow 的分工很清楚:

LazyRowHorizontalPager
语义 / 停靠连续的横向流,滚到哪算哪一页一屏,有 currentPage,每次停在一页边界上
典型用途横向卡片、标签条、横向筛选引导页、横幅轮播、一屏一卡
kotlin
val pagerState = rememberPagerState(pageCount = { banners.size })   // 用 lambda,数量变了跟着变
HorizontalPager(state = pagerState, modifier = Modifier.height(180.dp)) { page ->
    BannerCard(banners[page])
}

val current = pagerState.currentPage        // 拿它画指示点;pagerState.animateScrollToPage(0) 跳页

轮播的实现选择:

  • 页数少、不自动播(2~5 张横幅) → 就用上面的写法 + 指示点。这是默认选择,别为了「循环」增加复杂度。
  • 要自动播 → 定时翻页,并且用户一上手就停手,否则他正看着就被推走:
kotlin
LaunchedEffect(pagerState, banners.size) {
    if (banners.size <= 1) return@LaunchedEffect
    snapshotFlow { pagerState.isScrollInProgress }.distinctUntilChanged().collect { dragging ->
        if (dragging) return@collect                          // 用户上手 → 这一轮不自动翻
        while (true) {                                        // 用户离开后接着自动翻页
            delay(3_000)
            pagerState.animateScrollToPage((pagerState.currentPage + 1) % banners.size)
        }
    }
}
  • 页数很多、用户要一直往下滑 → 那不是轮播,是横向列表:用 LazyRow(Pager 每页要给足一屏宽,几十页堆在一起既没有页码意义也没有性能好处);也别用「复制三份数据假装无限循环」,要循环就用上面那种取模定位,复制会让页码、指示点、currentPage 全部失去意义。横幅放进 LazyColumn 的 item 里时,给 HorizontalPager 一个固定高度(异轴向嵌套合法,但外层不知道它该多高)。

交付前自查 ​

  • [ ] 长列表用 LazyColumn / LazyRow / LazyVerticalGrid,没有用 Column 硬铺一屏以上的数据
  • [ ] 每一项都有稳定唯一的 key(后端 id,不是下标,不是新建对象);多类型列表逐项给 contentType,按形状分
  • [ ] 滚到接近底部自动请求下一页,totalItemsCount > 0 守卫在,distinctUntilChanged() 在
  • [ ] load 入口有 loading / hasMore 守卫,分页请求走 Mutex,失败后有递增退避
  • [ ] 页脚三态齐全:加载中用 LoadingIndicator、失败是可点的「加载失败,点击重试」、到底显示「没有更多了」
  • [ ] 正常路径上没有「点击加载更多」按钮,下拉刷新会把页码重置回第一页并清空列表
  • [ ] 没有 LazyColumn 里嵌 LazyVerticalGrid 或 verticalScroll 的写法,item 里没有重活