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 侧优化的三个核心方向串起来了。你可以把它当作一个检查清单:

CPU 侧优化知识体系 CPU 侧优化 减少布局计算 避免过度绘制 脏矩形算法 布局缓存 布局树剪枝 脏标记位 裁剪区域检测 层级合并 可见性检测 脏矩形合并 局部刷新 滚动优化 核心思路:让 CPU 只做必要的工作 布局缓存 + 裁剪检测 + 脏矩形 = 流畅的嵌入式 GUI

4.5 避坑指南

最后分享几个我踩过的坑:

  • 脏矩形合并过头:我曾经把合并阈值设得太大,结果整个屏幕都变成了一个脏矩形——还不如全屏刷新呢。后来改成动态阈值,根据脏矩形的数量自动调整。
  • 布局缓存没考虑字体变化:有一次改了字体大小,布局缓存没清掉,结果文字都叠在一起了。记住:任何影响布局的参数变化都要清缓存。
  • 裁剪检测太频繁:每帧都做全量裁剪检测,反而比直接绘制还慢。我后来改成只在控件移动或层级变化时才重新计算裁剪区。

最后提醒一句:优化不是越多越好。每个优化手段都有开销。先 profiling,找到真正的瓶颈,再动手优化。别一上来就全上,结果优化代码本身成了新的瓶颈。