4. 性能分析基础
说到 GUI 卡顿,很多人第一反应是「GPU 不够快」。其实我做了这么多年嵌入式 UI,发现大部分性能瓶颈都卡在 CPU 侧。你想想看,CPU 要算布局、要处理事件、还要管绘制指令的生成——哪一环慢了,画面就卡给你看。
这一章咱们就聚焦 CPU 侧的三个核心优化点:减少布局计算、避免过度绘制、脏矩形算法。这三个东西搞明白了,你的界面至少能快一倍。
4.1 减少布局计算:别让 CPU 做无用功
布局计算说白了就是「算每个控件该放哪」。我见过不少项目,每次刷新都重新算一遍所有控件的位置——哪怕只有一个像素变了。这不是傻算吗?
核心原则:布局计算只发生在真正需要的时候。能缓存就缓存,能跳过就跳过。
4.1.1 布局缓存策略
我个人习惯把布局结果分成三类:
| 类型 | 说明 | 缓存方式 |
|---|---|---|
| 静态布局 | 位置、大小永不改变 | 首次计算后永久缓存 |
| 半动态布局 | 只在特定事件触发时变化 | 标记脏位,按需重算 |
| 全动态布局 | 每帧都可能变化 | 优化算法,减少计算量 |
我在项目中遇到过一种情况:一个仪表盘界面有 30 多个控件,每次刷新都重新计算所有控件的绝对坐标。后来我加了个布局缓存,把静态控件的位置存起来,只有窗口大小变化时才重算。帧率直接从 25fps 飙到了 55fps。
4.1.2 布局树剪枝
很多 GUI 框架的布局是树形结构。如果某个子树完全不可见,那它的布局计算就是浪费。
// 伪代码:布局剪枝
void updateLayout(Widget* node) {
if (!node->isVisible()) {
// 不可见节点,跳过整个子树
return;
}
if (!node->isDirty()) {
// 没有脏标记,跳过
return;
}
// 真正执行布局计算
node->doLayout();
for (auto child : node->children) {
updateLayout(child);
}
}
小技巧:给每个控件加一个 isLayoutDirty 标志位。只有标志位为 true 时才触发布局计算。这样能避免大量无效计算。
4.2 避免过度绘制:画该画的,别画多余的
过度绘制这个词,说白了就是「画了不该画的东西」。比如一个按钮被弹窗挡住了,你还去画按钮——画了也看不见,白费 CPU 时间。
我记得有一次排查一个卡顿问题,发现某个页面有 7 层重叠的控件,每一层都完整绘制。实际上用户只能看到最上面那层。这就是典型的过度绘制。
4.2.1 裁剪区域检测
绘制之前先算清楚:这个控件到底有多少像素是可见的?
// 伪代码:裁剪区域检测
Rect getVisibleRect(Widget* widget, Rect parentClip) {
Rect selfRect = widget->getAbsoluteRect();
// 与父裁剪区求交集
Rect visible = intersect(selfRect, parentClip);
if (visible.isEmpty()) {
// 完全不可见,跳过绘制
return Rect::empty();
}
// 继续传递给子控件
for (auto child : widget->children) {
child->setClipRect(visible);
}
return visible;
}
注意:裁剪检测本身也有开销。不要每帧都做全量裁剪计算。我建议只在控件位置变化或滚动发生时重新计算裁剪区。
4.2.2 层级合并
如果多个控件共享相同的背景色或纹理,可以考虑合并绘制。比如一个面板上有 10 个按钮,背景都是同一个颜色——那就先画一次背景,再在上面画按钮,而不是每个按钮都带背景。
| 做法 | 绘制次数 | CPU 耗时 |
|---|---|---|
| 每个控件独立绘制背景 | 10 次 | 高 |
| 先合并绘制背景 | 1 次背景 + 10 次前景 | 低 |
4.3 脏矩形算法:只刷新变化的部分
这是 CPU 侧优化里最核心的一招。脏矩形算法的思想很简单:只重绘发生变化的那一小块区域,而不是整个屏幕。
你想想看,一个 800x480 的屏幕,如果只是更新了一个 50x50 的图标,为什么要重绘整个屏幕?
4.3.1 脏矩形的基本原理
每次界面发生变化时,记录下变化区域的矩形范围。刷新时只对这个矩形内的内容进行重绘。
// 伪代码:脏矩形管理
class DirtyRectManager {
private:
std::vector<Rect> dirtyRects;
public:
void markDirty(Rect area) {
// 尝试合并相邻的脏矩形
for (auto& rect : dirtyRects) {
if (rect.intersects(area) || rect.isAdjacent(area)) {
rect = rect.unionRect(area);
return;
}
}
dirtyRects.push_back(area);
}
void flush() {
for (auto rect : dirtyRects) {
// 只重绘脏矩形区域
renderer->drawRegion(rect);
}
dirtyRects.clear();
}
};
4.3.2 脏矩形合并策略
脏矩形太多反而会降低效率。我一般用两种合并策略:
- 相邻合并:两个脏矩形如果靠得很近(间距小于阈值),就合并成一个大的。
- 面积阈值:如果脏矩形的总面积超过屏幕面积的 60%,干脆直接全屏刷新——因为合并和裁剪的开销已经不值得了。
经验值:我习惯把相邻合并的阈值设为 10 像素。面积阈值设为 60%。这两个值你可以根据实际硬件调整,但别设得太小,否则合并算法本身就成了瓶颈。
4.3.3 脏矩形与滚动优化
滚动是脏矩形算法最典型的应用场景。我记得之前做一个列表滚动功能,一开始是整屏刷新,卡得不行。后来改成脏矩形 + 局部滚动,流畅度直接翻倍。
// 伪代码:滚动时的脏矩形优化
void scrollList(int offset) {
// 计算滚动产生的脏区域
Rect scrollDirty(0, 0, screenWidth, abs(offset));
// 如果是向上滚动,脏区域在底部
if (offset > 0) {
scrollDirty.y = screenHeight - offset;
}
// 只重绘脏区域
dirtyRectManager->markDirty(scrollDirty);
// 移动已有的像素(用 memcpy 或 DMA)
memmove(screenBuffer + offset, screenBuffer, screenSize - offset);
}
4.4 知识体系总览
下面这张图把 CPU 侧优化的三个核心方向串起来了。你可以把它当作一个检查清单:
4.5 避坑指南
最后分享几个我踩过的坑:
- 脏矩形合并过头:我曾经把合并阈值设得太大,结果整个屏幕都变成了一个脏矩形——还不如全屏刷新呢。后来改成动态阈值,根据脏矩形的数量自动调整。
- 布局缓存没考虑字体变化:有一次改了字体大小,布局缓存没清掉,结果文字都叠在一起了。记住:任何影响布局的参数变化都要清缓存。
- 裁剪检测太频繁:每帧都做全量裁剪检测,反而比直接绘制还慢。我后来改成只在控件移动或层级变化时才重新计算裁剪区。
最后提醒一句:优化不是越多越好。每个优化手段都有开销。先 profiling,找到真正的瓶颈,再动手优化。别一上来就全上,结果优化代码本身成了新的瓶颈。