很久没有更新过小技巧系列,今天简单介绍一个非常好用的骨架屏框架 skeletonizer ,它主要是通过将你现有的布局自动简化为简单的骨架,并添加动画效果 来实现加载过程,而使用成本则是简单的添加一个 Skeletonizer 作为 parent :
dart
Skeletonizer(
enabled: _loading,
child: ListView.builder(
itemCount: 7,
itemBuilder: (context, index) {
return Card(
child: ListTile(
title: Text('Item number $index as title'),
subtitle: const Text('Subtitle here'),
trailing: const Icon(Icons.ac_unit),
),
);
},
),
)

当然,在实际使用场景中,一般情况在列表返回之前我们是没有数据的,所以可以在加载过程中,通过 skeletonizer 提供的 BoneMock 来组装一个你需要长度的数据列表:
dart
final fakeUsers = List.filled(7, User(
name: BoneMock.name,
jobTitle: BoneMock.words(2),
email: BoneMock.email,
createdAt: BoneMock.date,
),
);
final users = _loading ? fakeUsers : realUsers;
return Skeletonizer(
enabled: _loading,
child: UserList(users: users),
);
那 skeletonizer 是如何做到这个自动转换控件为骨架屏的呢?核心就是在绘制 child 时,通过自定义 context 来替换默认 PaintingContext :

在 skeletonizer 内部,它的
RenderSkeletonizer是一个RenderProxyBox实现,作为一个RenderProxyBox的子类,它在布局阶段表现得像一个透明代理,但在绘制阶段会接管控制权,决定是绘制真实的子节点还是绘制骨架。
简单来说,skeletonizer 就是通过自定义 PaintingContext 来拦截处理 child 的渲染 ,这里我们先简单看看它的核心代码的作用:
-
render_skeletonizer.dart:- 它是
RenderObject的实现,也就是实际负责渲染的对象,RenderSkeletonizer和RenderSliverSkeletonizer的核心就是 overridepaint方法,当Skeletonizer被激活时,它们不会像平常一样绘制child,而是创建一个自定义的SkeletonizerPaintingContext来接管绘制工作
- 它是
-
skeletonizer_painting_context.dart:- 骨架屏效果的关键,继承自
PaintingContext,但是提供了一个自定义的Canvas对象SkeletonizerCanvas,这个自定义的Canvas会拦截所有来自 child 的绘制,然后用骨架的样式来替代它们
- 骨架屏效果的关键,继承自
-
uniting_painting_context.dart:- 在 paint 里对应
Skeleton.unite的特殊实现,它提供了一个名为UnitingCanvas的特殊Canvas,当child在这个Canvas上绘制时,它不会真的去绘制每个元素,而是计算所有绘制操作的区域,并将它们合并成一个大的矩形(unitedRect),最终这个合并后的大矩形会被统一渲染成一个骨架块
- 在 paint 里对应
-
/effects/\*.dart:- 这个目录主要用于定义骨架屏的视觉动画效果,其中
painting_effect.dart定义了所有效果必须遵守的抽象基类PaintingEffect,主要是通过构建Paint来构建动画,默认的对应实现有:shimmer_effect.dart: 实现了最常见的"微光"或"闪烁"效果,通过一个滑动的LinearGradient(线性渐变) 来实现pulse_effect.dart: 实现了"脉冲"效果,在两种颜色之间来回渐变sold_color_effect.dart: 纯色效果,没有动画
- 这个目录主要用于定义骨架屏的视觉动画效果,其中

所以,整个骨架屏的渲染流程如上图所示,可以总结为:
-
启用 Skeletonizer:
- 当
Skeletonizer(enabled: true, child: ...)被构建时,它会启动一个动画控制器(AnimationController),并根据配置选择一个PaintingEffect(例如ShimmerEffect)
- 当
-
创建 RenderObject:
Skeletonizer会创建一个RenderSkeletonizer(或RenderSliverSkeletonizer) 对象,这个RenderObject会将自己标记为isRepaintBoundary = true,这意味着它会创建一个独立的绘制层 (Layer)
-
接管绘制上下文:
- 在
paint阶段,RenderSkeletonizer不会像普通RenderObject那样直接调用super.paint来绘制child,相反它会创建一个SkeletonizerPaintingContext实例,用于拦截绘制
- 在
-
拦截绘制指令:
SkeletonizerPaintingContext内部包含一个SkeletonizerCanvas,当 Flutter 引擎尝试绘制child时(比如Text、Container、Icon等),所有对canvas的操作(如drawParagraph,drawRect,drawImage)都会被SkeletonizerCanvas拦截
-
替换为骨架样式:
SkeletonizerCanvas会根据拦截到的绘制指令的类型和位置,绘制出相应的骨架形态,并实现一些系列绘制方法,比如:- 文本 (
drawParagraph) : 它会计算出文本的每一行在哪里,然后用一系列矩形来代替真实的文字,矩形的圆角、是否对齐等:
- 矩形/圆角矩形 (
drawRect/drawRRect) : 它会检查这个矩形是否被标记为"叶子节点"(比如一个没有子节点的Container或被Skeleton.leaf包裹的 Widget),如果是,它就会使用从PaintingEffect(如ShimmerEffect) 创建的shaderPaint(带有闪烁效果的画笔) 来填充这个区域,如果不是,它可能会根据配置绘制一个纯色背景,或者干脆忽略它:
- ······
- 文本 (
-
应用动画效果:
- 所有用于绘制骨架的
shaderPaint都来自于当前的PaintingEffect,Skeletonizer的AnimationController会不断更新动画值 (animationValue),PaintingEffect根据这个值来创建每一帧的Paint对象 ,对于ShimmerEffect来说,这就表现为一个不断移动的渐变,从而产生了微光流动的效果:

- 所有用于绘制骨架的
而在使用使用中,skeletonizer 也提供了丰富的可配置细节,例如:
-
skeleton.dart: 提供了一系列控制场景:-
Skeleton.ignore: 忽略某个子 Widget,不对其进行骨架化dartCard( child: ListTile( title: Text('The title goes here'), subtitle: Text('Subtitle here'), trailing: Skeleton.ignore( // the icon will not be skeletonized child: Icon(Icons.ac_unit, size: 40), ), ), )
-
Skeleton.leaf: 容器标记为叶子控件,直接还用 shader paint 绘制
dartSkeleton.leaf( child : Card( child: ListTile( title: Text('The title goes here'), subtitle: Text('Subtitle here'), trailing: Icon(Icons.ac_unit, size: 40), ), ) )
-
Skeleton.keep: 在骨架化时,保持某个子 Widget 的原始样貌dartCard( child: ListTile( title: Text('The title goes here'), subtitle: Text('Subtitle here'), trailing: Skeleton.keep( // the icon will be painted as is child: Icon(Icons.ac_unit, size: 40), ), ), )
-
Skeleton.replace: 在骨架化时,用一个替代的 Widget (比如一个简单的灰色方块) 来显示,比如遇到需要Image空间的场景dartCard( child: ListTile( title: Text('The title goes here'), subtitle: Text('Subtitle here'), trailing: Skeleton .replace( // the icon will be replaced when skeletonizer is enabled width: 50, // the width of the replacement height: 50, // the height of the replacement replacement: // defaults to a DecoratedBox child: Icon(Icons.ac_unit, size: 40),), ), );
-
Skeleton.unite: 将多个子 Widget 合并成一个大的骨架块dartCard( child: ListTile( title: Text('Item number 1 as title'), subtitle: Text('Subtitle here'), trailing: Skeleton.unite( child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.ac_unit, size: 32), SizedBox(width: 8), Icon(Icons.access_alarm, size: 32), ], ), ), ), )
作用 场景 Skeleton.ignore完全跳过骨架化 在加载时也需原样显示的 Logo 或品牌元素 Skeleton.leaf将容器标记为终端骨骼 将一个 Card组件显示为一整个实心骨架块Skeleton.keep保持自身,骨架化子孙 保持一个带特殊边框的容器,但骨架化其内部的文本和图标 Skeleton.shade为自定义绘制应用效果 骨架化一个使用 CustomPainter绘制的图表或图形Skeleton.replace在骨架化时替换组件 处理 Image.network,用一个占位方块替换加载中的网络图片Skeleton.unite将多个骨骼合并为一个 将一行紧邻的多个 Icon合并成一个连续的长条形骨架Skeleton.ignorePointers禁用指针事件 防止用户点击处于加载状态的按钮或列表项 -
-
bone.dart: 支持通过Skeletonizer.zone场景,手动自定义提供了一系列预设的"骨骼"Widget,用于手动搭建骨架屏布局,支持:Bone.text()Bone.multiText()Bone.circle()Bone.square()Bone.icon()Bone.button()Bone.iconButton()
dartSkeletonizer.zone( child: Card( child: ListTile( leading: Bone.circle(size: 48), title: Bone.text(words: 2), subtitle: Bone.text(), trailing: Bone.icon(), ), ), );

-
effects/*.dart, 主要用于定义了骨架屏的视觉动画效果,其中painting_effect.dart定义了抽象基类PaintingEffect:-
shimmer_effect.dart: 实现了最常见的"微光"或"闪烁"效果,通过一个滑动的LinearGradient(线性渐变) 来实现 -
pulse_effect.dart: 实现了"脉冲"效果,在两种颜色之间来回渐变 -
sold_color_effect.dart: 纯色效果,没有动画
-

当然,在一些复杂嵌套场景,或者某些特殊控件,比如 SwitchListTile ,还有比如 RoundedSuperellipseBorder 这样的自定义边框形状 等,框架在便利和处理时会无法处理对应的状态或者复现形状,这也算是它的局限性。
但是瑕不掩瑜,除了需要处理的 fake 数据部分,整体使用还是相当便捷,skeletonizer 的自动化能力可以极大地减少样板代码,并保证 UI 占位的一致性,这也是它值的推荐的原因。
那么,你会在你的应用里使用骨架屏吗?