任何一个信息流产品都绕不开列表。本文从零构建一个"技术文章Feed",深入讲解下拉刷新、上拉加载更多、以及 Loading/Error/Empty 三种页面状态的完整实现方案。
一、我们要做什么
一个文章信息流页面,具备完整的列表交互能力:
- 三态切换— 进入页面时显示 Loading 动画 → 数据加载成功展示列表 → 加载失败显示重试按钮 → 无数据显示空状态
- 下拉刷新— 下拉触发数据重置,刷新到最新内容
- 上拉加载更多— 滑动到底部自动加载下一页,滑到底时显示"已经到底了"
这些功能看似基础,但真实项目中每一处都有细节需要注意。本文会展开讲清楚每个设计决策。
二、数据模型设计
2.1 文章实体
exportclassArticleItem{id:number;title:string;// 标题summary:string;// 摘要author:string;// 作者publishTime:string;// 发布时间readCount:number;// 阅读数likeCount:number;// 点赞数coverColor:string;// 左侧色条颜色(视觉区分)}每个字段都有明确的用途。coverColor是UI层面的优化 —— 用不同颜色条在视觉上区分不同文章,而不是千篇一律的灰色卡片。这个字段和数据业务无关,属于展示层增强。
2.2 分页返回结果
fetchArticles是模拟的 API 函数,返回值不是简单的ArticleItem[]:
exportclassPageResult{data:ArticleItem[];hasMore:boolean;// 关键字段:是否还有下一页constructor(data:ArticleItem[],hasMore:boolean){this.data=data;this.hasMore=hasMore;}}exportfunctionfetchArticles(page:number,pageSize:number):Promise<PageResult>{returnnewPromise((resolve)=>{setTimeout(()=>{// 模拟总数据18条,每次取6条consttotalAvailable=18;conststart=(page-1)*pageSize;constend=Math.min(start+pageSize,totalAvailable);// ... 生成从 start 到 end 的数据resolve(newPageResult(data,end<totalAvailable));},800);});}设计要点:
hasMore是必须的。客户端需要知道是否还有下一页,才能决定"加载更多"按钮的显示文案(“上拉加载” vs “已经到底了”)。- 用
Promise模拟网络请求。setTimeout(800ms)制造了一个真实的等待感,让你能看清 Loading 状态。 - 总数据 18 条,每页 6 条= 正好 3 页。第 3 页加载完后
hasMore变为false。
三、页面状态管理 — 交互点1:三态视图
这是本文最核心的设计。一个列表页面有四种可能的状态:
enumPageState{LOADING,// 首次进入,数据加载中CONTENT,// 加载成功,展示列表ERROR,// 加载失败EMPTY// 加载成功但数据为空}为什么需要显式的状态枚举?因为状态决定了整个页面的渲染分支:
build(){Column(){if(this.pageState===PageState.LOADING){this.LoadingView()// 旋转加载动画 + "正在加载..."}elseif(this.pageState===PageState.ERROR){this.ErrorView()// 错误图标 + 提示文字 + 重试按钮}elseif(this.pageState===PageState.EMPTY){this.EmptyView()// 空盒子图标 + "暂无内容"}else{// PageState.CONTENT → 渲染列表Refresh({...}){List(){...}}}}}3.1 Loading 状态
最简单的状态,一个旋转进度条 + 提示文字:
@BuilderLoadingView(){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 状态
错误状态的核心是可恢复性。不能只展示一句"网络错误",必须给用户一个操作入口:
@BuilderErrorView(){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 是请求成功但数据为空:
@BuilderEmptyView(){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()中,根据返回结果设置状态:
privateloadFirstPage():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();// 触发刷新逻辑})刷新逻辑:
privateonRefresh():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回调在滑动到底部时触发。但实际触发加载还需要满足条件:
privateonLoadMore():void{if(this.isLoadingMore||!this.hasMore)return;// 防止重复请求this.isLoadingMore=true;constnextPage=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 底部状态指示器
列表底部的文字根据状态变化:
@BuilderLoadMoreFooter(){Row(){if(this.isLoadingMore){LoadingProgress().width(18).height(18)Text('加载中...')// 正在加载}elseif(this.hasMore){Text('上拉加载更多')// 还有数据}else{Text('— 已经到底了 —')// 全部加载完}}.width('100%').height(52).justifyContent(FlexAlign.Center)}三种文案对应三种状态,用户一目了然。
六、文章卡片设计
每张卡片是一个ListItem,使用Row横排布局,左侧色条 + 右侧内容:
@BuilderArticleCard(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.3onReachEnd会触发多次怎么办?
用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 的旋转动画替换成灰色占位骨架
- 缓存策略— 首次加载优先展示缓存,后台静默刷新