打开一个应用看到白屏——这是用户最焦虑的时刻。骨架屏就是解决这个问题的:在数据加载完之前,用灰色占位块模拟页面布局,给用户"内容马上就来"的心理预期。HarmonyOS NEXT没有内置Skeleton组件,得自己实现,但用@Builder封装后复用也不难。
为什么需要骨架屏
传统的加载方式有三种:
- 白屏等待— 最差体验,用户不知道是在加载还是崩了
- Loading转圈— 比白屏好,但遮挡了整个页面,用户对内容毫无预判
- 骨架屏— 最优体验,用户能提前看到页面结构,心理预期明确
骨架屏的本质是"视觉占位"——用占位元素告诉用户这里将来会出现什么。所以骨架的布局必须跟真实内容一致,不然切换时会有布局跳变。
基础骨架组件
文本行占位
@BuilderSkeletonLine(width:Length):void{Row().width(width).height(14).borderRadius(7).backgroundColor('#e0e0e0')}关键设计:
- 高度14vp,接近正文行高
- borderRadius设高度的一半(7vp),模拟文字的圆角感
- 颜色用#e0e0e0浅灰,不抢视觉焦点
- width用百分比(‘60%’/‘80%’),不同行不同宽度模拟自然文本
头像占位
@BuilderSkeletonCircle(size:number):void{Row().width(size).height(size).borderRadius(size/2).backgroundColor('#e0e0e0')}头像占位用圆形,size参数控制大小。48vp是常见头像尺寸,36vp是列表头像。
卡片骨架
@BuilderSkeletonCard():void{Row(){this.SkeletonCircle(48)// 头像Column(){this.SkeletonLine('60%')// 标题this.SkeletonLine('80%')// 副标题}.layoutWeight(1).margin({left:12}).alignItems(HorizontalAlign.Start).justifyContent(FlexAlign.SpaceAround)}.width('100%').padding(16).borderRadius(12).backgroundColor('#ffffff')}头像+文字行是卡片骨架最经典的模式——Circle在左,纵向Line在右。
列表项骨架
@BuilderSkeletonListItem():void{Row(){this.SkeletonCircle(36)Column(){this.SkeletonLine('50%')this.SkeletonLine('70%')this.SkeletonLine('40%')}.layoutWeight(1).margin({left:10}).alignItems(HorizontalAlign.Start)}.width('100%').padding(10).borderRadius(8).backgroundColor('#ffffff').margin({bottom:6})}列表骨架重复相同模式,宽度用不同百分比区分每行。
条件渲染切换
骨架屏最核心的交互就是加载/完成的切换:
@StateisLoading:boolean=true;if(this.isLoading){this.SkeletonCard()}else{Column(){Text('数据加载成功!').fontSize(16).fontWeight(FontWeight.Medium).fontColor('#43a047')// ...真实内容}.padding(16).borderRadius(12).backgroundColor('#ffffff').width('100%')}切换按钮:
Button(this.isLoading?'显示内容':'显示骨架').onClick(()=>{this.isLoading=!this.isLoading;})模拟网络加载:
Button('加载3秒').onClick(()=>{this.isLoading=true;setTimeout(()=>{this.isLoading=false;},3000);})setTimeout模拟异步数据加载,3秒后切换到真实内容。
列表骨架
列表场景需要多个骨架项同时显示:
@StateisLoading:boolean=true;List(){if(this.isLoading){ForEach([0,1,2,3,4],(item:number)=>{ListItem(){this.SkeletonListItem()}},(item:number)=>item.toString())}else{ForEach(realData,(msg:string,index:number)=>{ListItem(){// 真实列表项}},(msg:string,index:number)=>index.toString())}}5个骨架项是列表骨架的标配——太少了看不出效果,太多了浪费渲染。5-8个是比较合理的范围。
过渡动画
骨架到内容的切换如果硬切,会有跳变感。加个opacity过渡:
Column(){// 内容}.transition(TransitionEffect.OPACITY)transition让组件出现/消失时有透明度过渡。但这只在组件级别生效,骨架和内容是条件渲染互斥的,需要在两方都加transition才能实现平滑切换。
更高级的做法是用animateTo控制切换:
animateTo({duration:300,curve:Curve.EaseInOut},()=>{this.isLoading=false;})骨架屏设计原则
布局一致— 骨架与真实内容的布局结构必须匹配。如果真实内容是Row(头像+文字),骨架也必须是Row(圆形+线条)。
尺寸对应— 骨架元素的宽高应该接近真实内容。头像占位的大小应该跟真实头像一致,文字行的高度应该跟真实文字行高接近。
颜色柔和— #e0e0e0或#eeeeee,不要用深色。骨架是配角,不能抢视觉焦点。
圆角一致— 文字行用大圆角(borderRadius=height/2)模拟文字的连续感。图片用跟真实图片一样的borderRadius。
宽度变化— 不同行的宽度用不同百分比(40%/60%/80%),模拟自然文本的参差感。不要所有行都是100%宽度。
间距对齐— 骨架元素的间距(margin/padding)应该跟真实内容的间距一致。
文章骨架模式
长文章的骨架比较复杂——有大标题、多段正文、可能还有图片占位:
Column(){Row(){this.SkeletonCircle(56)// 作者头像(大)Column(){this.SkeletonLine('40%')// 作者名this.SkeletonLine('100%')// 标题(长)this.SkeletonLine('90%')// 摘要1this.SkeletonLine('60%')// 摘要2}.layoutWeight(1).margin({left:12}).alignItems(HorizontalAlign.Start)}.width('100%')}.padding(16).borderRadius(12).backgroundColor('#ffffff')文章骨架的特点是文字行多、宽度差异大,模拟真实文章的长短句。
性能注意
- 骨架屏渲染量很小(几个灰色矩形),性能不是问题
- 切换时如果列表数据量大,ForEach初始渲染可能有延迟——骨架屏正好填充这段时间
- 不要在骨架上做复杂动画(闪烁、波纹),简单的占位就够了
- 条件渲染(if/else)比opacity显隐更适合骨架场景——骨架不需要占空间但不可见
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| 骨架切换跳变 | 布局不一致 | 骨架布局匹配真实内容 |
| 骨架行全是100%宽 | 没做宽度变化 | 用40%-80%不等的百分比 |
| 骨架太刺眼 | 颜色太深 | 用#e0e0e0浅灰 |
| 切换无过渡 | 没加transition | 加TransitionEffect.OPACITY |
| 骨架圆角太锐 | borderRadius太小 | 设为height/2 |
| 列表骨架太少 | 只放了1-2个 | 放5-8个骨架项 |
骨架屏不是技术难题——它更像是设计意识的体现。同样的加载等待,骨架屏让用户觉得"快"了,因为心理预期被提前满足了。实现上就三个步骤:@Builder封装占位组件、if/else条件渲染切换、setTimeout模拟加载。简单但有效。