LoadingProgress 加载指示器完全指南:异步反馈、状态管理与重试机制

本文基于 HarmonyOS(ArkTS 声明式开发范式,API 12 / 5.0.0)写作,所有示例均可在 DevEco Studio 模拟器中验证。配套演示工程位于本文同级目录 ohos/,包含完整可运行的 EntryAbility.etsIndex.ets


一、引言

打开新闻 App 的那一秒,你最怕看到什么?不是广告,而是一片"死寂"——页面毫无反应,你不知道它是在加载、是卡死了、还是数据已经出错。这个问题的答案,藏在一个转动的圈里:LoadingProgress 加载指示器。

异步操作是移动应用的常态:请求新闻列表、上传图片、刷新订单状态,每一项都要花时间。而"花时间"这件事本身需要被看见——用户心理学上有个朴素的结论:无法预估的等待最令人焦虑。加载指示器就是那根"确定性"的锚:它告诉用户"系统在工作,请稍候",从而把用户的注意力从"是不是坏了"转移到"快好了"。这正是它在交互体系里不可替代的价值:LoadingProgress 存在的意义不是装饰,而是消除不确定性

ArkUI 的 LoadingProgress 是三者中"最简单"的组件:无构造参数、无内置文案、没有进度数值——它是一个不确定进度的循环动画,只表达"正在加载中"这一件事。它的全部定制点只有两个:color 控制颜色,通用的 width/height 控制大小。真正复杂的是它的显示与隐藏时机:请求发出的一刻显示,请求结束的一刻消失;而支撑这个时机的,是一套完整的加载状态管理——idleloadingsuccesserror 四种状态加上重试机制。

本文的路线是:先讲透 LoadingProgress 的 API 与显示时机,用状态机图说明"四态流转"的完整逻辑,再给出完整演示工程,覆盖数据加载(加载/成功/失败/重试)、样式配置、状态管理与竞态处理三个场景,最后谈模拟器验证、常见问题与加载反馈的最佳实践。读完你应当能独立设计任何异步页面的"加载 → 结果"骨架。


二、环境准备

LoadingProgress 属于 ArkUI 基础组件,API 8 起提供,且没有版本依赖的进阶特性,API 12 环境下可放心使用全部能力。

项目 推荐配置 说明
DevEco Studio 5.0 及以上 需支持 API 12 的 SDK
HarmonyOS SDK 5.0.0(12) compatibleSdkVersion 与之对应
设备 Phone 模拟器或真机 本文以模拟器验证为主
工程类型 Stage 模型 + ArkTS EntryAbility 继承 UIAbility

工程落地路径与前几篇一致,两种方式任选:

  • 方式一:在 DevEco Studio 新建 Empty Ability 工程,直接写 ArkTS 原生页面。本文演示工程即采用这种方式。
  • 方式二:在已有的 Flutter·鸿蒙壳工程里,把 ohos/entry/src/main/ets/ 下的页面与组件放进原生工程。这种方式下 EntryAbility 通常继承自 FlutterAbility,演示组件的代码不受影响。

本文配套工程目录结构如下(关键文件已给出):

ohos/
├── AppScope/app.json5
├── build-profile.json5
└── entry/src/main/
    ├── module.json5
    └── ets/
        ├── entryability/EntryAbility.ets
        ├── pages/Index.ets
        ├── model/NewsModel.ets
        └── components/*.ets

若你用的是方式二(Flutter 壳),只需关注 pages/Index.etsmodel/NewsModel.etscomponents/ 下的组件代码,其余配置沿用原工程即可。


三、核心 API 与原理解析

3.1 构造与样式:无参构造、color 与尺寸

LoadingProgress 的构造非常简单:

LoadingProgress()
  .width(48)          // 通用属性控制大小
  .height(48)
  .color('#0A59F7')   // 指示器颜色
配置项 方式 说明
构造参数 不接受任何参数
大小 width/height 通用属性,建议等宽等高(正方形)
颜色 color 单色即可,动画渐变由系统完成
文案 需自行配合 Text 说明加载意图

两个容易忽略的点:其一,LoadingProgress 没有内置文字,“正在加载"四个字要自己在旁边放一个 Text,这既是自由也是责任——文案必须与加载语义一致,别在转圈的时候写"请点击按钮”;其二,它表达的是不确定进度,没有 value/total 这类数值,动画是循环的。确定百分比该用 Progress(线性/环形),两者分工明确,见 3.4 对比表。

3.2 显示与隐藏时机:状态驱动的唯一法则

LoadingProgress 本身不会自动出现或消失,它的显隐完全由业务状态决定。演示工程里用 if 分支切换四种界面形态:

if (this.status === 'idle') {
  // 初始引导:按钮 + 文案
} else if (this.status === 'loading') {
  LoadingProgress().width(48).height(48).color('#0A59F7')
  Text('正在加载新闻,请稍候…')
} else if (this.status === 'success') {
  // 内容列表
} else {
  // 错误提示 + 重试按钮
}

时机的铁律只有两条:请求发出的一刻必须切到 loading(哪怕快得只有一帧);请求结束的一刻必须切走 loading(成功、失败、超时都算结束)。违反任意一条,用户就会面对"转圈卡死"或"内容凭空出现"两种糟糕体验。至于"请求快到转圈一闪而过要不要处理",那是体验细节,见 3.5。

3.3 状态机:idle → loading → success/error

把显示时机抽象出来,就是一个标准四态状态机:

点击加载

请求成功

请求失败

点击重试

再次加载

idle

loading

success

error

这个状态机的纪律性在于非法迁移必须被禁止loading 中不能再次进入 loading(防重复点击);idle 不能直接变 success(没有请求就没有结果)。演示工程的 StateManageDemo 把四个状态做成手动流转按钮,配合状态徽标实时展示,正是为了直观验证这些规则。

3.4 与相关组件的选型对比

组件 进度确定性 交互形态 适用场景
LoadingProgress 不确定 循环动画 加载中、提交中、任何未知耗时的等待
Progress 确定 线性/环形/胶囊 下载、上传、安装等可计算百分比
骨架屏 不确定 占位色块 内容轮廓已知时的首屏加载
Refresh 不确定 下拉动画 列表下拉刷新
自定义动画 视设计 任意 品牌化加载动效

选型一句话:"不知道要多久"用 LoadingProgress,"知道做到哪了"用 Progress,"知道大概长什么样"用骨架屏

3.5 显示时机的两个进阶细节

最小展示时长。 请求 30 毫秒就返回时,转圈一闪而过反而造成"闪屏"的廉价感。常见做法是给 loading 态一个最短展示时间(如 400ms),与真实耗时取较大值后再切走。代价是整体感知变慢,仅当闪屏明显时采用。

防重复触发。 加载期间按钮应禁用或隐藏,否则用户连点三次会发出三个请求——状态机里"loading 中禁止再进 loading"就是为此设计的。实现上,除了按钮 enabled 限制,还要在逻辑入口加状态守卫(见 4.6)。

3.6 竞态:谁的结果说了算

异步还有一个隐蔽的坑:请求返回的顺序不一定等于发出的顺序。用户先点"加载 A",等不及又触发"加载 B",A 的响应可能反而后到,把新数据覆盖成旧数据——这叫竞态(race condition)。解法是给每次请求编号,响应回来时比对编号,只有最新请求的响应被采纳

[
\text{采纳条件} \quad \text{seq}{响应} = \max(\text{seq}{已发出})
]

演示工程的 StateManageDemorequestSeq 实现了这套比对,快速连点三次可以现场看到"过期请求被丢弃"的 Toast。重试的时序设计上,如果连续失败,配合指数退避可以避免请求风暴:

[
t_{n} = t_{0} \cdot 2^{,n-1}, \quad n = 1,2,3,\dots
]

第一次失败等 1 秒重试,第二次等 2 秒,第三次等 4 秒——既给了网络恢复时间,又不至于让用户觉得"重试也没用"。


四、完整代码实现

下面给出演示工程的完整可运行代码。工程以 Tabs 组织三个模块:数据加载(核心场景)、样式配置、状态管理。数据模型 NewsModel.ets 提供模拟新闻列表。

4.1 入口:EntryAbility.ets

import { UIAbility } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  private readonly TAG: string = 'LoadingProgressGuideAbility';

  onCreate(want: object, launchParam: object): void {
    hilog.info(0x0000, this.TAG, '%{public}s', 'Ability onCreate');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, this.TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(0x0000, this.TAG, '%{public}s', 'Succeeded in loading the content.');
    });
  }

  onForeground(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onForeground'); }
  onBackground(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onBackground'); }
  onDestroy(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onDestroy'); }
  onWindowStageDestroy(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onWindowStageDestroy'); }
}

4.2 数据模型:NewsModel.ets

export class NewsItem {
  id: number;
  title: string;
  desc: string;
  time: string;

  constructor(id: number, title: string, desc: string, time: string) {
    this.id = id;
    this.title = title;
    this.desc = desc;
    this.time = time;
  }
}

export const MOCK_NEWS: NewsItem[] = [
  new NewsItem(1, '鸿蒙 5.0 正式发布', '全新系统架构与原生体验升级,开发者生态持续扩容。', '10:24'),
  new NewsItem(2, 'ArkTS 声明式开发再提速', '新一代编译器让首帧渲染时间平均缩短 20%。', '09:58'),
  new NewsItem(3, '多设备协同全面开放', '跨端流转能力开放给更多应用品类。', '09:31'),
  new NewsItem(4, '开发者大会亮点回顾', '一站式开发工具链与 AI 辅助编程成为焦点。', '08:47'),
  new NewsItem(5, '安全隐私框架升级', '细粒度权限管控与数据分级保护机制上线。', '08:12'),
  new NewsItem(6, '元服务生态持续增长', '轻量应用接入量同比增长 80%。', '07:40'),
];

4.3 主页面:Index.ets

import { DataLoadDemo } from '../components/DataLoadDemo';
import { StyleDemo } from '../components/StyleDemo';
import { StateManageDemo } from '../components/StateManageDemo';

@Entry
@Component
struct Index {
  @State currentIndex: number = 0;

  build() {
    Column() {
      Tabs({ barPosition: BarPosition.Start, index: this.currentIndex }) {
        TabContent() { DataLoadDemo() }.tabBar('数据加载')
        TabContent() { StyleDemo() }.tabBar('样式配置')
        TabContent() { StateManageDemo() }.tabBar('状态管理')
      }
      .vertical(false)
      .scrollable(true)
      .barMode(BarMode.Scrollable)
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
  }
}

4.4 数据加载:DataLoadDemo.ets

这是本文的核心场景:点按钮 → 转圈 → 模拟请求 1.5 秒后随机成功(60%)或失败(40%),失败给出重试入口。

import { promptAction } from '@kit.ArkUI';
import { NewsItem, MOCK_NEWS } from '../model/NewsModel';

@Component
export struct DataLoadDemo {
  @State status: string = 'idle';          // idle | loading | success | error
  @State newsList: NewsItem[] = [];
  private timer: number = -1;

  build() {
    Column({ space: 16 }) {
      Text('数据加载')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .width('92%')
        .textAlign(TextAlign.Start)

      if (this.status === 'idle') {
        Column({ space: 16 }) {
          Text('📰').fontSize(48)
          Text('下拉一点,看看今天发生了什么')
            .fontSize(14)
            .fontColor('#666666')
          Button('加载新闻列表')
            .width('72%')
            .height(44)
            .borderRadius(22)
            .fontSize(16)
            .onClick(() => {
              this.startLoad();
            })
        }
        .width('92%')
        .padding({ top: 40, bottom: 40 })
        .borderRadius(14)
        .backgroundColor('#F7F9FF')
      } else if (this.status === 'loading') {
        Column({ space: 12 }) {
          LoadingProgress()
            .width(48)
            .height(48)
            .color('#0A59F7')
          Text('正在加载新闻,请稍候…')
            .fontSize(14)
            .fontColor('#666666')
        }
        .width('92%')
        .height(180)
        .justifyContent(FlexAlign.Center)
        .borderRadius(14)
        .backgroundColor('#F7F9FF')
      } else if (this.status === 'success') {
        Column({ space: 10 }) {
          ForEach(this.newsList, (item: NewsItem) => {
            Row({ space: 12 }) {
              Column({ space: 4 }) {
                Text(item.title)
                  .fontSize(16)
                  .fontWeight(FontWeight.Medium)
                  .fontColor('#333333')
                Text(item.desc)
                  .fontSize(13)
                  .fontColor('#999999')
                  .maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)

              Text(item.time)
                .fontSize(12)
                .fontColor('#BBBBBB')
            }
            .width('100%')
            .padding(14)
            .borderRadius(12)
            .backgroundColor(Color.White)
            .border({ width: 1, color: '#F0F0F0' })
          }, (item: NewsItem) => item.id.toString())
        }
        .width('92%')

        Button('重新加载')
          .width('92%')
          .height(44)
          .borderRadius(22)
          .fontSize(15)
          .backgroundColor('#F0F0F0')
          .fontColor('#333333')
          .onClick(() => {
            this.startLoad();
          })
      } else {
        Column({ space: 12 }) {
          Text('⚠️').fontSize(44)
          Text('加载失败,请检查网络后重试')
            .fontSize(14)
            .fontColor('#666666')
          Button('重试')
            .width('60%')
            .height(44)
            .borderRadius(22)
            .fontSize(16)
            .onClick(() => {
              this.startLoad();
            })
        }
        .width('92%')
        .padding({ top: 36, bottom: 36 })
        .borderRadius(14)
        .backgroundColor('#FFF7F5')
      }

      Text('说明:LoadingProgress 本身没有文案,需要配合文本说明加载意图;'
        + '显示/隐藏完全由状态驱动,请求结束的一刻必须切走 loading 态。')
        .fontSize(13)
        .fontColor('#999999')
        .width('92%')
        .textAlign(TextAlign.Start)
    }
    .width('100%')
    .padding({ top: 16 })
  }

  aboutToDisappear(): void {
    if (this.timer >= 0) {
      clearTimeout(this.timer);
    }
  }

  startLoad(): void {
    this.status = 'loading';
    this.timer = setTimeout(() => {
      if (Math.random() < 0.6) {
        this.newsList = MOCK_NEWS;
        this.status = 'success';
        promptAction.showToast({ message: '加载成功', duration: 1200 });
      } else {
        this.status = 'error';
        promptAction.showToast({ message: '加载失败', duration: 1200 });
      }
    }, 1500);
  }
}

这段代码浓缩了三个工程细节:status 一个状态驱动四种界面形态loading 分支放 LoadingProgress 与配套文案;setTimeout 模拟异步请求,返回的句柄在 aboutToDisappear 中清理,避免页面销毁后回调更新状态;成功/失败都从 loading 切走,保证转圈不会残留。

4.5 样式配置:StyleDemo.ets

颜色与大小的全部玩法集中展示:

Column({ space: 8 }) {
  LoadingProgress()
    .width(36)
    .height(36)
    .color(color)
  Text(label)
    .fontSize(12)
    .fontColor('#999999')
}

页面分两行:第一行展示四种主题色(主题蓝/成功绿/警告橙/错误红),第二行展示三种尺寸(32/48/64)。注意 color 是整体着色,渐变与动画由系统渲染,业务侧只需要定"这个页面用什么颜色"。

4.6 状态管理与竞态:StateManageDemo.ets

这个模块回答"加载状态怎么管":手动流转四态,并演示请求序号比对丢弃过期响应。

@State status: string = 'idle';
@State requestSeq: number = 0;   // 已发出的最新请求序号
@State latestSeq: number = 0;    // 最近一次生效的请求序号

simulateLoad(): void {
  const mySeq: number = this.requestSeq + 1;
  this.requestSeq = mySeq;
  this.status = 'loading';
  setTimeout(() => {
    if (mySeq !== this.requestSeq) {
      promptAction.showToast({ message: `请求 #${mySeq} 已过期,结果丢弃`, duration: 1200 });
      return;
    }
    if (Math.random() < 0.6) {
      this.newsList = MOCK_NEWS;
      this.status = 'success';
    } else {
      this.status = 'error';
    }
    this.latestSeq = mySeq;
    promptAction.showToast({ message: `请求 #${mySeq} 生效`, duration: 1200 });
  }, 1500);
}

竞态处理的要点就在 mySeq !== this.requestSeq 这一行:闭包里保存自己发起时的序号,响应返回时与"最新序号"比对,不一致说明自己已经不是最新的请求,结果直接丢弃。配合按钮 enabled(this.status !== 'loading'),从交互与逻辑两层堵住重复请求。

4.7 模块配置要点

module.json5 声明 EntryAbilitypages/Index 路由,main_pages.json 指向 pages/Index,字符串与颜色资源位于 resources/base/element/。与通用 ArkTS 工程完全一致,不再赘述。


五、模拟器运行与效果展示

5.1 编译运行步骤

  1. 用 DevEco Studio 打开本文配套 ohos/ 目录;
  2. entry/src/main/resources/base/media/ 放入名为 icon.png 的图标(与 module.json5$media:icon 对应);
  3. 顶部选择 Phone 模拟器(或连接真机),点击 Run;
  4. 应用启动后进入 Index 页面,顶部 Tab 可在三个演示间切换。

5.2 预期效果截图

在这里插入图片描述

图 1:加载前界面。初始处于 idle 态,展示"📰"引导卡片与"加载新闻列表"按钮。

在这里插入图片描述

图 2:加载中动画。点击按钮后进入 loading 态,48 号主题蓝指示器转动,下方文案"正在加载新闻,请稍候…",此时无其他可操作元素。

在这里插入图片描述

5.3 交互验证

  • 四态流转:反复点击"加载新闻列表",观察 idle → loading → success/error 的界面切换,以及 Toast 的成败提示;
  • 失败重试:遇到 error 态时点击"重试",重新进入转圈流程,可一直点到成功为止;
  • 样式对照:在"样式配置"Tab 查看四色与三尺寸的 LoadingProgress 并行转动;
  • 状态机纪律:在"状态管理"Tab 手动流转四态,观察"发起加载"按钮在 loading 态自动禁用;
  • 竞态丢弃:快速连点三次"发起加载",三条 Toast 依次提示"请求 #N 已过期"或"请求 #N 生效",最终界面状态只由最后一个生效请求决定。

六、调试与常见问题

问题 1:转圈一直在转,页面没有结果。
请求结束时没有切走 loading 态。检查异步回调里是否覆盖了所有出口(成功、失败、超时),任何一个出口漏了 this.status = 'success'/'error' 都会造成"假死"。

问题 2:点击按钮发出多个请求。
按钮在 loading 态未禁用,或逻辑入口没有状态守卫。参考 4.6:按钮加 enabled(this.status !== 'loading')startLoad 开头再加一道 if (this.status === 'loading') return

问题 3:快速切换后数据显示错乱。
典型的竞态:旧请求的响应覆盖了新请求的数据。用请求序号比对(4.6)丢弃过期响应,或对"组件已销毁"的请求结果做丢弃处理。

问题 4:加载一闪而过,像闪屏。
请求耗时太短导致 loading 态一帧即逝。可引入最小展示时长(如 400ms):loading 结束时间取"真实耗时"与"最短展示"的较大值。

问题 5:LoadingProgress 想显示百分比。
它是不确定进度组件,没有数值能力。需要百分比请改用 Progresstype: ProgressType.Linear 等),见第三章对比表。

问题 6:页面销毁后 setTimeout 还在回调。
@State 更新发生在已销毁的组件上会告警甚至崩溃。在 aboutToDisappearclearTimeout,并给回调加状态判断(如 if (this.timer < 0) return)。

无障碍建议: LoadingProgress 是纯动画,读屏无法感知。在 loading 态给容器补 accessibilityText(如"正在加载新闻"),或让读屏播报伴随文案;加载结束切换界面时,也应在语义上让焦点落到新内容上。


七、总结与扩展

加载反馈的使用要点浓缩成四条:

一个状态机
idle/loading/success/error 四态严控迁移,请求结束必须离开 loading
一对时机
请求发出立刻显示、请求结束立刻消失,必要时加最小展示时长防闪屏;
一套守卫
按钮禁用 + 入口状态判断双重防重复,请求序号比对防竞态;
一句人话
LoadingProgress 无内置文案,转圈旁边必须配一句说明加载意图的文字。

转圈的意义,在于告诉用户:系统没有沉默,它在工作。

往深走,有三条值得继续的路:

  1. 骨架屏:内容轮廓已知的页面用色块占位替代转圈,首屏体验更接近"真内容",配合 Progress 的确定进度使用;
  2. 下拉刷新:列表页用 Refresh 组件承载"下拉即加载",与 LoadingProgress 的按钮触发形成两种互补的加载入口;
  3. 全局加载态:把"加载中/成功/失败/重试"封装成通用容器组件(@Builder 插槽 + 状态属性),全应用一套代码复用,配合 Promise 链与超时控制做成完整的请求管线。

异步反馈是交互设计里最容易被"技术完成度"掩盖的部分——请求能通、数据能显,转圈却常常被随手一放。把状态机、时机与守卫想清楚,LoadingProgress 就不再是"一个转圈",而是用户信任感的一部分。

Logo

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

更多推荐