列表与滚动:LazyColumn、LazyRow、LazyVerticalGrid

列表,是移动端应用最核心的交互形态之一。你想想看,从微信聊天记录到淘宝商品流,从微博时间线到抖音评论区,本质上都是列表。在 Jetpack Compose 里,我们不再用 RecyclerView 那套 Adapter 模式了,而是用一套更声明式的 Lazy 系列组件。

我个人习惯把 LazyColumn、LazyRow、LazyVerticalGrid 统称为「懒加载三兄弟」。它们最大的特点就是:只渲染屏幕上可见的部分。数据量再大,也不会卡死 UI 线程。嗯,这一点和 RecyclerView 的 ViewHolder 复用机制异曲同工,但写法上简洁太多了。

核心概念:Lazy 系列组件基于 LazyListState 管理滚动位置,通过 itemsitemsIndexed 填充数据,支持自定义 item 布局、粘性头部、下拉刷新等高级功能。

1. LazyColumn:垂直列表

LazyColumn 是最常用的垂直滚动列表。我在项目中用它替换了几乎所有 RecyclerView 的场景。基本用法如下:

@Composable
fun SimpleLazyColumn() {
    val items = (1..100).toList()

    LazyColumn {
        items(items) { item ->
            Text(
                text = "Item #$item",
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            )
        }
    }
}

这里有个细节:items 函数接收一个集合,内部会自动处理 key 的生成。如果你需要稳定的 key(比如数据库主键),可以显式传入:

items(items, key = { it.id }) { item ->
    // ...
}

我的经验:如果不传 key,Compose 会使用 item 在列表中的索引作为 key。当列表发生增删操作时,可能会导致不必要的重组。我建议只要数据有唯一 ID,就传 key。这能显著提升列表的滚动性能。

2. LazyRow:水平列表

LazyRow 和 LazyColumn 几乎一模一样,只是滚动方向变成了水平。适合做横向滑动的标签页、图片轮播等。

@Composable
fun HorizontalCategoryList() {
    val categories = listOf("推荐", "关注", "科技", "生活", "娱乐", "体育")

    LazyRow {
        items(categories) { category ->
            Text(
                text = category,
                modifier = Modifier
                    .padding(horizontal = 8.dp, vertical = 4.dp)
                    .background(Color.LightGray, RoundedCornerShape(8.dp))
                    .padding(horizontal = 16.dp, vertical = 8.dp)
            )
        }
    }
}

注意,LazyRow 默认不会换行。如果内容超出屏幕宽度,它会继续向右滚动。我曾经在做一个标签选择器时,忘了限制最大宽度,结果标签串成了一长条……嗯,后来加了个 widthIn(max = 200.dp) 才搞定。

3. LazyVerticalGrid:网格列表

网格布局在电商、相册等场景非常常见。LazyVerticalGrid 可以指定列数或自适应列宽。

@Composable
fun PhotoGrid() {
    val photos = (1..20).toList()

    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        contentPadding = PaddingValues(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(photos) { photo ->
            Box(
                modifier = Modifier
                    .aspectRatio(1f)
                    .background(Color.Gray)
            ) {
                Text(
                    text = "$photo",
                    modifier = Modifier.align(Alignment.Center),
                    color = Color.White
                )
            }
        }
    }
}

GridCells.Fixed(3) 表示固定 3 列。如果你希望列宽自适应,可以用 GridCells.Adaptive(minSize = 100.dp),它会根据容器宽度自动计算列数。

避坑指南:我曾经在 LazyVerticalGrid 里嵌套了另一个 LazyColumn,结果滚动冲突严重,界面卡顿。记住:不要在 Lazy 组件里嵌套另一个同方向的 Lazy 组件。如果非要嵌套,考虑用 LazyColumn + Column 组合,或者用 NestedScrollConnection 处理嵌套滚动。

4. items 与 itemsIndexed

这两个函数是填充数据的核心。区别很简单:items 只提供数据本身,itemsIndexed 额外提供索引。

// items:只有数据
items(items) { item ->
    Text(text = item.name)
}

// itemsIndexed:有索引和数据
itemsIndexed(items) { index, item ->
    Text(text = "第${index + 1}个:${item.name}")
}

我个人习惯:如果只需要渲染数据,用 items;如果需要在 UI 上显示序号、或者根据奇偶行设置不同背景色,就用 itemsIndexed

5. 粘性头部(Sticky Header)

粘性头部是列表分组时的经典需求——比如通讯录按字母分组,滚动时字母标题会「粘」在顶部。Compose 提供了 stickyHeader 函数:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactListWithStickyHeader() {
    val contacts = listOf(
        "A" to listOf("Alice", "Amy"),
        "B" to listOf("Bob", "Ben"),
        "C" to listOf("Charlie", "Cathy")
    )

    LazyColumn {
        contacts.forEach { (letter, names) ->
            stickyHeader {
                Text(
                    text = letter,
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color.LightGray)
                        .padding(8.dp),
                    fontWeight = FontWeight.Bold
                )
            }
            items(names) { name ->
                Text(
                    text = name,
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
                )
            }
        }
    }
}

注意 stickyHeader 目前还是 @ExperimentalFoundationApi,需要添加 opt-in 注解。不过放心,这个 API 已经很稳定了,我在生产项目里用了大半年没出过问题。

6. 下拉刷新

下拉刷新是列表的标配功能。Compose Material3 提供了 pullToRefresh 修饰符,但更常见的是用 accompanist-swiperefresh 或 Material3 的 PullToRefreshBox。这里我推荐 Material3 原生方案:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RefreshableList() {
    var isRefreshing by remember { mutableStateOf(false) }
    val items = remember { mutableStateListOf(*Array(20) { "Item $it" }) }

    PullToRefreshBox(
        isRefreshing = isRefreshing,
        onRefresh = {
            isRefreshing = true
            // 模拟网络请求
            MainScope().launch {
                delay(1500)
                items.clear()
                items.addAll(Array(20) { "Refreshed Item $it" })
                isRefreshing = false
            }
        }
    ) {
        LazyColumn {
            items(items) { item ->
                Text(
                    text = item,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                )
            }
        }
    }
}

PullToRefreshBox 会自动处理下拉手势、刷新指示器的显示和隐藏。你只需要在 onRefresh 回调里执行异步操作,完成后把 isRefreshing 设为 false 即可。

小技巧:下拉刷新时,如果列表数据量很大,建议配合 LaunchedEffect 做分页加载。我通常会在列表底部加一个 item 显示「加载更多」,当用户滚动到底部时自动触发加载。

知识体系总览

下面这张图梳理了本章的核心知识点和它们之间的关系:

列表与滚动:知识体系 Lazy 系列组件 LazyColumn 垂直列表,最常用 LazyRow 水平列表,横向滑动 LazyVerticalGrid 网格布局,固定/自适应列 items / itemsIndexed stickyHeader 粘性头部 PullToRefreshBox 下拉刷新 核心原则:只渲染可见项,避免嵌套同方向 Lazy

总结

列表和滚动是 Android 开发的基础功。LazyColumn、LazyRow、LazyVerticalGrid 覆盖了绝大多数场景。配合 itemsIndexed、stickyHeader、下拉刷新,基本能应对 90% 以上的列表需求。

最后提醒一句:性能优化的核心永远是「少渲染」。能用 Lazy 就别用 Column/Row 包大量数据,能用 key 就别偷懒。我在早期项目里吃过亏,一个列表塞了 500 条数据没用 Lazy,结果滑动掉帧严重……嗯,后来重构时全换成了 LazyColumn,流畅度直接起飞。


公众号:蓝海资料掘金营,微信deep3321