Qt 骨架屏组件的设计与实现

在现代应用开发中,骨架屏(Skeleton Screen)已经成为提升用户体验的重要手段。相比传统的加载动画,骨架屏能够让用户提前感知页面结构,减少等待时的焦虑感。本文将详细介绍如何在 Qt 框架下实现一个功能完整的骨架屏组件库。

先看效果

核心设计思路

骨架屏的本质是在内容加载期间显示一个与真实内容结构相似的占位符。这个占位符通常包含灰色的矩形、圆形等基本形状,并配有一个移动的高亮光带来模拟加载过程。

我们的设计目标是创建一个可组合的骨架屏系统,支持多种基本形状(矩形、圆角矩形、圆形),并且能够通过组合这些基本元素来构建复杂的骨架布局。更重要的是,整个骨架屏应该只有一个统一的高亮动画,而不是每个元素都有独立的动画效果。

基础架构设计

SkeletonBase 基类

所有骨架组件的基础是 SkeletonBase 类,它继承自 QWidget 并提供了高亮动画的核心功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SkeletonBase(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._shimmer_position = -100 # 高亮位置
self._animation = QPropertyAnimation(self, b"shimmer_position")
self._setup_animation()

def _setup_animation(self):
"""配置高亮动画"""
self._animation.setDuration(1000) # 动画时长
self._animation.setStartValue(-100) # 起始位置
self._animation.setEndValue(self.width() + 100) # 结束位置
self._animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
self._animation.setLoopCount(-1) # 无限循环

@pyqtProperty(float)
def shimmer_position(self):
return self._shimmer_position

@shimmer_position.setter
def shimmer_position(self, value):
self._shimmer_position = value
self.update() # 触发重绘

这里有几个关键的设计决策需要解释。首先, _shimmer_position 的初始值设为 -100,这是为了让高亮光带从组件的左侧外部开始移动,确保用户能看到完整的进入动画。其次,使用 QPropertyAnimation 配合 @pyqtProperty 装饰器,这样可以让 Qt 的动画系统自动调用我们的 setter 方法,从而触发界面重绘。

绘制系统的实现

骨架屏的视觉效果主要通过重写 paintEvent 方法来实现。这个方法需要处理两个层次的绘制:基础形状和高亮效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)

# 绘制基础骨架形状
path = self._create_shape_path()
painter.fillPath(path, QColor(210, 210, 210))

# 设置裁剪区域,确保高亮效果只在骨架形状内显示
painter.setClipPath(path)

# 绘制高亮光带
self._draw_shimmer_effect(painter)

裁剪区域的设置是一个重要的技术细节。通过 painter.setClipPath(path) ,我们确保后续的绘制操作只会在指定的形状内生效。这样,即使高亮光带的渐变范围超出了骨架形状,也不会影响到其他区域。

高亮光带的实现原理

高亮光带的核心是 QLinearGradient ,它创建了一个从透明到半透明白色再到透明的渐变效果:

1
2
3
4
5
6
7
8
9
10
11
12
def _draw_shimmer_effect(self, painter):
gradient = QLinearGradient()
gradient.setStart(self._shimmer_position - 50, 0)
gradient.setFinalStop(self._shimmer_position + 50, 0)

# 设置渐变颜色
gradient.setColorAt(0, QColor(255, 255, 255, 0))
gradient.setColorAt(0.5, QColor(255, 255, 255, 80))
gradient.setColorAt(1, QColor(255, 255, 255, 0))

# 绘制光带
painter.fillRect(rect, QBrush(gradient))

这里的关键是理解渐变的坐标系统。 setStart 和 setFinalStop 定义了渐变的方向,在这个例子中是从左到右的水平渐变。 setColorAt 方法则定义了渐变过程中不同位置的颜色,0.0 表示起点,1.0 表示终点,0.5 表示中点。

高亮区域的宽度设为 100 像素,太窄会显得不够明显,太宽则会影响整体的精致感。

具体形状的实现

矩形骨架项

最基础的矩形骨架项实现相对简单,主要是创建无圆角和一个带圆角的矩形路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SkeletonItem(SkeletonBase):
"""基础骨架项 - 矩形"""

def __init__(self, width=100, height=20, parent=None):
super().__init__(parent)
self.setFixedSize(width, height)
self._background_color = QColor(210, 210, 210)

def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)

rect = self.rect()

# 绘制背景
painter.fillRect(rect, self._background_color)

# 绘制光带
self.draw_shimmer(painter, rect)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class RoundedRectSkeletonItem(SkeletonBase):
"""圆角矩形骨架项"""

def __init__(self, width=100, height=20, radius=8, parent=None):
super().__init__(parent)
self.setFixedSize(width, height)
self._radius = radius
self._background_color = QColor(210, 210, 210)

def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)

rect = QRectF(self.rect())

# 创建圆角路径
path = QPainterPath()
path.addRoundedRect(rect, self._radius, self._radius)

# 绘制背景
painter.fillPath(path, self._background_color)

# 设置裁剪区域并绘制光带
painter.setClipPath(path)
self.draw_shimmer(painter, self.rect())

圆形骨架项

圆形骨架项需要特别注意的是如何创建一个真正的圆形。简单使用 addEllipse 方法可能会因为宽高不一致而产生椭圆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CircleSkeletonItem(SkeletonBase):
"""圆形骨架项"""

def __init__(self, diameter=40, parent=None):
super().__init__(parent)
self.setFixedSize(diameter, diameter)
self._background_color = QColor(210, 210, 210)

def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)

rect = QRectF(self.rect())

# 创建圆形路径
path = QPainterPath()
path.addEllipse(rect)

# 绘制背景
painter.fillPath(path, self._background_color)

# 设置裁剪区域并绘制光带
painter.setClipPath(path)
self.draw_shimmer(painter, self.rect())

容器系统的设计

单个骨架项只能显示简单的形状,真正的挑战在于如何将多个骨架项组合成复杂的布局,并且保持统一的高亮动画效果。

SkeletonContainer 的核心逻辑

SkeletonContainer 类负责管理多个骨架项,并提供统一的动画控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class SkeletonContainer(QWidget):
"""骨架容器基类,提供整体光带效果"""

def __init__(self, parent=None):
super().__init__(parent)
self._shimmer_position = -100
self._animation = None
self._skeleton_items = []
self._is_animating = False

@pyqtProperty(float)
def shimmer_position(self):
return self._shimmer_position

@shimmer_position.setter
def shimmer_position(self, value):
self._shimmer_position = value
# 更新所有子骨架项的光带位置
for item in self._skeleton_items:
if hasattr(item, '_shimmer_position'):
item._shimmer_position = value
item.update()

def add_skeleton_item(self, item):
"""添加骨架项到容器"""
if isinstance(item, SkeletonBase):
self._skeleton_items.append(item)
# 停止子项的独立动画
item.stop_animation()

def start_animation(self):
"""开始整体光带动画"""
if self._animation:
self._animation.stop()

self._animation = QPropertyAnimation(self, b"shimmer_position")
self._animation.setDuration(1000)
self._animation.setStartValue(-100)
self._animation.setEndValue(self.width() + 100)
self._animation.setLoopCount(-1)
self._animation.start()
self._is_animating = True

def stop_animation(self):
"""停止整体光带动画"""
if self._animation:
self._animation.stop()
self._is_animating = False

def resizeEvent(self, event):
super().resizeEvent(event)
if self._is_animating:
self.start_animation()

这里使用 isinstance 进行类型检查,确保只有合法的骨架项才能被添加到容器中。同时,调用 item.stop_animation() 停止子项的独立动画,避免出现多个不同步的动画效果。

动画同步机制

容器的关键功能是将自己的动画状态同步到所有子项:

1
2
3
4
5
6
7
8
9
@shimmer_position.setter
def shimmer_position(self, value):
self._shimmer_position = value
# 同步所有子项的动画位置
for item in self._skeleton_items:
if hasattr(item, '_shimmer_position'):
item._shimmer_position = value
item.update()
self.update()

工厂模式的应用

为了简化骨架项的创建过程,我们引入了工厂模式。 Skeleton 类提供了一系列静态方法来创建不同类型的骨架项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Skeleton:
"""骨架屏工厂类"""

@staticmethod
def create_item(width=100, height=20):
"""创建基础矩形骨架项"""
return SkeletonItem(width, height)

@staticmethod
def create_rounded_item(width=100, height=20, radius=8):
"""创建圆角矩形骨架项"""
return RoundedRectSkeletonItem(width, height, radius)

@staticmethod
def create_circle_item(diameter=40):
"""创建圆形骨架项"""
return CircleSkeletonItem(diameter)

@staticmethod
def create_circle_persona(avatar_size=50):
"""创建圆形头像骨架"""
return CirclePersonaSkeleton(avatar_size)

@staticmethod
def create_square_persona(avatar_size=50):
"""创建方形头像骨架"""
return SquarePersonaSkeleton(avatar_size)

@staticmethod
def create_article():
"""创建文章骨架"""
return ArticleSkeleton()

预设组件的实现

基于基础的骨架项和容器系统,我们可以创建一些常用的预设组件。以文章骨架为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ArticleSkeleton(SkeletonContainer):
def __init__(self, parent=None):
super().__init__(parent)
self._build_layout()

def _build_layout(self):
# 标题
title = Skeleton.create_rectangle(300, 24)
self.add_skeleton_item(title)

# 间距
self._layout.addSpacing(16)

# 内容行
for i in range(3):
width = 400 if i < 2 else 250 # 最后一行较短
line = Skeleton.create_rectangle(width, 16)
self.add_skeleton_item(line)
self._layout.addSpacing(8)

预设组件继承了容器的所有功能,包括统一的动画效果,同时提供了特定场景下的便利性。

技术细节与优化

在实现过程中,有几个技术细节值得特别注意。首先是动画的性能优化,使用 QPropertyAnimation 而不是定时器来驱动动画,这样可以利用 Qt 的硬件加速功能。其次是内存管理,通过合理的父子关系设置,确保组件能够正确地被垃圾回收。

另一个重要的优化是避免不必要的重绘。只有当 shimmer_position 发生变化时,才调用 update() 方法触发重绘。这样可以显著减少 CPU 使用率,特别是在有多个骨架屏同时运行的情况下。

总结

通过合理的架构设计和细致的实现,创建了一个既易用又灵活的骨架屏系统。它不仅能够满足常见的使用场景,还为特殊需求提供了足够的扩展空间。