任何一个信息流产品都绕不开列表。本文从零构建一个技术文章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.datadata;this.hasMorehasMore;}}exportfunctionfetchArticles(page:number,pageSize:number):PromisePageResult{returnnewPromise((resolve){setTimeout((){// 模拟总数据18条每次取6条consttotalAvailable18;conststart(page-1)*pageSize;constendMath.min(startpageSize,totalAvailable);// ... 生成从 start 到 end 的数据resolve(newPageResult(data,endtotalAvailable));},800);});}设计要点hasMore是必须的。客户端需要知道是否还有下一页才能决定加载更多按钮的显示文案“上拉加载” vs “已经到底了”。用Promise模拟网络请求。setTimeout(800ms)制造了一个真实的等待感让你能看清 Loading 状态。总数据 18 条每页 6 条 正好 3 页。第 3 页加载完后hasMore变为false。三、页面状态管理 — 交互点1三态视图这是本文最核心的设计。一个列表页面有四种可能的状态enumPageState{LOADING,// 首次进入数据加载中CONTENT,// 加载成功展示列表ERROR,// 加载失败EMPTY// 加载成功但数据为空}为什么需要显式的状态枚举因为状态决定了整个页面的渲染分支build(){Column(){if(this.pageStatePageState.LOADING){this.LoadingView()// 旋转加载动画 正在加载...}elseif(this.pageStatePageState.ERROR){this.ErrorView()// 错误图标 提示文字 重试按钮}elseif(this.pageStatePageState.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.pageStatePageState.LOADING;this.currentPage1;fetchArticles(1,6).then(res{this.articlesres.data;this.hasMoreres.hasMore;// 关键判断data为空 ≠ 请求失败this.pageStatethis.articles.length0?PageState.EMPTY:PageState.CONTENT;}).catch((){this.pageStatePageState.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.isRefreshingtrue;this.currentPage1;// 重置到第1页fetchArticles(1,6).then(res{this.articlesres.data;// 直接替换不是追加this.hasMoreres.hasMore;this.isRefreshingfalse;// 关闭刷新动画promptAction.showToast({message:刷新成功,duration:1000});}).catch((){this.isRefreshingfalse;// 失败也要关闭动画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.isLoadingMoretrue;constnextPagethis.currentPage1;fetchArticles(nextPage,6).then(res{this.articlesthis.articles.concat(res.data);// 追加不是替换this.hasMoreres.hasMore;this.currentPagenextPage;this.isLoadingMorefalse;});}三个防护点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 → isRefreshingtrue → 重新请求 → 替换数据 → 用户滑到底 → onReachEnd → isLoadingMoretrue → 追加数据 → 追加到最后一页 → hasMorefalse → 底部显示已经到底了九、常见面试题 / 踩坑点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 的旋转动画替换成灰色占位骨架缓存策略— 首次加载优先展示缓存后台静默刷新