【Compose Multiplatform跨端声明式UI实用技术】第7课:列表与惰性布局

第6课完成了组件与设计系统的学习。本课进入 CMP 列表与惰性布局的核心:LazyColumn、LazyRow、LazyVerticalGrid 的基本用法,key() 的正确使用与列表性能优化,列表项的重组控制与 @Stable/@Immutable 标注,列表状态管理与分页加载模式,以及 iOS 端惰性布局的滚动性能改进。本课内容基于 CMP 1.12.1。 文末附有15道配套习题与本课知识点总结。

目录

  • 一、惰性布局的核心概念
    • [1.1 惰性布局与普通布局的区别](#1.1 惰性布局与普通布局的区别)
    • [1.2 从 RecyclerView 迁移的心智模型](#1.2 从 RecyclerView 迁移的心智模型)
  • [二、LazyColumn 与 LazyRow 基础](#二、LazyColumn 与 LazyRow 基础)
    • [2.1 基本用法](#2.1 基本用法)
    • [2.2 核心参数详解](#2.2 核心参数详解)
    • [2.3 反向布局与聊天界面](#2.3 反向布局与聊天界面)
    • [2.4 粘性头部](#2.4 粘性头部)
  • [三、LazyVerticalGrid 与 LazyHorizontalGrid](#三、LazyVerticalGrid 与 LazyHorizontalGrid)
    • [3.1 基本用法](#3.1 基本用法)
    • [3.2 GridCells 的三种模式](#3.2 GridCells 的三种模式)
    • [3.3 跨列条目](#3.3 跨列条目)
  • [四、key() 的正确使用与性能优化](#四、key() 的正确使用与性能优化)
    • [4.1 没有 key 时会发生什么](#4.1 没有 key 时会发生什么)
    • [4.2 正确配置 key](#4.2 正确配置 key)
    • [4.3 key 的类型限制](#4.3 key 的类型限制)
    • [4.4 在列表外排序数据](#4.4 在列表外排序数据)
    • [4.5 contentType 与条目类型区分](#4.5 contentType 与条目类型区分)
  • 五、列表项的重组控制与稳定性
    • [5.1 稳定类型与非稳定类型](#5.1 稳定类型与非稳定类型)
    • [5.2 List 为什么是不稳定类型](#5.2 List 为什么是不稳定类型)
    • [5.3 三种修复方案](#5.3 三种修复方案)
    • [5.4 用 Layout Inspector 验证稳定性](#5.4 用 Layout Inspector 验证稳定性)
    • [5.5 减少 item 内部重组](#5.5 减少 item 内部重组)
  • 六、列表状态管理
    • [6.1 rememberLazyListState](#6.1 rememberLazyListState)
    • [6.2 滚动位置恢复](#6.2 滚动位置恢复)
    • [6.3 监听滚动位置:derivedStateOf vs snapshotFlow](#6.3 监听滚动位置:derivedStateOf vs snapshotFlow)
  • 七、分页加载与无限滚动
    • [7.1 基于 lazyPagingItems 的分页](#7.1 基于 lazyPagingItems 的分页)
    • [7.2 手动实现无限滚动](#7.2 手动实现无限滚动)
  • 八、条目动画
    • [8.1 animateItem](#8.1 animateItem)
    • [8.2 跨平台注意事项](#8.2 跨平台注意事项)
  • 九、缓存窗口与预取机制
    • [9.1 LazyLayoutCacheWindow](#9.1 LazyLayoutCacheWindow)
    • [9.2 iOS 端预取的已知问题](#9.2 iOS 端预取的已知问题)
  • [十、CMP 平台差异与性能改进](#十、CMP 平台差异与性能改进)
    • [10.1 iOS 惰性布局滚动性能改进](#10.1 iOS 惰性布局滚动性能改进)
    • [10.2 Android 与 iOS 的滚动差异](#10.2 Android 与 iOS 的滚动差异)
    • [10.3 LazyGridImageView 基准测试](#10.3 LazyGridImageView 基准测试)
    • [10.4 性能分析工具链](#10.4 性能分析工具链)
    • [10.5 commonMain 约束](#10.5 commonMain 约束)
  • 配套习题(15题)
  • 本课知识点总结

一、惰性布局的核心概念

1.1 惰性布局与普通布局的区别

第5课介绍的 Column、Row、Box 是立即布局------无论子元素是否可见,都会测量、布局并绘制所有子元素。当列表包含数百甚至数千个条目时,这会带来严重的内存和性能问题。

惰性布局(Lazy Layout) 只组合、测量和绘制当前可见的条目,以及一小部分预取缓冲区(约 1-2 屏的过度滚动缓冲),并在滚动时复用离开视口的条目。对于 4 万个条目的列表,渲染成本为 O(可见条目数),与列表总长度无关------通常同时可见 10-20 行,渲染成本是恒定的。这与 RecyclerView 的 View 复用池机制类似,但在 Compose 中,开发者无需手动管理 Adapter 和 ViewHolder------只需声明每个条目如何渲染,Compose 自动处理复用。

Compose 提供三类惰性布局:

布局 效果 对应 RecyclerView 的 LayoutManager
LazyColumn / LazyRow 垂直/水平滚动列表 LinearLayoutManager
LazyVerticalGrid / LazyHorizontalGrid 垂直/水平滚动网格 GridLayoutManager
LazyVerticalStaggeredGrid / LazyHorizontalStaggeredGrid 交错网格 StaggeredGridLayoutManager

所有惰性布局组件共享一套底层基础设施(LazyList、LazyGrid、LazyStaggeredGrid),用于高效组合和布局大规模条目集合。

1.2 从 RecyclerView 迁移的心智模型

对于有 Android 原生开发经验的读者,从 RecyclerView 迁移到惰性布局需要理解一个关键差异:Compose 中没有"创建条目视图"和"绑定数据到视图"的分离 ------这两个概念在 Composable 函数中被合并了。RecyclerView 的 onCreateViewHolder() 和 onBindViewHolder() 的逻辑,在 Compose 中由同一个 Composable 函数完成:它接收数据作为参数,并直接发射 UI。

LazyColumn 的 content 参数是 LazyListScope.() -> Unit 类型,在这个作用域中可以使用 item() 添加单个条目,或使用 items() 批量添加条目。

二、LazyColumn 与 LazyRow 基础

2.1 基本用法

LazyColumn 产生垂直滚动列表,LazyRow 产生水平滚动列表。二者的 API 几乎完全对称,只是轴方向不同。

kotlin 复制代码
LazyColumn(
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(
        items = messages,
        key = { it.id }
    ) { message ->
        MessageRow(message)
    }
}

LazyColumn 的 content 参数是 LazyListScope.() -> Unit 类型,在这个作用域中可以使用 item() 添加单个条目,或使用 items() 批量添加条目。

2.2 核心参数详解

参数 类型 说明
state LazyListState 列表滚动状态,通过 rememberLazyListState() 创建
contentPadding PaddingValues 内容内边距,在内容被裁剪后添加,无法通过 modifier 实现
reverseLayout Boolean 反转滚动和布局方向,常用于聊天界面
verticalArrangement Arrangement.Vertical 条目之间的间距和分布方式
horizontalAlignment Alignment.Horizontal 条目在水平方向的对齐方式
flingBehavior FlingBehavior 惯性滚动行为
userScrollEnabled Boolean 是否允许用户滚动
overscrollEffect OverscrollEffect 过度滚动效果
cacheWindow LazyLayoutCacheWindow 预取缓存窗口,控制提前组合的条目范围

contentPadding 与 modifier.padding 的区别 :contentPadding 在内容被裁剪后添加内边距,因此滚动时内容会延伸到内边距区域(如状态栏下方),而 modifier.padding 裁剪整个列表区域。文档明确指出,contentPadding 是"内容被裁剪后的填充,无法通过 modifier 参数实现"。在需要让内容滚动到系统栏下方的场景中(如 Edge-to-Edge 设计),应使用 contentPadding。

2.3 反向布局与聊天界面

reverseLayout = true 将滚动方向和布局方向都反转,列表从底部开始排列。这在聊天界面中非常实用------最新的消息出现在底部,列表自动定位到最新消息:

kotlin 复制代码
LazyColumn(
    reverseLayout = true,
    state = listState
) {
    items(messages.reversed(), key = { it.id }) { message ->
        MessageRow(message)
    }
}

2.4 粘性头部

LazyColumn 通过 stickyHeader 支持粘性头部------当列表滚动时,头部会固定在列表顶部直到被下一个头部推走:

kotlin 复制代码
// 理想情况下应在 ViewModel 中完成分组
val grouped = contacts.groupBy { it.firstName[0] }

LazyColumn {
    grouped.forEach { (initial, contactsForInitial) ->
        stickyHeader {
            CharacterHeader(initial)
        }
        items(contactsForInitial, key = { it.id }) { contact ->
            ContactListItem(contact)
        }
    }
}

stickyHeader 目前是实验性 API,需要添加 @OptIn(ExperimentalFoundationApi::class) 标注。在 CMP 中,也有社区库(如 lazy-sticky-headers)提供更高级的粘性头部支持,包括 CMP 原生的解决方案。

下面是 LazyColumn 的核心参数与使用场景总览:
#mermaid-svg-SYVQZrEcfWav2TmN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SYVQZrEcfWav2TmN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SYVQZrEcfWav2TmN .error-icon{fill:#552222;}#mermaid-svg-SYVQZrEcfWav2TmN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SYVQZrEcfWav2TmN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SYVQZrEcfWav2TmN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SYVQZrEcfWav2TmN .marker.cross{stroke:#333333;}#mermaid-svg-SYVQZrEcfWav2TmN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SYVQZrEcfWav2TmN p{margin:0;}#mermaid-svg-SYVQZrEcfWav2TmN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SYVQZrEcfWav2TmN .cluster-label text{fill:#333;}#mermaid-svg-SYVQZrEcfWav2TmN .cluster-label span{color:#333;}#mermaid-svg-SYVQZrEcfWav2TmN .cluster-label span p{background-color:transparent;}#mermaid-svg-SYVQZrEcfWav2TmN .label text,#mermaid-svg-SYVQZrEcfWav2TmN span{fill:#333;color:#333;}#mermaid-svg-SYVQZrEcfWav2TmN .node rect,#mermaid-svg-SYVQZrEcfWav2TmN .node circle,#mermaid-svg-SYVQZrEcfWav2TmN .node ellipse,#mermaid-svg-SYVQZrEcfWav2TmN .node polygon,#mermaid-svg-SYVQZrEcfWav2TmN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SYVQZrEcfWav2TmN .rough-node .label text,#mermaid-svg-SYVQZrEcfWav2TmN .node .label text,#mermaid-svg-SYVQZrEcfWav2TmN .image-shape .label,#mermaid-svg-SYVQZrEcfWav2TmN .icon-shape .label{text-anchor:middle;}#mermaid-svg-SYVQZrEcfWav2TmN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SYVQZrEcfWav2TmN .rough-node .label,#mermaid-svg-SYVQZrEcfWav2TmN .node .label,#mermaid-svg-SYVQZrEcfWav2TmN .image-shape .label,#mermaid-svg-SYVQZrEcfWav2TmN .icon-shape .label{text-align:center;}#mermaid-svg-SYVQZrEcfWav2TmN .node.clickable{cursor:pointer;}#mermaid-svg-SYVQZrEcfWav2TmN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SYVQZrEcfWav2TmN .arrowheadPath{fill:#333333;}#mermaid-svg-SYVQZrEcfWav2TmN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SYVQZrEcfWav2TmN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SYVQZrEcfWav2TmN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SYVQZrEcfWav2TmN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SYVQZrEcfWav2TmN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SYVQZrEcfWav2TmN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SYVQZrEcfWav2TmN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SYVQZrEcfWav2TmN .cluster text{fill:#333;}#mermaid-svg-SYVQZrEcfWav2TmN .cluster span{color:#333;}#mermaid-svg-SYVQZrEcfWav2TmN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SYVQZrEcfWav2TmN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SYVQZrEcfWav2TmN rect.text{fill:none;stroke-width:0;}#mermaid-svg-SYVQZrEcfWav2TmN .icon-shape,#mermaid-svg-SYVQZrEcfWav2TmN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SYVQZrEcfWav2TmN .icon-shape p,#mermaid-svg-SYVQZrEcfWav2TmN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SYVQZrEcfWav2TmN .icon-shape .label rect,#mermaid-svg-SYVQZrEcfWav2TmN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SYVQZrEcfWav2TmN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SYVQZrEcfWav2TmN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SYVQZrEcfWav2TmN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LazyColumn 核心参数
state
contentPadding
reverseLayout
verticalArrangement
cacheWindow
rememberLazyListState
滚动控制与状态恢复
内容被裁剪后添加内边距
Edge-to-Edge 场景
反转滚动与布局方向
聊天界面
条目间距与分布
预取缓存窗口
提前组合与预测量

三、LazyVerticalGrid 与 LazyHorizontalGrid

3.1 基本用法

LazyVerticalGrid 在垂直可滚动容器中以多列方式显示条目:

kotlin 复制代码
LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 150.dp),
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
    horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(photos, key = { it.id }) { photo ->
        PhotoCard(photo)
    }
}

3.2 GridCells 的三种模式

  • GridCells.Fixed(n) :固定 n 列,每列宽度均分。
  • GridCells.Adaptive(minSize) :根据容器宽度自动确定列数,每列至少 minSize 宽。适用于响应式布局------手机竖屏显示 2 列,平板横屏自动显示 4 列。
  • GridCells.FixedSize(size) :固定每列宽度为 size,列数由容器宽度决定。

3.3 跨列条目

LazyVerticalGrid 支持通过 span 参数让条目跨越多个列:

kotlin 复制代码
LazyVerticalGrid(columns = GridCells.Fixed(4)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        HeaderBanner()  // 跨全部 4 列
    }
    items(photos, key = { it.id }) { photo ->
        PhotoCard(photo)  // 占 1 列
    }
}

四、key() 的正确使用与性能优化

4.1 没有 key 时会发生什么

LazyColumn 的 key 参数告诉 Compose 如何识别列表中的每一个条目。未指定 key 时,Compose 默认按位置索引 区分条目。官方文档明确指出,key 必须是"稳定且唯一的"标识符,且"列表中不允许使用相同的 key 标识多个条目"。这意味着列表头部插入一个新条目后,所有条目的位置都会偏移,Compose 认为每个位置上的"条目"都发生了变化,触发全量重组。

例如,在一个按修改时间排序的笔记列表中,用户修改了底部的笔记,该笔记移动到顶部,其余所有笔记下移一位。没有 key 的情况下,Compose 无法识别"位置 3 的条目就是之前的条目 2",而是认为每个位置上的条目都被替换了,导致整个列表重组。

4.2 正确配置 key

kotlin 复制代码
LazyColumn {
    items(
        items = users,
        key = { it.id }
    ) { user ->
        UserItem(user)
    }
}

用唯一且稳定的 ID(如数据库主键、业务唯一标识)作为 key,Compose 可以精确追踪每个条目的移动和复用,只对真正发生变化的条目触发重组。

4.3 key 的类型限制

key 必须是可序列化到 Bundle 的类型(Int、Long、String 等原始类型或其包装类),因为在配置更改或进程重建时,LazyColumn 需要恢复滚动位置。如果用自定义对象做 key 且未实现 Parcelable,滚动状态恢复会静默失败。

4.4 在列表外排序数据

一个常见的性能陷阱是在 items() 内部对数据做变换:

kotlin 复制代码
// ❌ 每次重组都重新排序
LazyColumn {
    items(contacts.sortedWith(comparator)) { contact ->
        ContactRow(contact)
    }
}

每次 ContactList 重组时,整个联系人列表都会被重新排序,即使用户只是在滚动列表。正确的做法是在 LazyColumn 外部排序,并用 remember 缓存结果:

kotlin 复制代码
// ✅ 只在 contacts 或 comparator 变化时重新排序
val sortedContacts = remember(contacts, comparator) {
    contacts.sortedWith(comparator)
}
LazyColumn {
    items(sortedContacts) { contact ->
        ContactRow(contact)
    }
}

4.5 contentType 与条目类型区分

当列表包含多种类型的条目时(如头图、文本条目、视频条目),通过 contentType 参数告知 Compose 条目类型。官方文档指出:"相同类型的条目组合可以被更高效地复用"。这帮助复用池更精确地分配和回收条目:

kotlin 复制代码
LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { item ->
            when (item) {
                is FeedItem.Header -> "header"
                is FeedItem.Text -> "text"
                is FeedItem.Video -> "video"
            }
        }
    ) { item ->
        when (item) {
            is FeedItem.Header -> HeaderItem(item)
            is FeedItem.Text -> TextItem(item)
            is FeedItem.Video -> VideoItem(item)
        }
    }
}

将 List 替换为 ImmutableList,Compose 编译器可以自动推断类型稳定性。

方案 B:用 @Immutable 显式标注

kotlin 复制代码
@Immutable
data class User(
    val id: Long,
    val name: String,
    val tags: List<String>
)

@Immutable 是向 Compose 编译器的承诺:你保证这个类的所有属性在创建后不会改变。如果承诺被打破(用可变 List 并在外部修改),重组行为会变得不可预测。

方案 C:使用稳定性配置文件

对于无法直接修改的外部类或库类,可以通过 Compose 编译器稳定性配置文件标记为稳定:

kotlin 复制代码
// build.gradle.kts
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

在 stability_config.conf 中列出应被视为稳定的类(支持通配符)。

5.4 用 Layout Inspector 验证稳定性

Android Studio 的 Layout Inspector 在 Compose 模式下可以显示每个 Composable 的重组次数和跳过次数。打开 Live Updates,滑动列表触发更新,观察 UserItem 的跳过次数。如果跳过次数不增长,说明参数不稳定导致无法跳过重组。

此外,Compose Compiler Reports 可以生成不稳定参数和可跳过/可重启标志的报告,交叉验证哪些 Composable 存在稳定性问题。

5.5 减少 item 内部重组

避免在 item lambda 中捕获整个 ViewModel。每个 item 捕获 ViewModel 会导致 ViewModel 的任何状态变化都触发所有 item 重组:

kotlin 复制代码
// ❌ 每个 item 捕获整个 ViewModel
items(users) { user ->
    UserItem(
        user = user,
        onToggle = { viewModel.toggle(user.id) }  // 捕获了 viewModel
    )
}

// ✅ 只捕获需要的回调
val onToggle: (Long) -> Unit = remember { { id -> viewModel.toggle(id) } }
items(users, key = { it.id }) { user ->
    UserItem(user = user, onToggle = onToggle)
}

六、列表状态管理

6.1 rememberLazyListState

rememberLazyListState() 创建并记住列表滚动状态,提供以下关键属性:

属性 说明
firstVisibleItemIndex 第一个可见条目的索引
firstVisibleItemScrollOffset 第一个可见条目的滚动偏移量
isScrollInProgress 是否正在滚动
kotlin 复制代码
val listState = rememberLazyListState()

// 滚动到指定位置
LaunchedEffect(Unit) {
    listState.scrollToItem(index = 0)
}

// 平滑滚动
LaunchedEffect(targetIndex) {
    listState.animateScrollToItem(index = targetIndex)
}

LazyColumn(state = listState) {
    items(data) { item -> ItemRow(item) }
}

6.2 滚动位置恢复

rememberLazyListState 在配置更改(如旋转屏幕)时自动恢复滚动位置,前提是列表条目的 key 是可序列化的类型。如果使用自定义对象作为 key 且未实现 Parcelable,滚动状态恢复会静默失败。

6.3 监听滚动位置:derivedStateOf vs snapshotFlow

derivedStateOf 适用于需要驱动 UI 更新的场景 。通过 derivedStateOf 延迟读取滚动状态,避免每次滚动帧都触发重组:

kotlin 复制代码
val listState = rememberLazyListState()
val showScrollToTop by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 5
    }
}

LazyColumn(state = listState) { /* ... */ }

if (showScrollToTop) {
    FloatingActionButton(onClick = { /* 滚动到顶部 */ }) {
        Icon(Icons.Default.KeyboardArrowUp, "回到顶部")
    }
}

snapshotFlow 适用于不需要在同一组合中处理事件的场景。官方文档推荐的典型用法是发送分析事件:

kotlin 复制代码
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

两者的选择原则:需要驱动 UI 变化时用 derivedStateOf,需要触发副作用(如上报、加载)时用 snapshotFlow。

下面是列表状态管理的两种监听方式选择流程:
#mermaid-svg-GejrLtjUs3idMPC4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GejrLtjUs3idMPC4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GejrLtjUs3idMPC4 .error-icon{fill:#552222;}#mermaid-svg-GejrLtjUs3idMPC4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GejrLtjUs3idMPC4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 .marker.cross{stroke:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GejrLtjUs3idMPC4 p{margin:0;}#mermaid-svg-GejrLtjUs3idMPC4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster-label text{fill:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster-label span{color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster-label span p{background-color:transparent;}#mermaid-svg-GejrLtjUs3idMPC4 .label text,#mermaid-svg-GejrLtjUs3idMPC4 span{fill:#333;color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .node rect,#mermaid-svg-GejrLtjUs3idMPC4 .node circle,#mermaid-svg-GejrLtjUs3idMPC4 .node ellipse,#mermaid-svg-GejrLtjUs3idMPC4 .node polygon,#mermaid-svg-GejrLtjUs3idMPC4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .rough-node .label text,#mermaid-svg-GejrLtjUs3idMPC4 .node .label text,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape .label,#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-GejrLtjUs3idMPC4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .rough-node .label,#mermaid-svg-GejrLtjUs3idMPC4 .node .label,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape .label,#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape .label{text-align:center;}#mermaid-svg-GejrLtjUs3idMPC4 .node.clickable{cursor:pointer;}#mermaid-svg-GejrLtjUs3idMPC4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 .arrowheadPath{fill:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GejrLtjUs3idMPC4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GejrLtjUs3idMPC4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GejrLtjUs3idMPC4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GejrLtjUs3idMPC4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GejrLtjUs3idMPC4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GejrLtjUs3idMPC4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster text{fill:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster span{color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GejrLtjUs3idMPC4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GejrLtjUs3idMPC4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape p,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape .label rect,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GejrLtjUs3idMPC4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GejrLtjUs3idMPC4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GejrLtjUs3idMPC4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
rememberLazyListState
firstVisibleItemIndex
firstVisibleItemScrollOffset
isScrollInProgress
需要驱动 UI 更新?
derivedStateOf
延迟读取滚动状态
避免每帧触发重组
如:显示回到顶部按钮
snapshotFlow
触发副作用
发送分析事件
上报滚动行为

七、分页加载与无限滚动

7.1 基于 lazyPagingItems 的分页

Compose 提供了与 Paging 库集成的 lazyPagingItems 对象,在 LazyColumn 中高效管理分页数据的加载和呈现:

kotlin 复制代码
@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

lazyPagingItems.itemCount 返回已加载的条目总数(包括尚未加载的占位符),lazyPagingItems[index] 返回对应位置的条目,若尚未加载则返回 null,此时显示占位符。

7.2 手动实现无限滚动

不依赖 Paging 库时,可以通过 snapshotFlow 监听滚动状态手动触发加载:

kotlin 复制代码
@Composable
fun InfiniteList(viewModel: ListViewModel) {
    val listState = rememberLazyListState()
    val items by viewModel.items.collectAsState()

    LaunchedEffect(listState) {
        snapshotFlow {
            listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
        }.collect { lastVisibleIndex ->
            if (lastVisibleIndex != null &&
                lastVisibleIndex >= items.size - 5) {
                viewModel.loadMore()
            }
        }
    }

    LazyColumn(state = listState) {
        items(items, key = { it.id }) { item ->
            ItemRow(item)
        }
        if (viewModel.isLoading) {
            item { LoadingIndicator() }
        }
    }
}

性能陷阱 :避免为每个列表项创建 LaunchedEffect 来检查是否为最后一项,这会导致不必要的开销和对象分配。正确的做法是在列表级别使用一个 LaunchedEffect 监听 listState。

下面是分页加载与无限滚动的两种实现方案对比:
#mermaid-svg-PSbzFLDSwZx9l5fg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PSbzFLDSwZx9l5fg .error-icon{fill:#552222;}#mermaid-svg-PSbzFLDSwZx9l5fg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PSbzFLDSwZx9l5fg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg .marker.cross{stroke:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PSbzFLDSwZx9l5fg p{margin:0;}#mermaid-svg-PSbzFLDSwZx9l5fg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster-label text{fill:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster-label span{color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster-label span p{background-color:transparent;}#mermaid-svg-PSbzFLDSwZx9l5fg .label text,#mermaid-svg-PSbzFLDSwZx9l5fg span{fill:#333;color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .node rect,#mermaid-svg-PSbzFLDSwZx9l5fg .node circle,#mermaid-svg-PSbzFLDSwZx9l5fg .node ellipse,#mermaid-svg-PSbzFLDSwZx9l5fg .node polygon,#mermaid-svg-PSbzFLDSwZx9l5fg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .rough-node .label text,#mermaid-svg-PSbzFLDSwZx9l5fg .node .label text,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape .label,#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape .label{text-anchor:middle;}#mermaid-svg-PSbzFLDSwZx9l5fg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .rough-node .label,#mermaid-svg-PSbzFLDSwZx9l5fg .node .label,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape .label,#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape .label{text-align:center;}#mermaid-svg-PSbzFLDSwZx9l5fg .node.clickable{cursor:pointer;}#mermaid-svg-PSbzFLDSwZx9l5fg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg .arrowheadPath{fill:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PSbzFLDSwZx9l5fg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PSbzFLDSwZx9l5fg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSbzFLDSwZx9l5fg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PSbzFLDSwZx9l5fg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSbzFLDSwZx9l5fg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster text{fill:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster span{color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PSbzFLDSwZx9l5fg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg rect.text{fill:none;stroke-width:0;}#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape p,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape .label rect,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSbzFLDSwZx9l5fg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PSbzFLDSwZx9l5fg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PSbzFLDSwZx9l5fg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
分页加载需求
是否使用 Paging 库?
lazyPagingItems
pager.flow.collectAsLazyPagingItems
itemCount 返回已加载总数
index 返回条目或 null
null 时显示占位符
手动实现无限滚动
snapshotFlow 监听滚动状态
获取 lastVisibleIndex
接近列表底部?
viewModel.loadMore()
继续监听
列表级别单个 LaunchedEffect

将 List 替换为 ImmutableList,Compose 编译器可以自动推断类型稳定性。

方案 B:用 @Immutable 显式标注

kotlin 复制代码
@Immutable
data class User(
    val id: Long,
    val name: String,
    val tags: List<String>
)

@Immutable 是向 Compose 编译器的承诺:你保证这个类的所有属性在创建后不会改变。如果承诺被打破(用可变 List 并在外部修改),重组行为会变得不可预测。

方案 C:使用稳定性配置文件

对于无法直接修改的外部类或库类,可以通过 Compose 编译器稳定性配置文件标记为稳定:

kotlin 复制代码
// build.gradle.kts
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

在 stability_config.conf 中列出应被视为稳定的类(支持通配符)。

5.4 用 Layout Inspector 验证稳定性

Android Studio 的 Layout Inspector 在 Compose 模式下可以显示每个 Composable 的重组次数和跳过次数。打开 Live Updates,滑动列表触发更新,观察 UserItem 的跳过次数。如果跳过次数不增长,说明参数不稳定导致无法跳过重组。

此外,Compose Compiler Reports 可以生成不稳定参数和可跳过/可重启标志的报告,交叉验证哪些 Composable 存在稳定性问题。

5.5 减少 item 内部重组

避免在 item lambda 中捕获整个 ViewModel。每个 item 捕获 ViewModel 会导致 ViewModel 的任何状态变化都触发所有 item 重组:

kotlin 复制代码
// ❌ 每个 item 捕获整个 ViewModel
items(users) { user ->
    UserItem(
        user = user,
        onToggle = { viewModel.toggle(user.id) }  // 捕获了 viewModel
    )
}

// ✅ 只捕获需要的回调
val onToggle: (Long) -> Unit = remember { { id -> viewModel.toggle(id) } }
items(users, key = { it.id }) { user ->
    UserItem(user = user, onToggle = onToggle)
}

六、列表状态管理

6.1 rememberLazyListState

rememberLazyListState() 创建并记住列表滚动状态,提供以下关键属性:

属性 说明
firstVisibleItemIndex 第一个可见条目的索引
firstVisibleItemScrollOffset 第一个可见条目的滚动偏移量
isScrollInProgress 是否正在滚动
kotlin 复制代码
val listState = rememberLazyListState()

// 滚动到指定位置
LaunchedEffect(Unit) {
    listState.scrollToItem(index = 0)
}

// 平滑滚动
LaunchedEffect(targetIndex) {
    listState.animateScrollToItem(index = targetIndex)
}

LazyColumn(state = listState) {
    items(data) { item -> ItemRow(item) }
}

6.2 滚动位置恢复

rememberLazyListState 在配置更改(如旋转屏幕)时自动恢复滚动位置,前提是列表条目的 key 是可序列化的类型。如果使用自定义对象作为 key 且未实现 Parcelable,滚动状态恢复会静默失败。

6.3 监听滚动位置:derivedStateOf vs snapshotFlow

derivedStateOf 适用于需要驱动 UI 更新的场景 。通过 derivedStateOf 延迟读取滚动状态,避免每次滚动帧都触发重组:

kotlin 复制代码
val listState = rememberLazyListState()
val showScrollToTop by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 5
    }
}

LazyColumn(state = listState) { /* ... */ }

if (showScrollToTop) {
    FloatingActionButton(onClick = { /* 滚动到顶部 */ }) {
        Icon(Icons.Default.KeyboardArrowUp, "回到顶部")
    }
}

snapshotFlow 适用于不需要在同一组合中处理事件的场景。官方文档推荐的典型用法是发送分析事件:

kotlin 复制代码
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

两者的选择原则:需要驱动 UI 变化时用 derivedStateOf,需要触发副作用(如上报、加载)时用 snapshotFlow。

下面是列表状态管理的两种监听方式选择流程:
#mermaid-svg-GejrLtjUs3idMPC4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GejrLtjUs3idMPC4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GejrLtjUs3idMPC4 .error-icon{fill:#552222;}#mermaid-svg-GejrLtjUs3idMPC4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GejrLtjUs3idMPC4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GejrLtjUs3idMPC4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 .marker.cross{stroke:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GejrLtjUs3idMPC4 p{margin:0;}#mermaid-svg-GejrLtjUs3idMPC4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster-label text{fill:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster-label span{color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster-label span p{background-color:transparent;}#mermaid-svg-GejrLtjUs3idMPC4 .label text,#mermaid-svg-GejrLtjUs3idMPC4 span{fill:#333;color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .node rect,#mermaid-svg-GejrLtjUs3idMPC4 .node circle,#mermaid-svg-GejrLtjUs3idMPC4 .node ellipse,#mermaid-svg-GejrLtjUs3idMPC4 .node polygon,#mermaid-svg-GejrLtjUs3idMPC4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .rough-node .label text,#mermaid-svg-GejrLtjUs3idMPC4 .node .label text,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape .label,#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-GejrLtjUs3idMPC4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .rough-node .label,#mermaid-svg-GejrLtjUs3idMPC4 .node .label,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape .label,#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape .label{text-align:center;}#mermaid-svg-GejrLtjUs3idMPC4 .node.clickable{cursor:pointer;}#mermaid-svg-GejrLtjUs3idMPC4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 .arrowheadPath{fill:#333333;}#mermaid-svg-GejrLtjUs3idMPC4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GejrLtjUs3idMPC4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GejrLtjUs3idMPC4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GejrLtjUs3idMPC4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GejrLtjUs3idMPC4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GejrLtjUs3idMPC4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GejrLtjUs3idMPC4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster text{fill:#333;}#mermaid-svg-GejrLtjUs3idMPC4 .cluster span{color:#333;}#mermaid-svg-GejrLtjUs3idMPC4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GejrLtjUs3idMPC4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GejrLtjUs3idMPC4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape p,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GejrLtjUs3idMPC4 .icon-shape .label rect,#mermaid-svg-GejrLtjUs3idMPC4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GejrLtjUs3idMPC4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GejrLtjUs3idMPC4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GejrLtjUs3idMPC4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
rememberLazyListState
firstVisibleItemIndex
firstVisibleItemScrollOffset
isScrollInProgress
需要驱动 UI 更新?
derivedStateOf
延迟读取滚动状态
避免每帧触发重组
如:显示回到顶部按钮
snapshotFlow
触发副作用
发送分析事件
上报滚动行为

七、分页加载与无限滚动

7.1 基于 lazyPagingItems 的分页

Compose 提供了与 Paging 库集成的 lazyPagingItems 对象,在 LazyColumn 中高效管理分页数据的加载和呈现:

kotlin 复制代码
@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

lazyPagingItems.itemCount 返回已加载的条目总数(包括尚未加载的占位符),lazyPagingItems[index] 返回对应位置的条目,若尚未加载则返回 null,此时显示占位符。

7.2 手动实现无限滚动

不依赖 Paging 库时,可以通过 snapshotFlow 监听滚动状态手动触发加载:

kotlin 复制代码
@Composable
fun InfiniteList(viewModel: ListViewModel) {
    val listState = rememberLazyListState()
    val items by viewModel.items.collectAsState()

    LaunchedEffect(listState) {
        snapshotFlow {
            listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
        }.collect { lastVisibleIndex ->
            if (lastVisibleIndex != null &&
                lastVisibleIndex >= items.size - 5) {
                viewModel.loadMore()
            }
        }
    }

    LazyColumn(state = listState) {
        items(items, key = { it.id }) { item ->
            ItemRow(item)
        }
        if (viewModel.isLoading) {
            item { LoadingIndicator() }
        }
    }
}

性能陷阱 :避免为每个列表项创建 LaunchedEffect 来检查是否为最后一项,这会导致不必要的开销和对象分配。正确的做法是在列表级别使用一个 LaunchedEffect 监听 listState。

下面是分页加载与无限滚动的两种实现方案对比:
#mermaid-svg-PSbzFLDSwZx9l5fg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PSbzFLDSwZx9l5fg .error-icon{fill:#552222;}#mermaid-svg-PSbzFLDSwZx9l5fg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PSbzFLDSwZx9l5fg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PSbzFLDSwZx9l5fg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg .marker.cross{stroke:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PSbzFLDSwZx9l5fg p{margin:0;}#mermaid-svg-PSbzFLDSwZx9l5fg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster-label text{fill:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster-label span{color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster-label span p{background-color:transparent;}#mermaid-svg-PSbzFLDSwZx9l5fg .label text,#mermaid-svg-PSbzFLDSwZx9l5fg span{fill:#333;color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .node rect,#mermaid-svg-PSbzFLDSwZx9l5fg .node circle,#mermaid-svg-PSbzFLDSwZx9l5fg .node ellipse,#mermaid-svg-PSbzFLDSwZx9l5fg .node polygon,#mermaid-svg-PSbzFLDSwZx9l5fg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .rough-node .label text,#mermaid-svg-PSbzFLDSwZx9l5fg .node .label text,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape .label,#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape .label{text-anchor:middle;}#mermaid-svg-PSbzFLDSwZx9l5fg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .rough-node .label,#mermaid-svg-PSbzFLDSwZx9l5fg .node .label,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape .label,#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape .label{text-align:center;}#mermaid-svg-PSbzFLDSwZx9l5fg .node.clickable{cursor:pointer;}#mermaid-svg-PSbzFLDSwZx9l5fg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg .arrowheadPath{fill:#333333;}#mermaid-svg-PSbzFLDSwZx9l5fg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PSbzFLDSwZx9l5fg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PSbzFLDSwZx9l5fg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSbzFLDSwZx9l5fg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PSbzFLDSwZx9l5fg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSbzFLDSwZx9l5fg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster text{fill:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg .cluster span{color:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PSbzFLDSwZx9l5fg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PSbzFLDSwZx9l5fg rect.text{fill:none;stroke-width:0;}#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape p,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PSbzFLDSwZx9l5fg .icon-shape .label rect,#mermaid-svg-PSbzFLDSwZx9l5fg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSbzFLDSwZx9l5fg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PSbzFLDSwZx9l5fg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PSbzFLDSwZx9l5fg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
分页加载需求
是否使用 Paging 库?
lazyPagingItems
pager.flow.collectAsLazyPagingItems
itemCount 返回已加载总数
index 返回条目或 null
null 时显示占位符
手动实现无限滚动
snapshotFlow 监听滚动状态
获取 lastVisibleIndex
接近列表底部?
viewModel.loadMore()
继续监听
列表级别单个 LaunchedEffect

八、条目动画

8.1 animateItem

Modifier.animateItem() 是条目级别的动画 API,用于在条目添加、删除和移动时应用默认动画:

kotlin 复制代码
LazyColumn {
    items(users, key = { it.id }) { user ->
        UserItem(
            user = user,
            modifier = Modifier.animateItem()
        )
    }
}

animateItem() 提供三种动画规格参数:fadeInSpec(淡入)、fadeOutSpec(淡出)、placementSpec(位置变化)。可以自定义动画时长和缓动曲线。

使用前提 :必须为 items() 提供稳定的 key,否则动画无法正确追踪条目的身份。如果没有 key,条目在添加、删除或移动时不会被正确识别,动画会失效或不准确。

九、缓存窗口与预取机制

9.1 LazyLayoutCacheWindow

从 Compose 1.9 开始,LazyListPrefetchStrategy 已弃用,取而代之的是 LazyLayoutCacheWindow。它定义了 Lazy 布局视口外的缓存区域,在该区域中的条目会被提前准备(预组合和预测量),以提升滚动性能。

LazyLayoutCacheWindow 提供两个核心函数:

函数 说明
calculateAheadWindow(viewport) 计算滚动方向前方的预取窗口区域(像素)
calculateBehindWindow(viewport) 计算滚动反方向的后方缓存窗口区域(像素)
kotlin 复制代码
val customCacheWindow = remember(state) {
    object : LazyLayoutCacheWindow {
        override fun Density.calculateAheadWindow(viewport: Int): Int {
            val visibleItems = state.layoutInfo.visibleItemsInfo
            if (visibleItems.isEmpty()) return 0
            val averageItemHeight = visibleItems.sumOf { it.size } / visibleItems.size
            return averageItemHeight  // 预取 1 个条目
        }
    }
}
LazyColumn(state = state, cacheWindow = customCacheWindow) {
    items(itemsList) { item -> Text("Item $item") }
}

LazyLayoutCacheWindow 的 isNonScrollCachingEnabled 属性决定是否在非滚动触发时填充缓存窗口。这个 API 与 Compose 1.9 引入的可暂停组合(Pausable Composition)配合使用,可以在帧间分片执行预取工作,避免主线程抢占。

9.2 iOS 端预取的已知问题

社区中有开发者反馈 LazyLayoutCacheWindow 在 iOS 上的 ahead 预取可能不生效------默认的 Skiko 目标上的 PrefetchScheduler 可能是 no-op。运行时日志显示列表项在进入视口前未被预组合。推荐的解决方案是绕过 Compose 内部节点预取器,实现应用级别的数据和图片预取架构,基于滚动状态观察来驱动。

十、CMP 平台差异与性能改进

10.1 iOS 惰性布局滚动性能改进

CMP 1.12.0 为 iOS 端的惰性布局带来了滚动性能改进。列表项停用(deactivation)现在在绘制阶段之外执行,使绘制阶段能够更快完成,从而实现更平滑的滚动。在此之前,列表项在离开视口时的停用操作与绘制在同一阶段执行,增加了关键渲染路径的负担。

10.2 Android 与 iOS 的滚动差异

Android 端 CMP 复用 Jetpack Compose 的原生渲染管线,滚动性能与原生 Android 应用一致。iOS 端通过 Skiko 渲染,在 iOS 上模拟平台的滚动物理效果(如弹性回弹)。桌面端的滚动支持仅限于鼠标滚轮,不支持多点触控手势------鼠标滚轮产生离散的 tick 事件,触控板和触摸屏产生连续事件,CMP 1.10+ 处理了这三种模式。

在 iOS 上,LazyColumn 嵌套 LazyRow 的复杂场景中,可能出现滚动至底部时的回弹卡顿现象,firstVisibleItemIndex 跳变,onScroll 回调频率骤降。解决方案之一是为 LazyColumn 注入 iosScrollOptimization Modifier,内部监听 UIScrollViewDelegate 的 scrollViewDidScroll 并合并节流事件,绕过 CMP 默认的帧同步采样瓶颈。

10.3 LazyGridImageView 基准测试

JetBrains 的 LazyGridImageView 基准测试项目展示了如何在 CMP 中渲染 999 张图片的 3 列网格,并将 CMP 实现与原生 Android(Jetpack Compose)和原生 iOS(SwiftUI)进行性能对比。该项目用于比较 Compose Multiplatform 与原生实现在尺寸、启动时间、FPS、CPU/GPU 使用率等指标上的差异。

10.4 性能分析工具链

工具 用途 适用平台
Compose Compiler Reports 不稳定参数、可跳过/可重启标志 全平台
Layout Inspector 重组计数、跳过计数 Android
Composition Tracing 帧级组合时序 Android
Instruments CPU/GPU 分析 iOS
JVM Profiler CPU/内存分析 Desktop
浏览器开发者工具 性能分析 Web

这些工具帮助定位列表性能问题的根源------是重组过多、布局过深,还是渲染瓶颈。JetBrains 还提供了 find_degradation.main.kts 脚本,用于定位引入 CMP 性能回退的首个版本。

10.5 commonMain 约束

惰性布局代码在 commonMain 中编写,不能依赖平台特有的 API。平台特有的滚动行为调整应通过 expect/actual 机制下沉到对应源集。LazyLayoutCacheWindow 在 iOS 上的预取行为需要单独验证。

配套习题(15题)

第一组:惰性布局基础(4题)

第1题 惰性布局与普通 Column/Row 的核心区别是( )

A. 惰性布局支持更多平台

B. 惰性布局只组合和布局当前可见的条目,并在滚动时复用离开视口的条目

C. 惰性布局不支持水平滚动

D. 普通布局性能更好

答案:B

解析:惰性布局只组合和布局当前可见的条目,并在滚动时复用离开视口的条目。对于 4 万个条目的列表,渲染成本为 O(可见条目数),与列表总长度无关。这与 RecyclerView 的 View 复用池机制类似,但开发者无需手动管理 Adapter 和 ViewHolder。

第2题 LazyColumn 的 contentPadding 参数与 modifier.padding 的关键区别是( )

A. 两者效果完全相同

B. contentPadding 在内容被裁剪后添加内边距,滚动时内容会延伸到内边距区域

C. modifier.padding 只能用于 LazyColumn

D. contentPadding 不支持顶部内边距

答案:B

解析 :contentPadding 在内容被裁剪后添加内边距,因此滚动时内容会延伸到内边距区域(如状态栏下方),而 modifier.padding 裁剪整个列表区域。文档明确指出,contentPadding 是"内容被裁剪后的填充,无法通过 modifier 参数实现"。

第3题 reverseLayout = true 在聊天界面中的应用场景是( )

A. 使列表水平滚动

B. 使最新消息出现在底部,列表自动定位到最新消息

C. 禁用用户滚动

D. 使列表从右侧开始排列

答案:B

解析 :reverseLayout = true 将滚动方向和布局方向都反转,列表从底部开始排列。这在聊天界面中非常实用------最新的消息出现在底部,列表自动定位到最新消息。

第4题 stickyHeader 在 LazyColumn 中的作用是( )

A. 使列表滚动速度更快

B. 当列表滚动时,头部固定在列表顶部直到被下一个头部推走

C. 使列表条目不可点击

D. 自动折叠列表

答案:B

解析 :stickyHeader 支持粘性头部------当列表滚动时,头部会固定在列表顶部直到被下一个头部推走。该 API 目前是实验性的,需要 @OptIn(ExperimentalFoundationApi::class) 标注。

第二组:LazyVerticalGrid(3题)

第5题 GridCells.Adaptive(minSize = 150.dp) 的效果是( )

A. 始终显示 3 列

B. 根据容器宽度自动确定列数,每列至少 150.dp 宽

C. 每列宽度固定为 150.dp

D. 始终显示 1 列

答案:B

解析 :GridCells.Adaptive(minSize) 根据容器宽度自动确定列数,每列至少 minSize 宽。适用于响应式布局------手机竖屏显示 2 列,平板横屏自动显示 4 列。

第6题 在 LazyVerticalGrid 中让某个条目跨越所有列,应使用( )

A. Modifier.fillMaxWidth()

B. GridItemSpan(maxLineSpan)

C. GridCells.Fixed(1)

D. Modifier.span(3)

答案:B

解析 :LazyVerticalGrid 支持通过 span 参数让条目跨越多个列。GridItemSpan(maxLineSpan) 表示跨越当前网格的所有列。GridItemSpan(2) 表示跨越 2 列。

第7题 LazyVerticalStaggeredGrid 对应的 RecyclerView LayoutManager 是( )

A. LinearLayoutManager

B. GridLayoutManager

C. StaggeredGridLayoutManager

D. FlexboxLayoutManager

答案:C

解析 :LazyVerticalStaggeredGrid / LazyHorizontalStaggeredGrid 对应 StaggeredGridLayoutManager,用于实现交错网格布局。

第三组:key 与性能优化(4题)

第8题 没有为 items() 提供 key 时,列表头部插入新条目会导致( )

A. 只有新条目被渲染

B. 所有条目的位置偏移,Compose 认为每个位置上的条目都发生了变化,触发全量重组

C. 列表自动排序

D. 列表滚动位置丢失

答案:B

解析:未指定 key 时,Compose 默认按位置索引区分条目。列表头部插入一个新条目后,所有条目的位置都会偏移,Compose 认为每个位置上的"条目"都发生了变化,触发全量重组。

第9题 key 必须是什么类型?( )

A. 任意自定义对象

B. 可序列化到 Bundle 的类型(Int、Long、String 等原始类型或其包装类)

C. 只能是 String

D. 只能是 Int

答案:B

解析:key 必须是可序列化到 Bundle 的类型(Int、Long、String 等原始类型或其包装类),因为在配置更改或进程重建时,LazyColumn 需要恢复滚动位置。官方文档明确指出 key 必须是"可保存到 Bundle 的类型"。

第10题 Compose 为什么将 List、Set、Map 视为不稳定类型?( )

A. 这些类型性能较差

B. 无法保证它们不是可变的

C. 这些类型不支持 Kotlin 协程

D. 这些类型不能序列化

答案:B

解析 :Compose 始终将集合类(List、Set、Map)视为不稳定类型,因为无法保证它们不是可变的。如果一个 data class 包含 List<String> 字段,Compose 编译器会把整个类标记为不稳定。

第11题 关于 @Immutable 标注,以下说法正确的是( )

A. @Immutable 是向 Compose 编译器的承诺,保证类的所有属性在创建后不会改变

B. @Immutable 可以标注在函数上

C. @Immutable 只在 Android 平台生效

D. @Immutable 会自动将 List 转换为 ImmutableList

答案:A

解析 :@Immutable 是向 Compose 编译器的承诺:你保证这个类的所有属性在创建后不会改变。如果承诺被打破(用可变 List 并在外部修改),重组行为会变得不可预测。

第四组:状态管理与分页(2题)

第12题 rememberLazyListState() 提供的 firstVisibleItemIndex 属性的作用是( )

A. 返回列表中条目的总数

B. 返回第一个可见条目的索引

C. 返回列表的滚动速度

D. 返回列表的宽度

答案:B

解析 :rememberLazyListState() 创建的 LazyListState 提供以下关键属性:firstVisibleItemIndex(第一个可见条目的索引)、firstVisibleItemScrollOffset(第一个可见条目的滚动偏移量)、isScrollInProgress(是否正在滚动)。

第13题 lazyPagingItems[index] 返回 null 时表示( )

A. 列表加载失败

B. 该位置的条目尚未加载

C. 该位置的条目已被删除

D. 该位置的条目不需要显示

答案:B

解析 :lazyPagingItems[index] 返回对应位置的条目,若尚未加载则返回 null,此时显示占位符。lazyPagingItems.itemCount 返回已加载的条目总数(包括尚未加载的占位符)。

第五组:平台差异与性能(2题)

第14题 CMP 1.12.0 对 iOS 惰性布局的改进是( )

A. 增加了新的 LazyRow 组件

B. 列表项停用在绘制阶段之外执行,使绘制阶段更快完成

C. 移除了 LazyColumn 对 iOS 的支持

D. 将 LazyColumn 替换为原生 UITableView

答案:B

解析:CMP 1.12.0 为 iOS 端的惰性布局带来了滚动性能改进。列表项停用现在在绘制阶段之外执行,使绘制阶段能够更快完成,从而实现更平滑的滚动。

第15题 关于 Modifier.animateItem() 的使用前提,以下说法正确的是( )

A. 不需要任何前提条件

B. 必须为 items() 提供稳定的 key,否则动画无法正确追踪条目身份

C. 只能在 Android 平台上使用

D. 必须配合 stickyHeader 一起使用

答案:B

解析 :Modifier.animateItem() 必须为 items() 提供稳定的 key,否则动画无法正确追踪条目的身份。没有 key 时,条目在添加、删除或移动时不会被正确识别,动画会失效或不准确。

本课知识点总结

1. 惰性布局的虚拟化模型

惰性布局只组合、测量和绘制当前可见的条目,以及约 1-2 屏的过度滚动缓冲。对于 4 万个条目的列表,渲染成本为 O(可见条目数),与列表总长度无关。从 RecyclerView 迁移时,onCreateViewHolder() 和 onBindViewHolder() 的逻辑被合并为同一个 Composable 函数。

2. LazyColumn/LazyRow 核心参数与用法

contentPadding 在内容被裁剪后添加内边距,文档明确指出"无法通过 modifier 参数实现",在 Edge-to-Edge 场景中应使用 contentPadding 而非 modifier.padding。reverseLayout 反转滚动方向,适用于聊天界面。stickyHeader 支持粘性头部(实验性 API)。LazyVerticalGrid 的 columns 支持 Fixed、Adaptive、FixedSize 三种模式,通过 span 参数实现跨列条目。

3. key()、contentType 与性能优化

没有 key 时,Compose 按位置索引区分条目,列表头部插入新条目会触发全量重组。key 必须是唯一且可序列化到 Bundle 的类型。多类型列表应使用 contentType 帮助复用池精确分配,相同类型的条目组合可以被更高效地复用。在 items() 外部排序数据并用 remember 缓存结果,避免每次重组都重新排序。

4. 重组控制与稳定性(三种修复方案)

Compose 将集合类(List、Set、Map)视为不稳定类型。包含 List 字段的 data class 会被标记为不稳定,导致无法跳过重组。三种修复方案:使用 kotlinx.collections.immutable 的 ImmutableList ;用 @Immutable 显式标注 ;使用稳定性配置文件 (stabilityConfigurationFile)标记无法修改的外部类。用 Layout Inspector 和 Compose Compiler Reports 验证稳定性。

5. 列表状态管理与分页加载

rememberLazyListState() 提供 firstVisibleItemIndex 等滚动状态,配置更改时自动恢复位置(前提是 key 可序列化)。derivedStateOf 适用于驱动 UI 更新,snapshotFlow 适用于触发副作用。分页可通过 Paging 库的 lazyPagingItems 实现,或通过 snapshotFlow 监听滚动位置手动触发加载。避免为每个列表项创建 LaunchedEffect 来检查是否为最后一项。

6. 条目动画与缓存窗口

Modifier.animateItem() 在条目添加、删除和移动时应用动画,必须为 items() 提供稳定的 key 才能正常工作。LazyListPrefetchStrategy 已弃用,取而代之的是 LazyLayoutCacheWindow,通过 calculateAheadWindow 和 calculateBehindWindow 控制预取和缓存区域。iOS 端的 ahead 预取可能不生效,默认 PrefetchScheduler 可能是 no-op,需要应用级预取架构。

7. CMP 平台差异与性能分析

CMP 1.12.0 改进了 iOS 惰性布局滚动性能------列表项停用在绘制阶段之外执行,减少了关键渲染路径的负担。iOS 上 LazyColumn 嵌套 LazyRow 的复杂场景可能出现回弹卡顿。性能分析工具链包括 Compose Compiler Reports、Layout Inspector、Composition Tracing、Instruments、JVM Profiler 等,JetBrains 还提供了 find_degradation.main.kts 脚本用于定位性能回退版本。JetBrains 的 LazyGridImageView 基准测试项目对比了 CMP 与原生 Android/iOS 的网格性能。


下一课预告:第8课将进入状态管理与导航模块,深入 Compose 状态管理基础,包括 State、remember、mutableStateOf 的核心概念,状态提升(State Hoisting)与单向数据流,derivedStateOf 延迟读取优化,状态的生命周期,以及 DisposableEffect 与 LaunchedEffect 的正确使用。

相关推荐
传奇开心果编程12 小时前
【Compose Multiplatform跨端声明式UI实用技术】第6课:常用组件与 Material 3 设计系统
移动开发·编程语言理论·开发范式·编程框架·声明式ui·常用组件·material 3 设计系统
FungLeo1 天前
成为全栈·Flutter App 篇·Markdown 编辑器:编辑、预览与离开保护
flutter·移动开发·markdown编辑器·表单设计·成为全栈·草稿保存
传奇开心果编程1 天前
【Compose Multiplatform跨端声明式UI实用技术】第3课:Compose Hot Reload 与开发效率工具链
移动开发·编程语言理论·热重载·开发范式·编程框架·声明式ui·效率工具链
FungLeo2 天前
成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全
flutter·网络安全·移动开发·webview·路由管理·成为全栈
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第4课:Composable 函数与声明式 UI 思维
移动开发·编程语言理论·开发范式·编程框架·函数式思维·声明式ui·声明式ui思维
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第1课:CMP 全景解读——为什么选择声明式跨平台UI
移动开发·编程语言理论·开发范式·编程框架·声明式ui·跨平台声明式ui·逻辑共享与ui共享
FungLeo3 天前
成为全栈·Flutter App 篇·分类、标签与搜索:移动端内容发现
flutter·移动开发·标签·搜索·分类管理·成为全栈
FungLeo3 天前
成为全栈·Flutter App 篇·列表分页与下拉刷新:去重、失败保留和返回位置
flutter·移动开发·下拉刷新·分页加载·列表优化·成为全栈
FungLeo6 天前
成为全栈·Flutter App 篇·go_router 与四 Tab App Shell:保留导航状态、深链和登录回跳
flutter·移动开发·成为全栈·go_router·路由设计·深链接