在这里插入图片描述

📖 引言

列表是移动应用中最常见的界面形态。

从新闻列表、商品列表、聊天列表,到「民族图鉴」的民族列表……几乎每个 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),默认 80
  • friction:下拉摩擦系数,越大越难拉,默认 42
  • onRefresh:刷新触发回调

基本流程

  1. 用户下拉列表
  2. 下拉距离超过 offset,触发 onRefresh
  3. isRefreshing 设为 true,显示刷新指示器
  4. 数据加载完成后,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 实现原理

上拉加载的原理很简单:

  1. 监听列表滚动
  2. 当滚动到接近底部时(比如还剩 5 项),触发加载
  3. 加载下一页数据,追加到列表末尾
  4. 如果没有下一页了,显示"没有更多了"

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;
  }
}

关键点

  1. 防止重复加载:isLoadingMore 为 true 时,不再触发加载
  2. 数据追加:加载更多是 push 到数组末尾,不是替换
  3. hasMore 判断:根据后端返回判断是否还有下一页
  4. 错误处理:加载失败要显示错误,允许用户点击重试

步骤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 空状态设计

空状态不是"什么都没有",而是"告诉用户为什么没有,以及该怎么办"。

一个好的空状态应该包含:

  1. 插画/图标:视觉上的吸引
  2. 提示文字:说明为什么是空的
  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 骨架屏的实现原理

骨架屏的实现很简单:

  1. 写一个和真实列表结构差不多的"假"列表
  2. 每个元素都是灰色的占位块
  3. 加一个"微光扫过"的动画(可选,高级感的来源)
  4. 数据加载完成后,隐藏骨架屏,显示真实列表
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 组件的进阶用法
  • 🌐 理解网络图片加载的完整流程
  • 💾 掌握三级缓存策略:内存缓存 → 磁盘缓存 → 网络
  • 🎨 实现占位图、错误图、加载动画
  • 📐 学习图片的尺寸优化与压缩
  • 🚀 用「民族封面图的加载优化」实战,带你掌握图片加载与缓存的精髓

🔗 相关链接


💡 提示:列表交互是一个"细节决定成败"的领域。技术实现都不难,但阻尼系数差 5、阈值差 10、动画时长差 50ms,体验就完全不一样。最好的学习方法就是多玩——玩微信、玩小红书、玩抖音,注意它们的列表怎么刷新、怎么加载更多、空状态长什么样、加载失败了怎么办。玩得多了,你自然就知道"好的列表交互"是什么感觉。

Logo

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

更多推荐