HarmonyOS应用《民族图鉴》开发第51篇:下拉刷新与上拉加载——列表交互进阶实战

📖 引言
列表是移动应用中最常见的界面形态。
从新闻列表、商品列表、聊天列表,到「民族图鉴」的民族列表……几乎每个 App 都有列表。而列表的交互体验,直接决定了用户对整个应用的印象。
一个好的列表交互应该是什么样的?
- 下拉能刷新最新数据
- 上滑能加载更多
- 加载中有骨架屏,不是白屏
- 加载失败有重试按钮
- 没有数据了有友好的空状态
- 到底了告诉用户"没有更多了"
但很多开发者对列表交互的理解,停留在"用个 List 组件"的层面。结果就是:
- 下拉刷新生硬,没有阻尼感
- 上拉加载闪烁,数据跳来跳去
- 加载失败了没提示,用户不知道怎么回事
- 空状态就是一个大白屏,用户以为卡了
- 骨架屏一闪而过,还不如不要
ArkUI 提供了 Refresh 组件、List 的 onReachEnd 等基础能力,但要做出"丝滑"的列表交互,还需要很多细节处理。
本文我们就从列表交互的完整形态讲起,深入到下拉刷新、上拉加载、骨架屏、空状态、错误状态等各个环节,结合「民族图鉴」项目的民族列表实战,带你系统性地掌握列表交互开发。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解列表交互的完整形态:刷新 + 加载 + 空状态 + 错误状态
- ✅ 掌握 Refresh 组件的使用与自定义
- ✅ 理解上拉加载更多的实现原理
- ✅ 学会列表状态管理(加载中、失败、没有更多、全部加载完)
- ✅ 实现骨架屏(Skeleton)的加载效果
- ✅ 掌握下拉动画与丝滑的交互细节
- ✅ 避开列表交互的常见坑:刷新抖动、加载闪烁、数据错乱
💡 需求分析
列表交互的完整形态
一个"完整"的列表,应该有哪些状态?
| 状态 | 说明 | 用户看到什么 |
|---|---|---|
| 首次加载中 | 第一次打开页面,数据还没回来 | 骨架屏 / Loading |
| 加载成功(有数据) | 数据加载成功,有内容 | 列表 + 下拉刷新 + 上拉加载 |
| 加载成功(无数据) | 数据加载成功,但没有内容 | 空状态(空插画 + 提示文字) |
| 加载失败 | 网络错误、服务器错误等 | 错误状态(错误提示 + 重试按钮) |
| 下拉刷新中 | 用户下拉正在刷新 | 顶部刷新指示器 + 列表 |
| 上拉加载中 | 滑动到底部正在加载更多 | 底部 Loading |
| 加载更多失败 | 加载更多时出错 | 底部错误提示 + 点击重试 |
| 没有更多 | 所有数据都加载完了 | 底部"没有更多了"提示 |
8 种状态,一个都不能少。少了任何一个,用户在某个场景下就会"懵"。
为什么这些状态都很重要?
空状态:用户打开收藏页,发现是空的。如果只是白屏,用户会以为"卡了?还是坏了?"。如果有个空插画 + “还没有收藏,去看看吧”,用户就明白了。
错误状态:网络不好,数据加载失败。如果只是白屏,用户会一直等。如果有"加载失败,点击重试",用户就知道该怎么办。
没有更多:滑动到底部,如果什么都没有,用户会继续滑,心里想"怎么没了?是卡了吗?“。如果有个”—— 没有更多了 ——",用户就知道到底了。
💡 好的列表交互,就是在每个场景下,用户都知道"发生了什么"、“我该做什么”。
「民族图鉴」的列表场景
「民族图鉴」项目中有多个列表场景:
| 页面 | 列表类型 | 需要的能力 |
|---|---|---|
| 民族列表页 | 56个民族的网格/列表 | 下拉刷新、上拉加载(虽然只有56个,但架构上支持) |
| 收藏页 | 收藏的民族列表 | 下拉刷新、空状态 |
| 浏览历史页 | 浏览历史列表 | 下拉刷新、上拉加载、空状态 |
| 音乐列表页 | 民族音乐列表 | 下拉刷新、上拉加载 |
我们以民族列表页为例,一步步实现完整的列表交互。
🛠️ 核心实现
步骤1:Refresh 组件——下拉刷新
ArkUI 提供了 Refresh 组件,专门用于下拉刷新。
1.1 基本用法
@State isRefreshing: boolean = false;
build() {
Refresh({ refreshing: $$this.isRefreshing, offset: 80, friction: 42 }) {
List() {
// ...列表内容
}
.width('100%')
.layoutWeight(1)
}
.onRefresh(() => {
// 触发刷新
console.info('开始刷新');
this.loadData();
})
}
参数说明:
refreshing:是否正在刷新,用 $$ 双向绑定offset:触发刷新的下拉距离(vp),默认 80friction:下拉摩擦系数,越大越难拉,默认 42onRefresh:刷新触发回调
基本流程:
- 用户下拉列表
- 下拉距离超过 offset,触发 onRefresh
- isRefreshing 设为 true,显示刷新指示器
- 数据加载完成后,isRefreshing 设为 false,刷新结束
1.2 自定义刷新指示器
默认的刷新指示器是系统样式。如果想要自定义的效果,可以用 builder 参数:
@State isRefreshing: boolean = false;
@State pullDistance: number = 0;
build() {
Refresh({
refreshing: $$this.isRefreshing,
offset: 80,
friction: 42,
builder: () => {
this.buildCustomRefresh()
}
}) {
List() {
// ...列表内容
}
}
.onRefresh(() => {
this.loadData();
})
}
@Builder
buildCustomRefresh() {
Column() {
if (this.isRefreshing) {
// 刷新中:旋转的加载图标
Image($r('app.media.loading'))
.width(24)
.height(24)
.rotate({ angle: this.rotateAngle })
.animation({ duration: 800, curve: Curve.Linear, iterations: -1 })
} else {
// 下拉中:根据下拉距离变化
Image($r('app.media.refresh_arrow'))
.width(24)
.height(24)
.rotate({ angle: this.pullDistance > 80 ? 180 : 0 })
.animation({ duration: 200, curve: Curve.EaseInOut })
}
Text(this.isRefreshing ? '刷新中...' : (this.pullDistance > 80 ? '释放刷新' : '下拉刷新'))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 4 })
}
.width('100%')
.height(80)
.justifyContent(FlexAlign.Center)
}
1.3 下拉刷新的交互细节
一个好的下拉刷新,有很多细节:
1. 阻尼效果
下拉的过程不是线性的,而是越往下拉越"沉"——这就是阻尼。Refresh 组件的 friction 参数就是控制这个的。
- friction 越大,越难往下拉
- friction 越小,越容易往下拉
- 默认 42,是比较舒服的值
2. 释放反馈
下拉过程中,用户需要知道"拉够了没有":
- 没拉够:显示"下拉刷新",箭头朝下
- 拉够了:显示"释放刷新",箭头朝上(转 180 度)
这个细节很重要——用户知道"松手就会刷新",心里有底。
3. 刷新中状态
松手后,刷新指示器停在那里,转啊转,告诉用户"正在加载"。
4. 刷新结束回弹
数据加载完后,刷新指示器收回去,列表弹回原位。
步骤2:上拉加载更多
上拉加载(也叫无限滚动、分页加载)是长列表的标配。
2.1 实现原理
上拉加载的原理很简单:
- 监听列表滚动
- 当滚动到接近底部时(比如还剩 5 项),触发加载
- 加载下一页数据,追加到列表末尾
- 如果没有下一页了,显示"没有更多了"
ArkUI 的 List 组件提供了 onReachEnd 事件,专门用于这个场景。
@State list: Array<any> = [];
@State isLoadingMore: boolean = false;
@State hasMore: boolean = true;
@State page: number = 1;
private pageSize: number = 20;
build() {
List() {
ForEach(this.list, (item: any) => {
ListItem() {
// ...列表项
}
}, (item: any) => item.id)
// 底部加载状态
if (this.isLoadingMore) {
ListItem() {
this.buildLoadingMore()
}
} else if (!this.hasMore && this.list.length > 0) {
ListItem() {
this.buildNoMore()
}
}
}
.onReachEnd(() => {
// 滚动到底部了
if (this.hasMore && !this.isLoadingMore) {
this.loadMore();
}
})
}
2.2 加载更多的几种底部状态
底部有三种状态:
| 状态 | 显示 | 触发条件 |
|---|---|---|
| 加载中 | Loading + “加载中…” | 正在加载下一页 |
| 加载失败 | 错误提示 + 点击重试 | 加载更多失败 |
| 没有更多 | “—— 没有更多了 ——” | 所有数据加载完毕 |
代码实现:
@State loadMoreError: string = '';
@Builder
buildListFooter() {
if (this.isLoadingMore) {
// 加载中
Row({ space: 8 }) {
Image($r('app.media.loading'))
.width(16)
.height(16)
.rotate({ angle: this.footerRotateAngle })
.animation({ duration: 800, curve: Curve.Linear, iterations: -1 })
Text('加载中...')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
} else if (this.loadMoreError) {
// 加载失败
Row({ space: 8 }) {
Text('加载失败,点击重试')
.fontSize(13)
.fontColor($r('app.color.primary_color'))
.onClick(() => {
this.loadMore();
})
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
} else if (!this.hasMore && this.dataList.length > 0) {
// 没有更多了
Row({ space: 12 }) {
Divider()
.width(40)
.color($r('app.color.divider_color'))
Text('没有更多了')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
Divider()
.width(40)
.color($r('app.color.divider_color'))
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
}
}
2.3 分页加载的完整流程
// 首次加载
private async loadFirstPage(): Promise<void> {
this.isFirstLoading = true;
this.loadError = '';
try {
const result = await this.fetchData(1, this.pageSize);
this.dataList = result.list;
this.hasMore = result.hasMore;
this.page = 1;
} catch (e) {
this.loadError = e.message || '加载失败';
} finally {
this.isFirstLoading = false;
}
}
// 下拉刷新
private async loadRefresh(): Promise<void> {
try {
const result = await this.fetchData(1, this.pageSize);
this.dataList = result.list;
this.hasMore = result.hasMore;
this.page = 1;
this.loadMoreError = '';
} catch (e) {
promptAction.showToast({
message: '刷新失败:' + (e.message || '未知错误'),
duration: 2000
});
} finally {
this.isRefreshing = false;
}
}
// 上拉加载更多
private async loadMore(): Promise<void> {
if (this.isLoadingMore || !this.hasMore) return;
this.isLoadingMore = true;
this.loadMoreError = '';
try {
const nextPage = this.page + 1;
const result = await this.fetchData(nextPage, this.pageSize);
this.dataList = [...this.dataList, ...result.list];
this.hasMore = result.hasMore;
this.page = nextPage;
} catch (e) {
this.loadMoreError = e.message || '加载失败';
} finally {
this.isLoadingMore = false;
}
}
关键点:
- 防止重复加载:isLoadingMore 为 true 时,不再触发加载
- 数据追加:加载更多是 push 到数组末尾,不是替换
- hasMore 判断:根据后端返回判断是否还有下一页
- 错误处理:加载失败要显示错误,允许用户点击重试
步骤3:列表状态管理
一个完整的列表,有很多状态。怎么管理这些状态,是列表开发的核心。
3.1 列表状态枚举
可以用一个状态变量来表示当前列表的状态:
enum ListState {
Loading = 'loading', // 首次加载中
Success = 'success', // 加载成功(有数据)
Empty = 'empty', // 加载成功但无数据
Error = 'error', // 加载失败
}
@State listState: ListState = ListState.Loading;
@State errorMessage: string = '';
3.2 根据状态渲染不同内容
build() {
Column() {
if (this.listState === ListState.Loading) {
// 加载中:骨架屏
this.buildSkeleton()
} else if (this.listState === ListState.Error) {
// 错误状态
this.buildErrorState()
} else if (this.listState === ListState.Empty) {
// 空状态
this.buildEmptyState()
} else {
// 正常列表
this.buildList()
}
}
.width('100%')
.height('100%')
}
3.3 空状态设计
空状态不是"什么都没有",而是"告诉用户为什么没有,以及该怎么办"。
一个好的空状态应该包含:
- 插画/图标:视觉上的吸引
- 提示文字:说明为什么是空的
- 操作按钮:引导用户去做什么(可选)
@Builder
buildEmptyState() {
Column({ space: 16 }) {
Text('\u{1F4DD}')
.fontSize(64)
Text('还没有收藏的民族')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
Text('去发现更多有趣的民族吧')
.fontSize(14)
.fontColor($r('app.color.text_hint'))
Button('去看看')
.margin({ top: 8 })
.onClick(() => {
router.pushUrl({ url: 'pages/EthnicListPage' });
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
3.4 错误状态设计
错误状态和空状态类似,但重点是"告诉用户出错了,以及怎么重试"。
@Builder
buildErrorState() {
Column({ space: 16 }) {
Text('\u{26A0}\u{FE0F}')
.fontSize(64)
Text('加载失败')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
Text(this.errorMessage || '网络好像不太好,检查一下再试试吧')
.fontSize(14)
.fontColor($r('app.color.text_hint'))
.maxLines(2)
.textAlign(TextAlign.Center)
Button('重新加载')
.margin({ top: 8 })
.onClick(() => {
this.loadFirstPage();
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
💡 空状态和错误状态,是列表的"底线体验"。
数据加载成功时,大家都差不多;
数据加载失败、或者没有数据时,才能看出一个 App 用不用心。
步骤4:骨架屏(Skeleton)
首次加载时,如果直接显示 Loading 转圈,有点"干"。骨架屏(Skeleton)是更好的选择——它用灰色的占位框,模拟出页面的大致结构,用户知道"内容正在加载",而且大概知道长什么样。
4.1 骨架屏 vs 转圈 Loading
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 转圈 Loading | 实现简单 | 体验一般,用户不知道内容长啥样 | 小区域加载、弹窗加载 |
| 骨架屏 | 体验好,有"即将出现"的预期 | 实现稍复杂 | 页面级首次加载、列表加载 |
现在主流的 App,页面级的首次加载,基本都用骨架屏了。
4.2 骨架屏的实现原理
骨架屏的实现很简单:
- 写一个和真实列表结构差不多的"假"列表
- 每个元素都是灰色的占位块
- 加一个"微光扫过"的动画(可选,高级感的来源)
- 数据加载完成后,隐藏骨架屏,显示真实列表
4.3 实战:民族列表骨架屏
「民族图鉴」的民族列表页,首次加载时显示网格骨架屏:
@Component
struct GridSkeleton {
@State shimmerOffset: number = -300;
aboutToAppear(): void {
// 微光动画:从左扫到右,循环
this.startShimmer();
}
private startShimmer(): void {
animateTo({
duration: 1200,
curve: Curve.Linear,
iterations: -1,
playMode: PlayMode.Normal
}, () => {
this.shimmerOffset = 300;
});
}
build() {
Grid() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8], (item: number, index: number) => {
GridItem() {
Column({ space: 8 }) {
// 圆形头像占位
Column()
.width(44)
.height(44)
.borderRadius(22)
.backgroundColor('#F0F0F0')
// 名字占位
Column()
.width(40)
.height(12)
.borderRadius(6)
.backgroundColor('#F0F0F0')
// 人口占位
Column()
.width(30)
.height(10)
.borderRadius(5)
.backgroundColor('#F0F0F0')
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}, (item: number, index: number) => `skeleton_${index}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.padding({ left: 16, right: 16, top: 12 })
// 微光效果:用一个半透明的渐变层扫过
.mask(this.buildShimmerMask())
}
@Builder
buildShimmerMask() {
Column()
.width('100%')
.height('100%')
.backgroundColor(Color.White)
.opacity(0)
}
}
简化版骨架屏(不带微光):
如果觉得微光动画太复杂,也可以做简单的灰色占位,效果也还可以:
@Builder
buildSimpleSkeleton() {
Grid() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8], (_, index: number) => {
GridItem() {
Column({ space: 8 }) {
Circle({ width: 44, height: 44 })
.fill('#F0F0F0')
Rect({ width: 40, height: 12, radius: 6 })
.fill('#F0F0F0')
Rect({ width: 30, height: 10, radius: 5 })
.fill('#F0F0F0')
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}, (_, index: number) => `sk_${index}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.padding(16)
.opacity(0.6)
}
4.4 骨架屏的设计原则
1. 和真实结构一致
骨架屏的结构要和真实列表的结构尽量一致。这样用户才能形成"正确的预期"。
- 列表就做列表形状的骨架
- 网格就做网格形状的骨架
- 卡片就做卡片形状的骨架
2. 不要太"精致"
骨架屏是"占位"的,不是"真实内容"。不要做得太像真的,不然用户会去点——点了没反应,反而困惑。
- 用灰色块,不要有文字
- 轮廓对就行,不要有细节
- 透明度可以低一点,0.5-0.7 之间
3. 加载时间短可以不用
如果数据加载很快(比如 200ms 以内),骨架屏一闪而过,反而会闪一下,体验不好。
这种情况可以加个延时:比如 300ms 内数据回来了,就不显示骨架屏,直接显示内容。
步骤5:实战:民族列表的完整实现
让我们把上面讲的所有东西整合起来,实现一个完整的民族列表页——包括下拉刷新、上拉加载、骨架屏、空状态、错误状态。
// pages/EthnicListPage.ets(简化版)
import router from '@ohos.router';
import { EthnicGroup } from '../models/EthnicModels';
import { StorageService } from '../services/StorageService';
// 列表状态枚举
enum ListState {
Loading = 'loading',
Success = 'success',
Empty = 'empty',
Error = 'error',
}
@Component
export struct EthnicListPage {
// 数据
@State dataList: EthnicGroup[] = [];
@State page: number = 1;
private pageSize: number = 20;
// 状态
@State listState: ListState = ListState.Loading;
@State errorMessage: string = '';
@State isRefreshing: boolean = false;
@State isLoadingMore: boolean = false;
@State hasMore: boolean = true;
@State loadMoreError: string = '';
// 视图
@State viewMode: 'grid' | 'list' = 'grid';
@State searchText: string = '';
aboutToAppear(): void {
this.loadFirstPage();
}
// ========== 数据加载 ==========
private async loadFirstPage(): Promise<void> {
this.listState = ListState.Loading;
this.errorMessage = '';
try {
const result = await this.fetchEthnicList(1, this.pageSize, this.searchText);
this.dataList = result.list;
this.hasMore = result.hasMore;
this.page = 1;
if (this.dataList.length === 0) {
this.listState = ListState.Empty;
} else {
this.listState = ListState.Success;
}
} catch (e) {
this.errorMessage = e.message || '加载失败';
this.listState = ListState.Error;
}
}
private async loadRefresh(): Promise<void> {
try {
const result = await this.fetchEthnicList(1, this.pageSize, this.searchText);
this.dataList = result.list;
this.hasMore = result.hasMore;
this.page = 1;
this.loadMoreError = '';
if (this.dataList.length === 0) {
this.listState = ListState.Empty;
} else {
this.listState = ListState.Success;
}
} catch (e) {
promptAction.showToast({
message: '刷新失败:' + (e.message || '未知错误'),
duration: 2000
});
} finally {
this.isRefreshing = false;
}
}
private async loadMore(): Promise<void> {
if (this.isLoadingMore || !this.hasMore) return;
this.isLoadingMore = true;
this.loadMoreError = '';
try {
const nextPage = this.page + 1;
const result = await this.fetchEthnicList(nextPage, this.pageSize, this.searchText);
this.dataList = [...this.dataList, ...result.list];
this.hasMore = result.hasMore;
this.page = nextPage;
} catch (e) {
this.loadMoreError = e.message || '加载失败';
} finally {
this.isLoadingMore = false;
}
}
// 模拟 API 请求(实际项目中替换成真实 API)
private async fetchEthnicList(page: number, pageSize: number, keyword: string): Promise<{
list: EthnicGroup[];
hasMore: boolean;
}> {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 这里用 mock 数据,实际项目中调 API
const allData = await this.getFilteredData(keyword);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const list = allData.slice(start, end);
const hasMore = end < allData.length;
return { list, hasMore };
}
// ========== UI 渲染 ==========
build() {
Column() {
// 顶部导航栏
this.buildNavBar()
// 搜索栏
this.buildSearchBar()
// 内容区
if (this.listState === ListState.Loading) {
this.buildSkeleton()
} else if (this.listState === ListState.Error) {
this.buildErrorState()
} else if (this.listState === ListState.Empty) {
this.buildEmptyState()
} else {
this.buildRefreshList()
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background_color'))
}
@Builder
buildRefreshList() {
Refresh({ refreshing: $$this.isRefreshing, offset: 80, friction: 42 }) {
Column({ space: 0 }) {
// 筛选栏
this.buildFilterBar()
// 列表/网格
if (this.viewMode === 'grid') {
this.buildGridView()
} else {
this.buildListView()
}
}
.width('100%')
.height('100%')
}
.onRefresh(() => {
this.loadRefresh();
})
}
@Builder
buildGridView() {
Scroll() {
Column() {
Grid() {
LazyForEach(this.dataSource, (ethnic: EthnicGroup, index: number) => {
GridItem() {
this.buildGridCard(ethnic, index)
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.padding({ left: 16, right: 16, top: 12 })
// 底部加载状态
this.buildListFooter()
}
}
.scrollBar(BarState.Off)
.onScrollEdge((side: Edge) => {
if (side === Edge.Bottom) {
if (this.hasMore && !this.isLoadingMore) {
this.loadMore();
}
}
})
}
@Builder
buildListView() {
List() {
LazyForEach(this.dataSource, (ethnic: EthnicGroup, index: number) => {
ListItem() {
this.buildListItem(ethnic, index)
}
}, (ethnic: EthnicGroup) => ethnic.id)
// 底部加载状态
ListItem() {
this.buildListFooter()
}
}
.width('100%')
.layoutWeight(1)
.onReachEnd(() => {
if (this.hasMore && !this.isLoadingMore) {
this.loadMore();
}
})
}
@Builder
buildListFooter() {
if (this.isLoadingMore) {
// 加载中
Row({ space: 8 }) {
Text('\u23F3')
.fontSize(14)
Text('加载中...')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
} else if (this.loadMoreError) {
// 加载失败
Text('加载失败,点击重试')
.fontSize(13)
.fontColor($r('app.color.primary_color'))
.width('100%')
.height(44)
.textAlign(TextAlign.Center)
.onClick(() => {
this.loadMore();
})
} else if (!this.hasMore && this.dataList.length > 0) {
// 没有更多了
Row({ space: 12 }) {
Divider()
.width(40)
.color($r('app.color.divider_color'))
Text('没有更多了')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
Divider()
.width(40)
.color($r('app.color.divider_color'))
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
}
}
@Builder
buildSkeleton() {
Column() {
this.buildFilterBar()
Grid() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8], (_, index: number) => {
GridItem() {
Column({ space: 8 }) {
Circle({ width: 44, height: 44 })
.fill('#F0F0F0')
Rect({ width: 40, height: 12, radius: 6 })
.fill('#F0F0F0')
Rect({ width: 30, height: 10, radius: 5 })
.fill('#F0F0F0')
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}, (_, index: number) => `sk_${index}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.padding({ left: 16, right: 16, top: 12 })
.opacity(0.6)
}
.width('100%')
}
@Builder
buildEmptyState() {
Column({ space: 16 }) {
Text('\u{1F50D}')
.fontSize(64)
Text('没有找到相关民族')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
Text('换个关键词试试吧')
.fontSize(14)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
@Builder
buildErrorState() {
Column({ space: 16 }) {
Text('\u{26A0}\u{FE0F}')
.fontSize(64)
Text('加载失败')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
Text(this.errorMessage || '网络好像不太好')
.fontSize(14)
.fontColor($r('app.color.text_hint'))
.textAlign(TextAlign.Center)
Button('重新加载')
.margin({ top: 8 })
.onClick(() => {
this.loadFirstPage();
})
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
// ... 其他方法省略
}
步骤6:列表性能优化
列表是性能重灾区。列表项多了、每个项复杂了,滑动就会卡。
这里简单列几个关键的优化点,详细的性能优化会在专门的文章里讲。
6.1 用 LazyForEach 而不是 ForEach
ForEach 会一次性渲染所有项,数据多了就卡。LazyForEach 只会渲染可见区域的项,滚动时复用。
// ✅ 好:LazyForEach,按需渲染
List() {
LazyForEach(this.dataSource, (item: EthnicGroup) => {
ListItem() {
// ...
}
}, (item: EthnicGroup) => item.id)
}
// ❌ 不好:ForEach,一次性渲染所有
List() {
ForEach(this.dataList, (item: EthnicGroup) => {
ListItem() {
// ...
}
}, (item: EthnicGroup) => item.id)
}
6.2 列表项尽量简单
每个列表项的层级越少越好。能一层解决的,不要套三层。
// ✅ 好:层级少
ListItem() {
Row({ space: 12 }) {
Image(...)
Column() { Text(...); Text(...) }
Image(...)
}
}
// ❌ 不好:层级深
ListItem() {
Column() {
Row() {
Column() {
Row() {
Image(...)
}
}
}
}
}
6.3 图片设置明确宽高
列表里的图片,如果没有明确宽高,加载完后会"撑开",导致列表跳动、重新布局。
// ✅ 好:明确宽高
Image(item.imageUrl)
.width(56)
.height(56)
.borderRadius(8)
.objectFit(ImageFit.Cover)
// ❌ 不好:不设宽高,靠内容撑开
Image(item.imageUrl)
.objectFit(ImageFit.Cover)
6.4 避免在列表项里做复杂计算
每个列表项的 build 方法里,不要做复杂计算。列表滚动时,每个项都会频繁 build,计算量大了就卡。
// ✅ 好:提前算好,列表项只展示
// 在数据加载时就算好 formattedPopulation
this.dataList = data.map(item => ({
...item,
formattedPopulation: this.formatPopulation(item.population)
}));
// ❌ 不好:每次 build 都算
Text(this.formatPopulation(item.population))
⚠️ 常见问题与解决方案
问题1:下拉刷新抖动、不丝滑
现象:
下拉的时候,列表一顿一顿的,或者刷新结束回弹的时候很突兀。
常见原因及解决方案:
原因1:下拉过程中做了耗时操作
// ❌ 不好:下拉时做复杂计算
.onRefresh(() => {
this.heavyCalculation(); // 耗时操作,会卡
this.loadData();
})
解决:把耗时操作放到异步里,不要阻塞 UI 线程。
原因2:刷新时数据直接替换,导致跳动
下拉刷新返回的数据和当前数据不一样,直接替换的话,列表会"跳一下"。
解决:
- 如果是同一份数据,用 diff 更新,不要全量替换
- 或者刷新时保留当前滚动位置
原因3:摩擦系数不合适
friction 太大(下拉很费劲)或太小(下拉太容易),都会感觉不舒服。
解决:调整 friction 参数,默认 42 是比较舒服的,可以在 30-60 之间试。
问题2:上拉加载闪烁、数据错乱
现象:
- 加载更多时,列表闪一下
- 数据顺序错乱,或者重复
- 快速上拉,加载多次
常见原因及解决方案:
原因1:没有防止重复加载
快速滑动到底部,onReachEnd 触发多次,发起多个请求。
// ❌ 不好:没有防重
onReachEnd(() => {
this.loadMore(); // 可能触发多次
})
解决:加 isLoadingMore 标志位。
// ✅ 好:有防重
onReachEnd(() => {
if (!this.isLoadingMore && this.hasMore) {
this.loadMore();
}
})
原因2:key 不稳定
LazyForEach 的 key 函数返回的值不稳定,导致复用时错乱。
// ❌ 不好:用 index 当 key,数据变化后 index 会变
ForEach(this.list, (item, index) => {
// ...
}, (item, index) => index.toString())
解决:用数据的唯一 id 当 key。
// ✅ 好:用唯一 id
ForEach(this.list, (item) => {
// ...
}, (item) => item.id)
原因3:数据追加方式不对
加载更多时,不是追加,而是替换,导致列表跳回顶部。
// ❌ 不好:直接替换
this.dataList = result.list;
// ✅ 好:追加到末尾
this.dataList = [...this.dataList, ...result.list];
问题3:骨架屏一闪而过
现象:
网络好的时候,数据很快就回来了,骨架屏刚显示出来就消失了,闪一下,反而不舒服。
解决方案:
方案1:加最小显示时间
骨架屏至少显示 300ms,避免闪烁。
private async loadFirstPage(): Promise<void> {
this.listState = ListState.Loading;
const startTime = Date.now();
try {
const result = await this.fetchData();
// 确保骨架屏至少显示 300ms
const elapsed = Date.now() - startTime;
if (elapsed < 300) {
await new Promise(resolve => setTimeout(resolve, 300 - elapsed));
}
// 渲染数据
} catch (e) {
// ...
}
}
方案2:快速加载不显示骨架屏
如果 200ms 内数据回来了,就不显示骨架屏,直接显示内容。
private loadFirstPage(): void {
let showSkeleton = true;
// 200ms 后才显示骨架屏
const timer = setTimeout(() => {
this.listState = ListState.Loading;
showSkeleton = true;
}, 200);
this.fetchData()
.then(result => {
clearTimeout(timer);
// 显示数据
})
.catch(e => {
clearTimeout(timer);
// 显示错误
});
}
问题4:空状态/错误状态下不能下拉刷新
现象:
页面是空状态或者错误状态,用户想下拉刷新,但拉不动——因为 Refresh 组件包着列表,空状态下没有列表,就不能下拉。
解决方案:
把 Refresh 包在最外层,不管什么状态都在 Refresh 里面。
// ✅ 好:Refresh 包在最外层,所有状态都能下拉刷新
Refresh({ refreshing: $$this.isRefreshing }) {
Column() {
if (this.listState === ListState.Loading) {
this.buildSkeleton()
} else if (this.listState === ListState.Error) {
this.buildErrorState()
} else if (this.listState === ListState.Empty) {
this.buildEmptyState()
} else {
this.buildList()
}
}
.width('100%')
.height('100%')
}
.onRefresh(() => {
this.loadRefresh();
})
这样,不管是空状态还是错误状态,用户都能下拉刷新,体验一致。
问题5:加载更多失败后,用户不知道怎么重试
现象:
加载更多失败了,底部只显示"加载失败",用户不知道可以点,也不知道怎么办。
解决方案:
1. 显示"点击重试",并且真的可以点击
if (this.loadMoreError) {
Text('加载失败,点击重试')
.fontSize(13)
.fontColor($r('app.color.primary_color'))
.onClick(() => {
this.loadMore();
})
}
2. 颜色区分,让用户知道这是可点击的
用主题色,而不是灰色,暗示"这是可以点的"。
3. 可以加个重试图标
加个刷新图标在旁边,更直观。
📝 本章小结
核心知识点
本文从列表交互的完整形态讲起,系统介绍了下拉刷新、上拉加载、骨架屏等内容:
1. 列表的 8 种状态
- 首次加载中 → 骨架屏
- 加载成功(有数据)→ 正常列表
- 加载成功(无数据)→ 空状态
- 加载失败 → 错误状态
- 下拉刷新中 → 顶部刷新指示器
- 上拉加载中 → 底部 Loading
- 加载更多失败 → 底部错误提示
- 没有更多 → 底部"没有更多了"
2. 下拉刷新(Refresh)
- Refresh 组件的基本用法
- 自定义刷新指示器
- 阻尼效果、释放反馈、回弹动画
3. 上拉加载更多
- onReachEnd 事件触发
- 分页加载的完整流程
- 底部三种状态:加载中 / 失败 / 没有更多
- 防止重复加载
4. 骨架屏(Skeleton)
- 骨架屏 vs Loading 转圈
- 实现原理:灰色占位 + 微光动画
- 设计原则:结构一致、不要太精致、快速加载可以不用
5. 空状态与错误状态
- 空状态:插画 + 提示 + 引导操作
- 错误状态:错误信息 + 重试按钮
6. 列表性能优化
- 用 LazyForEach 代替 ForEach
- 列表项层级尽量少
- 图片设置明确宽高
- 避免在 build 里做复杂计算
最佳实践总结
✅ 8 种状态一个都不能少
首次加载、加载成功(有/无数据)、加载失败、
下拉刷新中、上拉加载中、加载更多失败、没有更多
少了任何一个,用户在某个场景下就会懵。
✅ 骨架屏比转圈体验好
页面级首次加载,用骨架屏。
小区域加载、弹窗加载,用转圈。
骨架屏让用户有预期,知道"内容马上就来"。
✅ 空状态/错误状态要有引导
不要只说"没有数据"、"加载失败"。
告诉用户"为什么没有"、"该怎么办"。
加个按钮,引导用户去操作。
✅ 下拉刷新随时可用
Refresh 包在最外层,
空状态、错误状态下也能下拉刷新。
用户的习惯是:出问题了就下拉试试。
✅ 防止重复加载
isLoadingMore 标志位一定要有。
不然用户快速滑到底部,会发好几个请求。
数据错乱、顺序重复,都是这么来的。
✅ 性能优化从第一天开始
不要等卡了再优化,一开始就按高性能的方式写。
LazyForEach、明确的 key、简单的列表项...
这些都是举手之劳,但效果显著。
下一步预告
在下一篇文章中,我们将:
- 🖼️ 学习 Image 组件的进阶用法
- 🌐 理解网络图片加载的完整流程
- 💾 掌握三级缓存策略:内存缓存 → 磁盘缓存 → 网络
- 🎨 实现占位图、错误图、加载动画
- 📐 学习图片的尺寸优化与压缩
- 🚀 用「民族封面图的加载优化」实战,带你掌握图片加载与缓存的精髓
🔗 相关链接
- 项目源码: GitCode 仓库
- Refresh 组件: 官方文档
- List 组件: 官方文档
- LazyForEach: 官方文档
- Scroll 组件: 官方文档
💡 提示:列表交互是一个"细节决定成败"的领域。技术实现都不难,但阻尼系数差 5、阈值差 10、动画时长差 50ms,体验就完全不一样。最好的学习方法就是多玩——玩微信、玩小红书、玩抖音,注意它们的列表怎么刷新、怎么加载更多、空状态长什么样、加载失败了怎么办。玩得多了,你自然就知道"好的列表交互"是什么感觉。
更多推荐


所有评论(0)