鸿蒙ArkUI实战:分页加载与列表状态管理
任何一个信息流产品都绕不开列表。本文从零构建一个"技术文章Feed",深入讲解下拉刷新、上拉加载更多、以及 Loading/Error/Empty 三种页面状态的完整实现方案。
一、我们要做什么
一个文章信息流页面,具备完整的列表交互能力:
- 三态切换 — 进入页面时显示 Loading 动画 → 数据加载成功展示列表 → 加载失败显示重试按钮 → 无数据显示空状态
- 下拉刷新 — 下拉触发数据重置,刷新到最新内容
- 上拉加载更多 — 滑动到底部自动加载下一页,滑到底时显示"已经到底了"
这些功能看似基础,但真实项目中每一处都有细节需要注意。本文会展开讲清楚每个设计决策。
二、数据模型设计
2.1 文章实体
export class ArticleItem {
id: number;
title: string; // 标题
summary: string; // 摘要
author: string; // 作者
publishTime: string; // 发布时间
readCount: number; // 阅读数
likeCount: number; // 点赞数
coverColor: string; // 左侧色条颜色(视觉区分)
}
每个字段都有明确的用途。coverColor 是UI层面的优化 —— 用不同颜色条在视觉上区分不同文章,而不是千篇一律的灰色卡片。这个字段和数据业务无关,属于展示层增强。
2.2 分页返回结果
fetchArticles 是模拟的 API 函数,返回值不是简单的 ArticleItem[]:
export class PageResult {
data: ArticleItem[];
hasMore: boolean; // 关键字段:是否还有下一页
constructor(data: ArticleItem[], hasMore: boolean) {
this.data = data;
this.hasMore = hasMore;
}
}
export function fetchArticles(page: number, pageSize: number): Promise<PageResult> {
return new Promise((resolve) => {
setTimeout(() => {
// 模拟总数据18条,每次取6条
const totalAvailable = 18;
const start = (page - 1) * pageSize;
const end = Math.min(start + pageSize, totalAvailable);
// ... 生成从 start 到 end 的数据
resolve(new PageResult(data, end < totalAvailable));
}, 800);
});
}
设计要点:
hasMore是必须的。客户端需要知道是否还有下一页,才能决定"加载更多"按钮的显示文案(“上拉加载” vs “已经到底了”)。- 用
Promise模拟网络请求。setTimeout(800ms)制造了一个真实的等待感,让你能看清 Loading 状态。 - 总数据 18 条,每页 6 条 = 正好 3 页。第 3 页加载完后
hasMore变为false。

三、页面状态管理 — 交互点1:三态视图
这是本文最核心的设计。一个列表页面有四种可能的状态:
enum PageState {
LOADING, // 首次进入,数据加载中
CONTENT, // 加载成功,展示列表
ERROR, // 加载失败
EMPTY // 加载成功但数据为空
}
为什么需要显式的状态枚举?因为状态决定了整个页面的渲染分支:
build() {
Column() {
if (this.pageState === PageState.LOADING) {
this.LoadingView() // 旋转加载动画 + "正在加载..."
} else if (this.pageState === PageState.ERROR) {
this.ErrorView() // 错误图标 + 提示文字 + 重试按钮
} else if (this.pageState === PageState.EMPTY) {
this.EmptyView() // 空盒子图标 + "暂无内容"
} else {
// PageState.CONTENT → 渲染列表
Refresh({...}) { List() {...} }
}
}
}
3.1 Loading 状态
最简单的状态,一个旋转进度条 + 提示文字:
@Builder
LoadingView() {
Column() {
LoadingProgress()
.width(48).height(48)
.color(AppColors.PRIMARY)
Text('正在加载...')
.fontSize(FontSize.BODY)
.fontColor(AppColors.TEXT_TERTIARY)
.margin({ top: Spacing.MD })
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
}

3.2 Error 状态
错误状态的核心是可恢复性。不能只展示一句"网络错误",必须给用户一个操作入口:
@Builder
ErrorView() {
Column() {
Image($r('sys.symbol.exclamationmark_triangle'))
.width(56).height(56)
.fillColor(AppColors.ERROR)
Text('加载失败,请检查网络')
.fontSize(FontSize.MEDIUM)
.fontColor(AppColors.TEXT_SECONDARY)
.margin({ top: Md })
Button('点击重试')
.fontSize(Body)
.fontColor(Color.White)
.backgroundColor(AppColors.PRIMARY)
.borderRadius(FULL)
.margin({ top: Lg })
.onClick(() => this.loadFirstPage()) // 关键:重新发起请求
}
}
点击"重试"按钮调用 loadFirstPage(),重置页码并重新发起请求,状态回到 LOADING。
3.3 Empty 状态
区别于 Error。Error 是网络/服务端故障,Empty 是请求成功但数据为空:
@Builder
EmptyView() {
Column() {
Image($r('sys.symbol.archivebox'))
.width(56).height(56)
.fillColor(AppColors.TEXT_DISABLED)
Text('暂无内容')
.fontSize(FontSize.MEDIUM)
.fontColor(AppColors.TEXT_TERTIARY)
.margin({ top: Md })
}
}
3.4 状态判断逻辑
在 loadFirstPage() 中,根据返回结果设置状态:
private loadFirstPage(): void {
this.pageState = PageState.LOADING;
this.currentPage = 1;
fetchArticles(1, 6).then(res => {
this.articles = res.data;
this.hasMore = res.hasMore;
// 关键判断:data为空 ≠ 请求失败
this.pageState = this.articles.length === 0 ? PageState.EMPTY : PageState.CONTENT;
}).catch(() => {
this.pageState = PageState.ERROR;
});
}
这里的逻辑链条很清晰:
- 进入方法 →
LOADING .then()成功 →CONTENT或EMPTY.catch()失败 →ERROR
四、交互点2:下拉刷新
ArkUI 的 Refresh 组件提供了原生风格的下拉刷新:
Refresh({ refreshing: $$this.isRefreshing }) {
List() {
ForEach(this.articles, (article: ArticleItem) => {
ListItem() { this.ArticleCard(article) }
})
ListItem() { this.LoadMoreFooter() } // 加载更多在列表内部
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.onReachEnd(() => this.onLoadMore())
.layoutWeight(1)
}
.onRefreshing(() => {
this.onRefresh(); // 触发刷新逻辑
})
刷新逻辑:
private onRefresh(): void {
this.isRefreshing = true;
this.currentPage = 1; // 重置到第1页
fetchArticles(1, 6).then(res => {
this.articles = res.data; // 直接替换,不是追加
this.hasMore = res.hasMore;
this.isRefreshing = false; // 关闭刷新动画
promptAction.showToast({ message: '刷新成功', duration: 1000 });
}).catch(() => {
this.isRefreshing = false; // 失败也要关闭动画!
promptAction.showToast({ message: '刷新失败', duration: 1000 });
});
}
两个关键细节:
- 刷新时重置数据 —
this.articles = res.data是替换而非追加。currentPage回到 1。 - 失败也必须关闭动画 —
.catch()里this.isRefreshing = false不能漏。否则失败后刷新动画不会消失,页面会卡住。
五、交互点3:上拉加载更多
5.1 触发条件
List 组件的 onReachEnd 回调在滑动到底部时触发。但实际触发加载还需要满足条件:
private onLoadMore(): void {
if (this.isLoadingMore || !this.hasMore) return; // 防止重复请求
this.isLoadingMore = true;
const nextPage = this.currentPage + 1;
fetchArticles(nextPage, 6).then(res => {
this.articles = this.articles.concat(res.data); // 追加,不是替换
this.hasMore = res.hasMore;
this.currentPage = nextPage;
this.isLoadingMore = false;
});
}
三个防护点:
this.isLoadingMore— 正在加载时不再触发!this.hasMore— 没有下一页时不再请求concat追加数据 — 和刷新的=替换不同
5.2 底部状态指示器
列表底部的文字根据状态变化:
@Builder
LoadMoreFooter() {
Row() {
if (this.isLoadingMore) {
LoadingProgress().width(18).height(18)
Text('加载中...') // 正在加载
} else if (this.hasMore) {
Text('上拉加载更多') // 还有数据
} else {
Text('— 已经到底了 —') // 全部加载完
}
}
.width('100%').height(52)
.justifyContent(FlexAlign.Center)
}
三种文案对应三种状态,用户一目了然。
六、文章卡片设计
每张卡片是一个 ListItem,使用 Row 横排布局,左侧色条 + 右侧内容:
@Builder
ArticleCard(article: ArticleItem) {
Row() {
// 左侧4px色条,视觉区分不同文章
Row()
.width(4)
.height('100%')
.backgroundColor(article.coverColor)
.borderRadius(2)
Column() {
// 标题(最多2行,超出省略号)
Text(article.title)
.fontSize(16).fontWeight(Bold)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 摘要(最多2行)
Text(article.summary)
.fontSize(14).fontColor(TEXT_SECONDARY)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 底部:作者 · 时间 | 阅读数 | 点赞数
Row() {
Text(article.author).fontColor(PRIMARY)
Text(' · ')
Text(article.publishTime).fontColor(TERTIARY)
Blank()
Image($r('sys.symbol.eye')).width(14).height(14)
Text(`${article.readCount}`)
Image($r('sys.symbol.heart')).width(14).height(14)
Text(`${article.likeCount}`)
}
}
.layoutWeight(1).margin({ left: 12 })
}
.padding(16).backgroundColor(WHITE)
.borderRadius(8)
.margin({ left: 16, right: 16, top: 8, bottom: 8 })
}
几个设计细节:
- 色条 4px宽 — 给卡片增加视觉节奏感,比纯白卡片更有辨识度
- 标题和摘要都用
maxLines(2)— 防止过长内容撑破布局 - 底部信息栏用
Blank()撑开 — 作者信息靠左,阅读/点赞数据靠右
七、代码结构
entry/src/main/ets/
├── common/
│ └── Constants.ets # AppColors, Spacing, FontSize, BorderRadius
├── model/
│ └── FeedModel.ets # ArticleItem + PageResult + fetchArticles()
└── pages/
├── Index.ets # 入口页(按钮导航到各Demo)
├── ProductListPage.ets # 上一篇:商品列表页面
└── FeedPage.ets # 本篇核心:Feed流(~200行)
核心页面约 200 行,单一文件自包含。没有引入第三方依赖。
八、页面状态的完整流转
以时间线方式梳理状态的切换过程:
用户打开页面
→ LOADING (旋转动画)
→ 请求成功且有数据 → CONTENT (展示列表)
→ 请求成功但无数据 → EMPTY (空状态提示)
→ 请求失败 → ERROR (错误提示+重试按钮)
在 CONTENT 状态下:
→ 用户下拉 → 触发 onRefresh → isRefreshing=true → 重新请求 → 替换数据
→ 用户滑到底 → onReachEnd → isLoadingMore=true → 追加数据
→ 追加到最后一页 → hasMore=false → 底部显示"已经到底了"
九、常见面试题 / 踩坑点
9.1 下拉刷新时为什么要用 = 而不是 concat?
刷新意味着"获取最新数据",应该替换旧数据。如果用 concat,刷新一次就多出 6 条重复数据。
9.2 为什么 hasMore 要后端返回而不是客户端计算?
客户端可以猜(articles.length >= totalCount),但 totalCount 本身就是后端返回的。最简单的方案是后端直接给 hasMore,客户端不做多余判断。
9.3 onReachEnd 会触发多次怎么办?
用 isLoadingMore 锁。第一次触发后设为 true,请求完成后才恢复 false。加上 !hasMore 的判断,双重防护。
9.4 Error 和 Empty 有什么区别?
Error = 网络/服务端出问题,用户需要"重试"。
Empty = 请求成功了,但就是没数据,不需要重试。
两者的提示文案和行为完全不同。
十、运行方式
代码位于 dev/entry/src/main/ets/:
| 文件 | 用途 |
|---|---|
model/FeedModel.ets |
文章实体 + 分页数据源 |
pages/FeedPage.ets |
文章Feed流主页面 |
用 DevEco Studio 打开 dev/ 项目,首页点击"文章Feed — 分页加载刷新"即可体验:
- 进入页面 → 看到 Loading 动画(800ms延迟)
- 数据出现 → 下拉试试刷新
- 滑到底 → 自动加载更多
- 总共 3 页(18条),第 3 页后显示"已经到底了"
十一、扩展方向
本文的基础架构可以直接扩展:
- 真实网络请求 — 把
fetchArticles替换为http.createHttp().request() - 图片懒加载 — 在卡片中加上封面图,配合
Image的onComplete事件 - 骨架屏 — 把 Loading 的旋转动画替换成灰色占位骨架
- 缓存策略 — 首次加载优先展示缓存,后台静默刷新
更多推荐



所有评论(0)