StellarX星垣GUI官网:点击跳转
我在做 StellarX(星垣 GUI 框架)时,最先要解决的不是“控件功能多不多”,而是“界面在频繁交互下是否稳定”。只要涉及输入、hover、高亮、切换页面,很多自研 GUI 框架都会被同一类问题拖住:闪烁、残影、脏帧叠画、以及为了掩盖问题而引入的全量重绘带来的性能浪费。
StellarX 的渲染后端目前使用的是EasyX图形库来完成像素级绘制,但我始终把它当作“后端实现细节”。框架层真正要建立的是一套可解释、可复用、可扩展的刷新机制。为此,我在 StellarX 中固化了一条稳定的渲染链路:
- 背景快照(snapshot):控件重绘前先把自己区域恢复成“没画控件”的样子
- 脏标记(dirty):状态没变就不画,变了才画
- 局部重绘(requestRepaint):父容器只重绘变脏且可见的子控件
这篇文章以 StellarX 的第一视角,把这套机制讲透,并对照 Qt 在同类场景下的做法,说明两者本质上在解决同一个问题:明确擦除语义 + 限定刷新范围。
1. 我想解决的核心问题:控件交互频繁时,画面必须稳定
典型界面往往具备这样的结构:
- 大面积背景(插画、渐变、主题底图)基本不变
- 动态区域集中在少量控件:TextBox 输入/焦点、Button hover/click、Tab 页切换等
在这种界面结构下,如果我把刷新策略做错,问题会非常明显:
- 只做覆盖绘制:TextBox 文本变化、边框变化会留残影
- 依赖全量重绘:大背景反复绘制,闪烁明显、性能浪费
- 可见性/页签切换处理不严谨:多页叠画造成脏帧
所以框架层必须回答两个问题:
- 如何把旧画面擦干净?
- 如何把重绘范围收敛到最小?
2. 我在 StellarX 里的答案:把“擦除语义”做成控件能力
在立即式绘制模型里,“画”本身很简单,“擦除”才是最难的部分。我最后把控件绘制流程固定成一个顺序:
- 必要时抓取背景快照(首次绘制 / 尺寸变化 / 快照失效)
- 每次重绘前先回贴快照(恢复背景,等价于擦除旧画面)
- 再绘制当前状态(边框、背景、文本等)
- 清掉 dirty
概念级结构如下(表达流程,不绑定具体后端 API):
classControl{public:voiddraw(){if(!visible||!dirty)return;if(!hasSnapshot||snapshotSizeChanged()){saveBackgroundSnap(x,y,w,h);}restoreBackgroundSnap();// 擦除旧画面paint();// 绘制新状态dirty=false;}protected:voidsaveBackgroundSnap(intx,inty,intw,inth);voidrestoreBackgroundSnap();booldirty=true;boolvisible=true;intx,y,w,h;};与 Qt 对照:Qt 把“擦除与缓冲”托管在绘制系统里
在 Qt(以 QWidget 体系为例)里,我通常不需要显式维护“背景快照”,原因是:
- 状态变化时我调用
update()(或局部update(rect)),Qt 会安排一次重绘; - Qt 的 backing store/双缓冲与裁剪区域(dirty region)由框架处理;
- 子控件(如
QLineEdit、QPushButton)的绘制与擦除路径由控件内部实现。
Qt 的用户侧写法更像“声明需要重绘”,而不是“手写擦除策略”:
voidMyButton::enterEvent(QEnterEvent*){hovered=true;update();}对照关系很直接:
- StellarX 的
dirty + draw()是框架内的“可控刷新协议”; - Qt 的
update() + paintEvent()是框架提供的“重绘协议”。
3. 脏标记(dirty):我把“何时重绘”压缩成一个布尔量
为了避免无意义的重复绘制,我把重绘约束成:
- 视觉输出会变→
dirty = true draw()发现不 dirty → 直接 return
对 Button 来说,最典型的就是 hover/click:
boolButton::handleEvent(constEvent&e){boololdHover=hover;boololdClick=clicked;updateHoverAndClick(e);if(hover!=oldHover||clicked!=oldClick){dirty=true;requestRepaint(parent);}returnhover||clicked;}对 TextBox 来说,dirty 触发点更多:
- 输入/删除字符(内容变化)
- 焦点获得/失去(边框、光标显示变化)
- 密码模式下掩码字符数量变化(显示变化)
与 Qt 对照:dirty 的语义对应 update() 的调用边界
Qt 里我不会自己保存dirty字段,但我会明确哪些状态变化需要update()。
StellarX 里我把这个语义内化成统一规则:状态变化 → dirty,并让绘制入口完全尊重 dirty。
4. 局部重绘(requestRepaint):我让父容器只画“脏且可见”的控件
如果控件变脏后总是触发全量重绘,dirty 的意义会被抵消。因此我把“重绘调度权”放在父容器(Canvas):
classCanvas:publicControl{public:voidrequestRepaint(Control*callerParent){for(auto&c:children){if(c->isVisible()&&c->isDirty()){c->draw();}}}};这带来的直观效果是:
- hover 只重画按钮的矩形区域
- 输入只重画输入框的矩形区域
- 大背景稳定,不参与重复绘制
- 整体观感更接近“成熟 GUI 框架”的稳定性
与 Qt 对照:局部重绘由框架默认完成
Qt 的控件树是保留式(retained-mode)体系,子控件自己管理重绘,框架负责:
- 合并无效区域(dirty region)
- 进行裁剪绘制与缓冲合成
因此 Qt 里通常不需要我写“扫描 children 并 draw dirty”的逻辑;但在 StellarX 里,这正是我需要补齐的框架能力。
5. 事件分发顺序:我采用逆序命中,匹配 Z 序直觉
容器控件的事件分发如果不考虑层级,很容易出现“点到上层却被下层吃掉”的问题。我的取舍是:
- 按添加顺序,后加入的控件通常在视觉上更“上层”
- 事件分发采用逆序遍历(从后往前),更符合命中直觉
Qt 里类似语义由事件传播与控件层级体系处理;在 StellarX 里我需要把这条规则明确写进容器逻辑,减少交互歧义。
6. 多页容器的关键:绘制集合必须收敛,才能避免叠画脏帧
在 TabControl 这类多页容器里,如果可见性切换处理不当,很容易出现“恢复可见后多页一起画”的叠画问题。我的原则是:
- 任意时刻参与绘制的页面集合最多为 1(激活页)
- 无激活页则不绘制任何页
- 容器隐藏时所有页都不可见/不绘制
与 Qt 对照:QTabWidget/QStackedWidget 天然只显示当前页
Qt 的QTabWidget、QStackedWidget的基本语义就是“只显示 current widget”。
这类问题在 Qt 的默认控件里通常不会以同样方式出现,而在自研框架里必须明确写成不变量,并在可见性与切换逻辑里严格执行。
7. 我确认过的边界点:哪些场景需要更谨慎?
这套机制在大多数“背景大、控件少、交互频繁”的界面里非常有效,但我也确认了几个敏感点:
7.1 控件重叠
背景快照是“恢复背后像素”。当控件区域发生重叠时,A 回贴快照可能影响 B 的显示。
因此我在布局层尽量避免控件重叠(或不对重叠效果做强保证)。
7.2 边框/圆角/阴影外扩
快照抓取范围如果只等于控件矩形本体,外扩效果可能擦不干净。
我在抓取快照时会向外扩 margin,以覆盖线宽与抗锯齿边缘。
7.3 密码模式
密码模式属于显示遮罩(显示*),不属于安全存储。真实文本仍在控件内部。
8. 我复用的最小模板:把后端差异彻底藏到框架内部
我把机制压缩成三段逻辑,任何后端都可以实现save/restore,框架层语义不变:
// 1) 状态变化 -> dirtyvoidControl::onStateChanged(){dirty=true;requestRepaint(parent);}// 2) draw:dirty 才画;先擦再画voidControl::draw(){if(!dirty||!visible)return;if(needResnap())saveBackgroundSnap(x,y,w,h);restoreBackgroundSnap();// 擦掉旧画面paint();// 画新状态dirty=false;}// 3) 容器:局部重绘 dirty 子控件voidCanvas::requestRepaint(Control*parent){for(auto&child:children)if(child->isVisible()&&child->isDirty())child->draw();}与 Qt 的对应关系非常明确:
dirty = true相当于我在 Qt 里调用update()requestRepaint相当于 Qt 内部的无效区域合并与裁剪绘制snapshot restore相当于 Qt 的缓冲/擦除路径(由框架托管)
结尾:我在 StellarX 里坚持的刷新哲学
我最终把界面稳定性归结为两件事:
- 擦除语义必须明确:控件重绘前必须能把自己画过的区域恢复干净
- 刷新范围必须收敛:能只画一个控件就不要画全窗口,能只画一个矩形就不要画整页
Qt 作为成熟框架,默认把这两件事做成了基础设施;而 StellarX 的工作就是把它们以最少概念、最可读的方式内化到框架层,从而让上层控件的功能扩展不被渲染问题拖住。