鸿蒙ArkUI实战:骨架屏加载与状态过渡
这篇文章介绍了如何使用ArkUI的条件渲染实现骨架屏(Skeleton Screen)加载效果,提升用户等待数据时的体验。文章首先解释了骨架屏的概念和价值——通过灰色占位块预演内容布局,减轻用户等待焦虑。然后详细说明了实现步骤:1)使用一个@State变量控制加载状态;2)设计圆形、矩形等不同形状的骨架占位;3)实现自动加载过渡和刷新重置功能;4)通过差异化宽度模拟真实内容特征。最后展示了完整的代
用户等待数据加载时,白屏或转圈是最差的体验。骨架屏(Skeleton Screen)用灰色占位块预演内容布局,让用户感知"内容正在加载中"。本文用 ArkUI 的条件渲染实现完整的骨架屏加载模式——多种骨架形状、自动过渡到真实内容、以及重新加载重置。
一、什么是骨架屏
骨架屏是在数据加载期间显示的一组灰色占位块,形状和布局与最终内容一一对应:
- 圆形占位 → 最终是头像
- 短宽矩形 → 最终是标题文字
- 长窄矩形 → 最终是副标题/描述
- 正方形占位 → 最终是缩略图
骨架屏的核心价值不是"让等待变快",而是 “让等待不那么焦虑”。一个灰色骨架比一个 loading 转圈更能让用户感知到"页面框架已经就绪,只是内容在加载"。
二、我们要做什么
一个个人资料 + 文章列表页面,包含三层骨架:
- 头像 + 昵称区 — 圆形骨架(头像)+ 两行矩形骨架(名字/简介)
- 统计数据区 — 4 列,每列一个宽矩形 + 窄矩形
- 文章列表区 — 4 行,每行一个长矩形(标题)+ 短矩形(时间)+ 正方形(缩略图)
交互点:
- 自动加载 — 进入页面 2 秒内显示骨架,2 秒后自动切换到真实内容
- 点击刷新 — 顶部"刷新"按钮重置为骨架状态,重新计时 2 秒
- 不同骨架形状 — 圆形(头像)、宽矩形(标题)、窄矩形(副标题)、正方形(缩略图),匹配内容语义
- 骨架宽度差异化 — 4 篇文章的标题骨架宽度不同(80%/70%/85%/60%),模拟真实内容的不均匀长度
三、核心原理:一个 @State 控制全页
@State isLoading: boolean = true;
private startLoading(): void {
this.isLoading = true;
this.loadTimer = setTimeout(() => {
this.isLoading = false;
this.loadTimer = -1;
}, 2000);
}
整个页面的骨架 vs 真实内容的切换,就靠一个 isLoading 布尔值。每个 UI 区域用 if (this.isLoading) 分支:
true→ 渲染灰色占位块(骨架)false→ 渲染真实内容
这和 FeedPage 的 Loading/Error/Empty 三态切换是同一个原理——状态驱动 UI,而不是手动操作 DOM。
四、骨架形状设计
4.1 圆形骨架(头像)
if (this.isLoading) {
Row()
.width(64).height(64)
.borderRadius(32)
.backgroundColor('#E8E8E8')
} else {
Row() {
Text('李')
.fontSize(24).fontWeight(FontWeight.Bold)
.fontColor(Color.White)
}
.width(64).height(64)
.borderRadius(32)
.backgroundColor(AppColors.PRIMARY)
.justifyContent(FlexAlign.Center)
}
骨架是一个 64×64 的灰色圆形,和真实头像尺寸完全一致。borderRadius(32) 是宽高的一半 → 正圆。
为什么用 Row() 而不用 Circle() 或其他形状组件?因为 Row().width().height().borderRadius() 是最通用的占位块写法——圆形只是矩形的特殊形态(borderRadius 足够大)。所有骨架形状用同一种写法,减少认知负担。
4.2 矩形骨架(文字行)
// 标题骨架 — 106px 宽 16px 高
Row()
.width(100).height(16)
.borderRadius(4)
.backgroundColor('#E8E8E8')
// 副标题骨架 — 160px 宽 12px 高
Row()
.width(160).height(12)
.borderRadius(4)
.backgroundColor('#E8E8E8')
标题骨架的宽度(100vp)大致匹配"李四"两个字的实际渲染宽度。骨架宽度和真实内容宽度越接近,过渡时的"跳变感"越小。
4.3 骨架宽度差异化
this.articleSkeleton('80%', '50%', 0)
this.articleSkeleton('70%', '40%', 1)
this.articleSkeleton('85%', '55%', 2)
this.articleSkeleton('60%', '35%', 3)
4 篇文章的标题骨架宽度分别为 80%/70%/85%/60%,时间骨架分别为 50%/40%/55%/35%。这模拟了真实内容的特征——文章标题长度不一。如果所有骨架宽度都相同,用户一眼就能看穿"这是假的",骨架屏的可信度下降。
但是,ArkUI 的 width(string) 接受百分比字符串,但这里的百分比是相对于父容器宽度。所有 4 篇文章的父容器宽度相同(list 宽度减去 padding 和缩略图),所以百分比有效。

五、交互点1:自动加载与过渡
aboutToAppear(): void {
this.startLoading();
}
进入页面 → isLoading = true → 2 秒后 isLoading = false → 骨架消失,真实内容出现。
这个 2 秒延迟模拟的是网络请求时间。真实 App 中,这个 delay 由数据加载速度决定——isLoading 的初始值为 true,在 API 响应的 .then() 中设为 false。
过渡体验
切换是瞬时的——骨架瞬间替换为真实内容,没有渐变动画。这不是偷懒,而是有意为之:
骨架屏的设计意图是预演布局。用户的眼睛已经通过骨架知道了"这里有个头像、这里有 4 个统计数、这里有 4 篇文章"。切换到真实内容时,用户只需要"填入信息",不需要重新扫描布局。所以瞬切不会造成困惑,反而给人一种"数据来得很快"的错觉——相比白屏+转圈,骨架屏让 2 秒"感觉上"更短。
六、交互点2:刷新重置
private reload(): void {
if (this.loadTimer >= 0) {
clearTimeout(this.loadTimer);
}
this.startLoading();
}
点击顶部"刷新"按钮 → 回到骨架状态 → 重新计时 2 秒 → 再次切换到真实内容。
这里有两个关键操作:
- 清除旧定时器 —
clearTimeout(this.loadTimer)防止前一个定时器和新定时器"打架"(前一个 2 秒到了设为 false,新的还在等) - 重启加载流程 —
startLoading()设isLoading = true+ 创建新定时器

七、交互点3 & 4:多种骨架形状 + 差异化宽度
用 @Builder articleSkeleton(titleWidth, descWidth, index) 封装每行文章的骨架/内容切换:
@Builder
articleSkeleton(titleWidth: string, descWidth: string, index: number) {
Column() {
Row() {
Column() {
if (this.isLoading) {
Row()
.width(titleWidth).height(16) // 标题骨架,宽度可变
.borderRadius(4)
.backgroundColor(SKELETON_COLOR)
Row()
.width(descWidth).height(12) // 时间骨架,宽度可变
.borderRadius(4)
.backgroundColor(SKELETON_COLOR)
} else {
Text(this.getTitle(index))...
Text(this.getTime(index))...
}
}
.layoutWeight(1)
// 右侧缩略图
if (this.isLoading) {
Row()
.width(48).height(48) // 正方形骨架
.borderRadius(BorderRadius.SM)
.backgroundColor(SKELETON_COLOR)
} else {
Row()
.width(48).height(48)
.borderRadius(BorderRadius.SM)
.backgroundColor(this.getColor(index))
.justifyContent(FlexAlign.Center)
Text('图').fontColor(Color.White)
}
}
...
}
}
三种骨架形状在同一行内:
- 文本骨架(宽矩形 + 窄矩形)
- 缩略图骨架(正方形 48×48)
骨架的灰色统一用 #E8E8E8(比 BACKGROUND 的 #F2F3F5 略深),在白色卡片背景上有清晰对比,但又不会过于醒目——骨架是"暗示",不是"强调"。
八、统计数据骨架
@Builder
statItem(value: string, label: string, index: number) {
Column() {
if (this.isLoading) {
Row()
.width(36).height(24) // 数字骨架
.borderRadius(4)
.backgroundColor(SKELETON_COLOR)
Row()
.width(28).height(12) // 标签骨架
.borderRadius(4)
.backgroundColor(SKELETON_COLOR)
} else {
Text(value)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#1677FF')
Text(label)
.fontSize(FontSize.CAPTION)
.fontColor(AppColors.TEXT_TERTIARY)
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
4 个统计数据用同一个 @Builder,layoutWeight(1) 均分宽度。骨架状态下,用户看到 4 列相同的"方框+方框",知道这里有 4 个数字指标。真实数据出现后,骨架的"方框"变成"48 文章 / 126 收藏 / 23 关注 / 89 粉丝"。
九、生命周期管理
aboutToDisappear(): void {
if (this.loadTimer >= 0) {
clearTimeout(this.loadTimer);
this.loadTimer = -1;
}
}
和 ChatPage、VerifyCodePage 一样——页面销毁前清理定时器。骨架屏的定时器如果泄漏,会导致:用户在骨架加载期间返回上一页 → 2 秒后定时器回调 → 试图更新已销毁组件的 isLoading → 运行时错误。
十、页面结构总结
SkeletonPage (~200 行)
├── 状态层
│ ├── @State isLoading: boolean — 唯一状态开关
│ └── loadTimer — 加载定时器
├── 业务方法
│ ├── startLoading() — 设 isLoading=true + 2s定时器
│ └── reload() — 清理旧定时器 + 重新加载
├── 数据方法
│ ├── getTitle(index) — 4 篇文章标题
│ ├── getTime(index) — 4 个发布时间
│ └── getColor(index) — 4 种缩略图颜色
├── Builder
│ ├── statItem(value, label, i) — 统计数字(骨架+真实)
│ └── articleSkeleton(w1, w2, i) — 文章行(骨架+真实)
└── UI
├── Header (返回 + 标题 + 刷新)
├── 用户资料(头像圆圈 + 文字行)
├── 统计数据(4 列数字)
└── 文章列表(4 行标题+缩略图)
十一、常见面试题 / 踩坑点
11.1 骨架屏 vs Loading 转圈,什么时候用哪个?
- 骨架屏 — 页面整体加载,布局已知但数据未知。用户能通过骨架预演"这里会有什么内容"。适合:个人中心、文章列表、商品详情
- Loading 转圈 — 操作后的等待。布局不确定或操作语义是"处理中"。适合:登录提交、支付处理、文件上传
- 两者不是替代关系,是互补关系 — 首次打开页面用骨架,点"提交"按钮后用转圈
11.2 骨架宽度为什么不能全部相同?
看两张对比图的心态差异:
- 全部相同宽度 → “这是一堆灰色方块” → 骨架可信度低
- 宽度随机但大致匹配 → “标题在加载、时间在加载、图片在加载” → 大脑自动补完
第二条是骨架屏的核心原理——利用用户对布局的预判,让大脑自行"填充"预期内容。
11.3 骨架颜色为什么选 #E8E8E8?
需要一个比背景色 #F2F3F5 深、但比文字色 #1D2129 浅得多的颜色。太深 → 像已加载的灰色文字,用户会误读;太浅 → 和背景融为一体,看不到。
#E8E8E8 在白色卡片上有清晰可见的轮廓,但远没有文字的对比度。它在视觉层级中处于"比背景强,比内容弱"的位置——恰好是"内容即将到来"的暗示。
11.4 为什么没有骨架动画?
真实的骨架屏(如 Facebook、LinkedIn)通常有从左到右的 shimmer 光扫过。在 ArkUI 中实现 shimmer 需要:
- 一个
@State控制的动画位置变量 animateTo循环驱动位置变化- 半透明白色渐变的 overlay
这增加了约 80 行代码,而 Demo 的核心目标是"骨架屏的布局原理"——静态骨架已经够了。生产环境建议加上 shimmer 动画——让用户明确感知"页面不是卡住了"。
11.5 骨架屏的代码会污染业务组件吗?
如果每个组件都写 if (isLoading) { skeleton } else { real },确实会让组件越来越长。更大的问题是条件渲染代码的维护。建议的工程化方案:
@Builder
skeletonOrReal(skeleton: () => void, real: () => void) {
if (this.isLoading) {
skeleton()
} else {
real()
}
}
但这个 Builder 的两个参数都是 CustomBuilder,在 ArkTS 中没有简洁的 lambda 语法支持。一个折中方案是把骨架提取成独立的 @Component,在页面上做 if (isLoading) { SkeletonCard() } else { RealCard() }。
十二、运行方式
代码位于 dev/entry/src/main/ets/pages/SkeletonPage.ets。
用 DevEco Studio 打开 dev/ 项目,首页点击"骨架屏 — 加载占位与状态过渡"即可体验:
- 进入页面 → 所有内容为灰色骨架占位(圆形头像 + 矩形文字 + 正方形缩略图)
- 2 秒后 → 骨架消失,真实内容显示(头像姓名 + 统计数据 + 文章列表)
- 点击顶部"刷新" → 骨架再次出现 → 重新计时 2 秒 → 再次切换
- 注意骨架和真实内容的布局完全一致——头像位置、文字行数、缩略图位置都没有跳动
十三、扩展方向
- Shimmer 动画 — 用
animateTo+translate驱动光带从左到右扫过骨架,让骨架"动起来",用户明确知道不是卡住了 - 骨架颜色暗黑模式适配 — 暗黑模式下骨架颜色从
#E8E8E8改为#2A2A2A - 分区域加载 — 不是全页一个
isLoading,而是每个区域独立的isLoading。头像数据先返回 → 头像先显示;文章列表数据后返回 → 骨架保持更久 - 加载失败骨架 — 骨架显示 10 秒后数据仍未返回 → 自动切换到加载失败状态(重试按钮),而不是无限显示骨架
- 骨架屏组件库 — 封装
SkeletonCircle、SkeletonLine(width)、SkeletonBlock(w, h)三个通用组件,任何页面拼装使用 - 图片渐进加载 — 缩略图先用模糊低分辨率图占位,高清图加载完成后替换(类似 Medium 的图片加载体验)
更多推荐
所有评论(0)