用户等待数据加载时,白屏或转圈是最差的体验。骨架屏(Skeleton Screen)用灰色占位块预演内容布局,让用户感知"内容正在加载中"。本文用 ArkUI 的条件渲染实现完整的骨架屏加载模式——多种骨架形状、自动过渡到真实内容、以及重新加载重置。


一、什么是骨架屏

骨架屏是在数据加载期间显示的一组灰色占位块,形状和布局与最终内容一一对应

  • 圆形占位 → 最终是头像
  • 短宽矩形 → 最终是标题文字
  • 长窄矩形 → 最终是副标题/描述
  • 正方形占位 → 最终是缩略图

骨架屏的核心价值不是"让等待变快",而是 “让等待不那么焦虑”。一个灰色骨架比一个 loading 转圈更能让用户感知到"页面框架已经就绪,只是内容在加载"。


二、我们要做什么

一个个人资料 + 文章列表页面,包含三层骨架:

  1. 头像 + 昵称区 — 圆形骨架(头像)+ 两行矩形骨架(名字/简介)
  2. 统计数据区 — 4 列,每列一个宽矩形 + 窄矩形
  3. 文章列表区 — 4 行,每行一个长矩形(标题)+ 短矩形(时间)+ 正方形(缩略图)

交互点:

  1. 自动加载 — 进入页面 2 秒内显示骨架,2 秒后自动切换到真实内容
  2. 点击刷新 — 顶部"刷新"按钮重置为骨架状态,重新计时 2 秒
  3. 不同骨架形状 — 圆形(头像)、宽矩形(标题)、窄矩形(副标题)、正方形(缩略图),匹配内容语义
  4. 骨架宽度差异化 — 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 秒 → 再次切换到真实内容。

这里有两个关键操作:

  1. 清除旧定时器clearTimeout(this.loadTimer) 防止前一个定时器和新定时器"打架"(前一个 2 秒到了设为 false,新的还在等)
  2. 重启加载流程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 个统计数据用同一个 @BuilderlayoutWeight(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 需要:

  1. 一个 @State 控制的动画位置变量
  2. animateTo 循环驱动位置变化
  3. 半透明白色渐变的 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/ 项目,首页点击"骨架屏 — 加载占位与状态过渡"即可体验:

  1. 进入页面 → 所有内容为灰色骨架占位(圆形头像 + 矩形文字 + 正方形缩略图)
  2. 2 秒后 → 骨架消失,真实内容显示(头像姓名 + 统计数据 + 文章列表)
  3. 点击顶部"刷新" → 骨架再次出现 → 重新计时 2 秒 → 再次切换
  4. 注意骨架和真实内容的布局完全一致——头像位置、文字行数、缩略图位置都没有跳动

十三、扩展方向

  • Shimmer 动画 — 用 animateTo + translate 驱动光带从左到右扫过骨架,让骨架"动起来",用户明确知道不是卡住了
  • 骨架颜色暗黑模式适配 — 暗黑模式下骨架颜色从 #E8E8E8 改为 #2A2A2A
  • 分区域加载 — 不是全页一个 isLoading,而是每个区域独立的 isLoading。头像数据先返回 → 头像先显示;文章列表数据后返回 → 骨架保持更久
  • 加载失败骨架 — 骨架显示 10 秒后数据仍未返回 → 自动切换到加载失败状态(重试按钮),而不是无限显示骨架
  • 骨架屏组件库 — 封装 SkeletonCircleSkeletonLine(width)SkeletonBlock(w, h) 三个通用组件,任何页面拼装使用
  • 图片渐进加载 — 缩略图先用模糊低分辨率图占位,高清图加载完成后替换(类似 Medium 的图片加载体验)
Logo

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

更多推荐