
前言
商品列表一进来居中放一个转圈,用户只能等。他不知道这里会出现什么,也不知道页面是不是卡住了。骨架屏好一点,它至少先告诉用户:这里会有图片、标题、价格和操作按钮。

HarmonyOS7 做电商、课程、消息列表时,我更愿意用骨架屏承接首屏加载。骨架屏不是灰色装饰,它是在加载阶段提前交代页面结构。
为什么这个问题经常被写乱
骨架屏比转圈更有耐心 这类内容很容易被写成“代码能跑就算讲完了”,但对初学者来说,这恰恰是最不够的地方。真正让人卡住的,往往不是某个组件名记不住,而是不知道这段代码为什么要这样拆、状态为什么要这样放、以后需求变化时应该从哪里改。

所以这篇文章不只想给你一个能跑的例子,更想把背后的判断过程讲清楚。你只要把这个判断过程吃透,后面自己改页面、补需求、查问题时,心里会稳很多。
骨架要贴近真实卡片
| 区域 | 骨架形状 | 真实内容 |
|---|---|---|
| 商品图 | 方形灰块 | 商品封面 |
| 标题 | 两条短线 | 商品名与卖点 |
| 价格 | 短线 | 价格 |
| 操作 | 小圆角块 | 加购按钮 |
骨架数量和首屏真实卡片接近就够了,不要无限铺满页面。骨架尺寸也要贴近真实内容,否则加载完成后页面会跳动。
先把页面目标想清楚
在真正写代码之前,先别急着盯着 API。更有用的做法是先想清楚:这个页面到底想解决什么问题,用户最在意的反馈是什么,哪些状态必须一直保持一致。
当你先把这条主线想明白,再回头看组件和状态设计,很多选择都会顺理成章。对小白来说,这一步尤其重要,因为它能帮你从“照着抄”慢慢过渡到“看得懂、改得动”。
完整 ArkTS 示例
interface GoodsItem23 {
id: number
name: string
price: string
tag: string
}
@Entry
@Component
struct SkeletonGoodsPage {
@State loading: boolean = true
@State loadFailed: boolean = false
@State goods: GoodsItem23[] = []
aboutToAppear(): void {
this.loadGoods()
}
async loadGoods(): Promise<void> {
this.loading = true
this.loadFailed = false
try {
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 700)
})
this.goods = [
{ id: 1, name: '轻量通勤双肩包', price: '¥199', tag: '今日好价' },
{ id: 2, name: '桌面无线充电底座', price: '¥129', tag: '热卖' },
{ id: 3, name: '降噪蓝牙耳机', price: '¥399', tag: '新品' }
]
} catch (err) {
this.loadFailed = true
} finally {
this.loading = false
}
}
@Builder
SkeletonCard() {
Row({ space: 12 }) {
Blank().width(86).height(86).backgroundColor('#E5E8EF').borderRadius(10)
Column({ space: 10 }) {
Blank().width('80%').height(16).backgroundColor('#E5E8EF').borderRadius(4)
Blank().width('56%').height(16).backgroundColor('#EEF0F5').borderRadius(4)
Blank().width(72).height(22).backgroundColor('#E5E8EF').borderRadius(11)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}
@Builder
GoodsCard(item: GoodsItem23) {
Row({ space: 12 }) {
Stack() {
Text(item.tag).fontSize(12).fontColor(Color.White)
}
.width(86)
.height(86)
.backgroundColor('#2F6FED')
.borderRadius(10)
Column({ space: 8 }) {
Text(item.name).fontSize(17).fontWeight(FontWeight.Medium)
Text('次日达 · 支持七天无理由').fontSize(12).fontColor('#777777')
Row() {
Text(item.price).fontSize(18).fontColor('#D92D20').fontWeight(FontWeight.Bold)
Blank()
Button('加购').height(30)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}
build() {
Column({ space: 14 }) {
Text('商品推荐').fontSize(24).fontWeight(FontWeight.Bold).width('100%')
if (this.loading) {
ForEach([0, 1, 2], (index: number) => {
this.SkeletonCard()
}, (index: number) => index.toString())
} else if (this.loadFailed) {
Column({ space: 10 }) {
Text('商品加载失败').fontSize(18).fontWeight(FontWeight.Bold)
Button('重新加载').onClick(() => this.loadGoods())
}
.padding(24)
.width('100%')
.backgroundColor(Color.White)
.borderRadius(12)
} else {
ForEach(this.goods, (item: GoodsItem23) => {
this.GoodsCard(item)
}, (item: GoodsItem23) => item.id.toString())
}
}
.padding(16)
.width('100%')
.height('100%')
.backgroundColor('#F6F7F9')
}
}
把关键代码一段段拆开
SkeletonCard() 和 GoodsCard() 的主要尺寸接近。图片区域、内边距、圆角都一致,加载完成后页面不会突然抖一下。
loading 和 loadFailed 分开。只用一个布尔值很快会不够用,失败态和加载态必须能独立表达,否则接口失败后骨架可能一直停在那里。
ForEach([0, 1, 2]) 用固定骨架数量。骨架不是数据,不需要伪造商品对象,首屏放三个占位就能说明结构。
让骨架更像真实业务
骨架不要覆盖所有可能内容,只覆盖稳定区域。比如商品图片、标题、价格、按钮通常都会有;临时活动标签、库存提醒、会员价不一定每条都有,就不必硬塞。
接口很快返回时,可以让骨架少展示或不展示,避免灰块一闪而过。接口失败时必须切到错误态,不能让用户一直看骨架。
新手最容易踩的坑
这一类示例最容易让人产生错觉:界面出来了,就以为已经掌握了。其实真正容易出问题的地方,通常都在效果之外,比如状态有没有收拢、失败后怎么兜底、以后要扩展时会不会牵一发动全身。
所以你练这篇内容时,别只看“现在能不能跑”,还要继续看“以后好不好改”。能把这个习惯养起来,你写出来的页面会比单纯照着示例拼出来的页面稳很多。
放进真实项目还要补什么
示例代码的重点是把核心思路讲明白,所以很多工程化细节会故意省掉。真正落到项目里时,你通常还要继续补接口联动、异常处理、边界保护、资源抽离,以及和其他页面状态之间的配合。
比较稳的做法是分三步走:先把结构和职责立住,再把真实业务接进去,最后再优化视觉和交互体验。这样改出来的页面不只是“能演示”,而是真的更接近可以长期维护的业务代码。
写在最后
骨架屏的价值是降低等待焦虑,不是把页面做得更花。越贴近真实卡片,越能让用户预期下一步会出现什么。HarmonyOS7 用条件渲染写这种状态很直接,关键是别把加载、失败和真实内容混在一起。

459

被折叠的 条评论
为什么被折叠?



