任何一个信息流产品都绕不开列表。本文从零构建一个"技术文章Feed",深入讲解下拉刷新、上拉加载更多、以及 Loading/Error/Empty 三种页面状态的完整实现方案。


一、我们要做什么

一个文章信息流页面,具备完整的列表交互能力:

  1. 三态切换 — 进入页面时显示 Loading 动画 → 数据加载成功展示列表 → 加载失败显示重试按钮 → 无数据显示空状态
  2. 下拉刷新 — 下拉触发数据重置,刷新到最新内容
  3. 上拉加载更多 — 滑动到底部自动加载下一页,滑到底时显示"已经到底了"

这些功能看似基础,但真实项目中每一处都有细节需要注意。本文会展开讲清楚每个设计决策。


二、数据模型设计

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() 成功 → CONTENTEMPTY
  • .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 });
  });
}

两个关键细节:

  1. 刷新时重置数据this.articles = res.data 是替换而非追加。currentPage 回到 1。
  2. 失败也必须关闭动画.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 — 分页加载刷新"即可体验:

  1. 进入页面 → 看到 Loading 动画(800ms延迟)
  2. 数据出现 → 下拉试试刷新
  3. 滑到底 → 自动加载更多
  4. 总共 3 页(18条),第 3 页后显示"已经到底了"

十一、扩展方向

本文的基础架构可以直接扩展:

  • 真实网络请求 — 把 fetchArticles 替换为 http.createHttp().request()
  • 图片懒加载 — 在卡片中加上封面图,配合 ImageonComplete 事件
  • 骨架屏 — 把 Loading 的旋转动画替换成灰色占位骨架
  • 缓存策略 — 首次加载优先展示缓存,后台静默刷新
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐