一张 sketch-notes 风格的框架示意图,主题是 HarmonyOS 7 颜色 Token

前言

骨架屏不是灰色占位块越多越专业。它应该贴近真实页面结构,让用户知道内容正在加载,也让页面从加载到完成时不会明显跳动。

这篇单独聊 通用加载 这个场景。重点不是堆 API,而是把骨架屏抽成足够小的 ArkUI 构建器,让加载态贴近真实页面结构。

为什么这个问题经常被写乱

页面骨架如何做成组件 这类内容很容易被写成“代码能跑就算讲完了”,但对初学者来说,这恰恰是最不够的地方。真正让人卡住的,往往不是某个组件名记不住,而是不知道这段代码为什么要这样拆、状态为什么要这样放、以后需求变化时应该从哪里改。

所以这篇文章不只想给你一个能跑的例子,更想把背后的判断过程讲清楚。你只要把这个判断过程吃透,后面自己改页面、补需求、查问题时,心里会稳很多。

场景:资讯列表首屏加载

骨架屏不是把页面涂成一片灰色。它应该告诉用户:这里会有标题、这里会有摘要、这里会有列表卡片。资讯、课程、订单、商品列表这类页面,如果加载时只放一个转圈,用户不知道还要等多久;如果骨架结构贴近真实内容,等待感会明显降低。

我做骨架组件时会控制复杂度。骨架应该是轻量的 UI 片段,不应该复制一份完整业务卡片。复制得越像,维护成本越高;抽得太粗,又失去降低跳动的意义。

骨架屏不是装饰,它是“页面马上会长什么样”的预告。

骨架块设计

骨架块 对应真实内容 尺寸建议 注意点
SkeletonLine 标题、摘要 12 到 22 vp 高 宽度不要全满
SkeletonAvatar 头像、图标 32 到 48 vp 圆角贴近真实图形
SkeletonCard 列表卡片 接近真实卡片高度 避免加载完成大跳动
SkeletonAction 按钮 32 到 40 vp 高 不要太抢眼

实操步骤

  1. 先画真实页面结构,再反推骨架块,而不是先画灰块。
  2. 抽出最小单元 SkeletonLine,复用颜色和圆角。
  3. 再组合出 ArticleSkeletonCard 这类业务骨架。
  4. 加载态和内容态用同一块页面区域切换,避免高度差过大。
  5. 骨架数量与首屏可见内容接近,不要一次渲染几十个灰块。
  6. 接口失败时切到错误态,不要让骨架无限显示。

先把页面目标想清楚

在真正写代码之前,先别急着盯着 API。更有用的做法是先想清楚:这个页面到底想解决什么问题,用户最在意的反馈是什么,哪些状态必须一直保持一致。

当你先把这条主线想明白,再回头看组件和状态设计,很多选择都会顺理成章。对小白来说,这一步尤其重要,因为它能帮你从“照着抄”慢慢过渡到“看得懂、改得动”。

完整示例:资讯卡片骨架组件

interface ArticleItem {
  id: number
  title: string
  summary: string
  author: string
}

@Entry
@Component
struct SkeletonComponentPage {
  @State loading: boolean = true
  @State failed: boolean = false

  private articles: ArticleItem[] = [
    { id: 1, title: 'ArkUI 列表加载的几个细节', summary: '从骨架屏、空态到错误兜底,整理一个更完整的加载体验。', author: '编辑部' },
    { id: 2, title: 'HarmonyOS7 页面状态管理实践', summary: '用明确状态减少页面闪动和重复判断。', author: '技术团队' }
  ]

  @Builder
  SkeletonLine(widthValue: Length, heightValue: number, radiusValue: number) {
    Blank()
      .width(widthValue)
      .height(heightValue)
      .backgroundColor('#E6EAF0')
      .borderRadius(radiusValue)
  }

  @Builder
  ArticleSkeletonCard() {
    Row({ space: 12 }) {
      this.SkeletonLine(48, 48, 8)
      Column({ space: 10 }) {
        this.SkeletonLine('64%', 18, 4)
        this.SkeletonLine('92%', 14, 4)
        this.SkeletonLine('48%', 14, 4)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
  }

  @Builder
  ArticleCard(item: ArticleItem) {
    Row({ space: 12 }) {
      Text(item.author.substring(0, 1))
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width(48)
        .height(48)
        .textAlign(TextAlign.Center)
        .backgroundColor('#0A59F7')
        .borderRadius(8)

      Column({ space: 6 }) {
        Text(item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(item.summary)
          .fontSize(13)
          .fontColor('#666666')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(item.author)
          .fontSize(12)
          .fontColor('#999999')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
  }

  private reload() {
    this.loading = true
    this.failed = false
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        Text('加载示例')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
        Blank()
        Button(this.loading ? '显示内容' : '显示骨架')
          .onClick(() => this.loading = !this.loading)
      }.width('100%')

      if (this.failed) {
        Column({ space: 8 }) {
          Text('加载失败').fontSize(18).fontWeight(FontWeight.Medium)
          Text('网络不稳定,请稍后重试。').fontSize(13).fontColor('#666666')
          Button('重试').onClick(() => this.reload())
        }.alignItems(HorizontalAlign.Start).padding(14).backgroundColor('#FFFFFF').borderRadius(8)
      } else if (this.loading) {
        this.ArticleSkeletonCard()
        this.ArticleSkeletonCard()
        this.ArticleSkeletonCard()
      } else {
        ForEach(this.articles, (item: ArticleItem) => {
          this.ArticleCard(item)
        }, (item: ArticleItem) => item.id.toString())
      }
    }
    .padding(16)
    .height('100%')
    .backgroundColor('#F5F7FA')
  }
}

把关键代码一段段拆开

SkeletonLine() 是最小单元,只关心宽、高、圆角。这样标题线、摘要线、头像占位都能复用同一套灰色样式,不需要到处复制 backgroundColor

ArticleSkeletonCard() 的结构和真实 ArticleCard() 接近:左侧头像,右侧三行文字。加载完成后高度变化很小,页面不会突然跳动。

failedloading 分开处理。骨架只能表示“正在加载”,不能拿来表示“加载失败”。接口失败后如果骨架一直转,用户会以为还在等。

容易踩坑的点

  • 骨架和真实内容高度差太大,加载完成时页面明显跳动。
  • 骨架数量过多,反而增加首屏渲染压力。
  • 把骨架写成完整业务组件的复制版,后续维护两套结构。
  • 失败后仍显示骨架,没有错误兜底。
  • 所有灰块宽度都一样,看起来不像真实内容。

优化建议

骨架屏适合超过短暂等待的页面。如果接口通常几十毫秒返回,强行闪一下骨架反而打扰用户。可以设置一个很小的延迟策略:请求很快完成就不展示骨架,请求超过一定时间再展示。

颜色上不要用太重的灰,尤其是在浅色背景里。骨架应该低调地暗示结构,不应该抢走真实内容的位置感。深色模式下也要单独调整骨架色,否则灰块可能过亮。

骨架组件要轻,但不能失真

骨架屏的价值在于降低等待感和布局跳动,不是复制一套完整业务 UI。灰块要贴近真实内容结构,让用户知道页面马上会出现什么;但它又不能和真实卡片完全同步维护,否则后面改一次业务卡片就要改两份代码。

示例先抽 SkeletonLine() 作为最小单元,再组合成 ArticleSkeletonCard()。这样颜色、圆角和尺寸可以复用,资讯卡片的左图右文结构也能保留下来,加载完成时页面不会明显跳动。

写在最后

失败态要和加载态分开。骨架只表示“正在加载”,接口失败后应该切到错误提示和重试入口。如果骨架无限显示,用户会以为还在等待,实际已经没有下一步了。

Logo

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

更多推荐