鸿蒙 LoadingProgress 加载指示器完全指南:异步反馈、状态管理与重试机制
LoadingProgress 加载指示器完全指南:异步反馈、状态管理与重试机制
本文基于 HarmonyOS(ArkTS 声明式开发范式,API 12 / 5.0.0)写作,所有示例均可在 DevEco Studio 模拟器中验证。配套演示工程位于本文同级目录
ohos/,包含完整可运行的EntryAbility.ets与Index.ets。
一、引言
打开新闻 App 的那一秒,你最怕看到什么?不是广告,而是一片"死寂"——页面毫无反应,你不知道它是在加载、是卡死了、还是数据已经出错。这个问题的答案,藏在一个转动的圈里:LoadingProgress 加载指示器。
异步操作是移动应用的常态:请求新闻列表、上传图片、刷新订单状态,每一项都要花时间。而"花时间"这件事本身需要被看见——用户心理学上有个朴素的结论:无法预估的等待最令人焦虑。加载指示器就是那根"确定性"的锚:它告诉用户"系统在工作,请稍候",从而把用户的注意力从"是不是坏了"转移到"快好了"。这正是它在交互体系里不可替代的价值:LoadingProgress 存在的意义不是装饰,而是消除不确定性。
ArkUI 的 LoadingProgress 是三者中"最简单"的组件:无构造参数、无内置文案、没有进度数值——它是一个不确定进度的循环动画,只表达"正在加载中"这一件事。它的全部定制点只有两个:color 控制颜色,通用的 width/height 控制大小。真正复杂的是它的显示与隐藏时机:请求发出的一刻显示,请求结束的一刻消失;而支撑这个时机的,是一套完整的加载状态管理——idle、loading、success、error 四种状态加上重试机制。
本文的路线是:先讲透 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.ets、model/NewsModel.ets与components/下的组件代码,其余配置沿用原工程即可。
三、核心 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
把显示时机抽象出来,就是一个标准四态状态机:
这个状态机的纪律性在于非法迁移必须被禁止: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}{已发出})
]
演示工程的 StateManageDemo 用 requestSeq 实现了这套比对,快速连点三次可以现场看到"过期请求被丢弃"的 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 声明 EntryAbility 与 pages/Index 路由,main_pages.json 指向 pages/Index,字符串与颜色资源位于 resources/base/element/。与通用 ArkTS 工程完全一致,不再赘述。
五、模拟器运行与效果展示
5.1 编译运行步骤
- 用 DevEco Studio 打开本文配套
ohos/目录; - 在
entry/src/main/resources/base/media/放入名为icon.png的图标(与module.json5中$media:icon对应); - 顶部选择 Phone 模拟器(或连接真机),点击 Run;
- 应用启动后进入
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 想显示百分比。
它是不确定进度组件,没有数值能力。需要百分比请改用 Progress(type: ProgressType.Linear 等),见第三章对比表。
问题 6:页面销毁后 setTimeout 还在回调。@State 更新发生在已销毁的组件上会告警甚至崩溃。在 aboutToDisappear 中 clearTimeout,并给回调加状态判断(如 if (this.timer < 0) return)。
无障碍建议: LoadingProgress 是纯动画,读屏无法感知。在 loading 态给容器补 accessibilityText(如"正在加载新闻"),或让读屏播报伴随文案;加载结束切换界面时,也应在语义上让焦点落到新内容上。
七、总结与扩展
加载反馈的使用要点浓缩成四条:
-
一个状态机
-
idle/loading/success/error四态严控迁移,请求结束必须离开loading; 一对时机
- 请求发出立刻显示、请求结束立刻消失,必要时加最小展示时长防闪屏; 一套守卫
- 按钮禁用 + 入口状态判断双重防重复,请求序号比对防竞态; 一句人话
-
LoadingProgress无内置文案,转圈旁边必须配一句说明加载意图的文字。
转圈的意义,在于告诉用户:系统没有沉默,它在工作。
往深走,有三条值得继续的路:
- 骨架屏:内容轮廓已知的页面用色块占位替代转圈,首屏体验更接近"真内容",配合
Progress的确定进度使用; - 下拉刷新:列表页用
Refresh组件承载"下拉即加载",与LoadingProgress的按钮触发形成两种互补的加载入口; - 全局加载态:把"加载中/成功/失败/重试"封装成通用容器组件(
@Builder插槽 + 状态属性),全应用一套代码复用,配合 Promise 链与超时控制做成完整的请求管线。
异步反馈是交互设计里最容易被"技术完成度"掩盖的部分——请求能通、数据能显,转圈却常常被随手一放。把状态机、时机与守卫想清楚,LoadingProgress 就不再是"一个转圈",而是用户信任感的一部分。
更多推荐


所有评论(0)