用 Qt 复刻 Qt Designer 的布局系统

写在前面

本文记录我在开发工业视觉上位机时,从零实现一套 Qt Designer 式布局系统的过程。设计器需要支持”用户拖入控件 → 右键应用布局 → 保存/加载/复制”完整链路,涉及 QLayout 操作、动态类型识别、序列化等多个技术点。

文章较长,建议按章节阅读。代码基于 Qt 5.9 + MSVC 2017。为保护公司代码资产,文中所有实现均以伪代码呈现,仅保留架构思路与关键决策。


1. 概述

1.1 功能目标

在视觉上位机设计器中实现类似 Qt Designer 的布局管理功能,支持两种操作方式:

方式 触发入口 使用场景
容器右键布局 右键菜单 对已存在的多个控件或容器内部子控件应用布局
布局控件拖拽 控件栏拖拽 从控件栏拖入一个布局容器,作为设计元素使用

两者共享底层布局管理器和序列化机制,最终效果一致。

1.2 支持的布局类型

布局 内部实现 特性
水平布局 QHBoxLayout 控件横向排列,支持拉伸因子
垂直布局 QVBoxLayout 控件纵向排列,支持拉伸因子
网格布局 QGridLayout 行列可动态扩展,支持跨行跨列
表单布局 QGridLayout 固定 2 列,只向下扩展行

2. 架构设计

2.1 类关系

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
┌─────────────────────────────────────────────────────────────┐
│ 控件属性接口 (抽象层) │
│ isContainer / addChild / saveLayoutData / loadLayoutData │
└─────────────────────────────────────────────────────────────┘
▲
┌─────────────────────┼─────────────────────┐
│ │ │
┌─────────┐ ┌─────────┐ ┌────────────────┐
│ 分组框 │ │ 标签页 │ │ 布局控件基类 │
│ (容器) │ │ (容器) │ │ │
└─────────┘ └─────────┘ └────────────────┘
▲
┌────────────────────────┼────────────────────┐
│ │ │
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 水平布局 │ │ 垂直布局 │ │ 网格布局 │
└────────────┘ └────────────┘ └────────────┘
┌────────────┐
│ 表单布局 │
└────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 布局管理器 (核心调度层) │
│ applyLayout / breakLayout / save / load / insert │
└─────────────────────────────────────────────────────────────┘

2.2 职责分工

角色 职责
布局管理器 布局应用、打破、序列化、插入位置计算
布局控件基类 容器接口实现(isContainer、addChild、深拷贝)
4 个布局派生类 各自的 newWidget 与类型标识
主窗口 集成布局管理器、右键菜单、拖拽落点、复制逻辑
分组框 / 标签页 通过统一的持久化接口参与布局序列化

3. 两种操作方式对比

3.1 操作流程

步骤 容器右键布局 布局控件拖拽
入口 右键菜单 → 布局 控件栏 → 拖入设计器
目标 现有控件的父容器 布局控件自身
生成物 在容器上设置布局对象 创建一个带布局对象的容器控件
占位符 无 有
可独立选中 否 是
可删除 通过”打破布局” 直接删除控件

3.2 数据模型

1
2
3
4
5
6
7
8
9
10
11
容器右键布局:
GroupBox (QWidget)
└── QHBoxLayout ← 直接设置在 GroupBox 上
├── Button1
└── Button2

布局控件拖拽:
LayoutHorizontal (QWidget, 带类型标记属性)
└── QHBoxLayout ← 设置在 LayoutHorizontal 上
├── Button1
└── Button2

区别:容器右键布局不增加对象树深度;布局控件增加一层 QWidget 深度,但具备可选中、可复制的特性。


4. 右键布局实现

4.1 菜单结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
右键控件
├── 编辑属性
├── 置于顶层 / 置于底层
├── ─────────────
├── 布局 ▼
│ ├── 水平布局(选中控件) ← 选中多个控件时可用
│ ├── 垂直布局(选中控件)
│ ├── 网格布局(选中控件)
│ ├── 表单布局(选中控件)
│ ├── ─────────────
│ ├── 水平布局(容器内部) ← 选中单个容器时可用
│ ├── 垂直布局(容器内部)
│ ├── 网格布局(容器内部)
│ ├── 表单布局(容器内部)
│ ├── ─────────────
│ └── 打破布局
├── ─────────────
├── 删除 / 复制 / 粘贴

4.2 启用条件

菜单项 启用条件
布局(选中控件) 选中 ≥2 个控件,有共同父控件,不含布局控件
布局(容器内部) 选中单个容器,内部有效子控件 ≥1 个
打破布局 选中控件、父控件或标签页当前页有布局

4.3 核心流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
场景1:选中多个控件
applyLayoutToParent(类型, 父控件, 控件列表)
1. 删除旧布局(保留子控件)
2. 按类型创建新布局
3. 设置边距、间距、列拉伸
4. 将控件添加到布局
5. 激活布局

场景2:选中容器内部布局
applyLayoutInsideContainer(类型)
1. 通过容器接口获取实际容器
(分组框返回自身,标签页返回当前页)
2. 过滤有效子控件(排除占位符、布局控件)
3. 清除旧布局
4. 调用 applyLayoutToParent

5. 布局控件实现

5.1 布局控件基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class 布局控件基类 : public 控件属性接口
{
// 容器接口
isContainer() → true
containerForWidget(父控件, 子控件) → 返回父控件自身
addChild(父控件, 子控件, 位置) → 见 §6
getContainerChildren(容器) → 返回直接子控件

// 持久化接口
saveLayoutData(容器, map) → 委托布局管理器序列化
loadLayoutData(容器, map) → 委托布局管理器反序列化

// 复制接口
deepCopyContainerChildren(源, 目标, 偏移, 映射表)
}

5.2 初始化布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
initLayout(方向, 控件, 边距, 间距):
// 启用样式表背景绘制
控件.启用样式表背景
控件.设置最小尺寸(80, 50)
控件.设置对象名("LayoutContainer")

// 红色实线边框(设计模式可见)
控件.设置样式表("边框红色、背景透明")

// 根据方向创建具体布局(用具体类方便后续类型判断)
if 方向是水平:
布局 = new QHBoxLayout(控件)
else:
布局 = new QVBoxLayout(控件)

布局.设置边距(边距)
布局.设置间距(间距)
控件.设置布局(布局)

5.3 表单布局特殊处理

表单布局固定 2 列,内部用 QGridLayout:

1
2
3
4
5
6
7
8
9
10
11
12
表单布局::newWidget(父控件):
控件 = new QWidget(父控件)
控件.设置类型标记属性(表单布局)

布局 = new QGridLayout(控件)
布局.设置边距(5, 5, 5, 5)
布局.设置间距(6)
布局.设置列拉伸(0, 1)
布局.设置列拉伸(1, 1) // 固定 2 列

控件.设置布局(布局)
返回 控件

6. 拖拽插入逻辑

6.1 通过类型标记区分布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
主窗口::addWidgetToContainer(父控件, 子控件, 位置):
布局 = 父控件.布局()
if 布局 为空:
绝对定位处理
返回

if 布局 是 QBoxLayout:
按鼠标位置插入(见 §6.3)
else if 布局 是 QGridLayout:
类型 = 父控件.读取类型标记属性()
if 类型 是 表单布局:
insertIntoGridLayout(布局, 子控件, 位置, 最大列数=2)
else:
insertIntoGridLayout(布局, 子控件, 位置, 最大列数=-1) // 不限制

6.2 网格插入中的列数限制

insertIntoGridLayout 通过 maxColLimit 参数控制:

maxColLimit 行为
2 列数达上限时禁止新增列,鼠标在右侧时换行到下一行
-1 无限制,可自由扩展行列

关键逻辑(伪代码):

1
2
3
4
5
6
7
8
9
10
列数已满 = (maxColLimit > 0) 且 (当前最大列 >= maxColLimit)

if 目标格已被占用:
if 列数已满:
// 不允许右移,改找下一个空位
扫描当前行、后续行的空位
若全部满了,追加到新行
else:
// 未达上限:正常右移
目标行.在目标列插入(子控件)

6.3 水平/垂直布局插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if 布局 是 QBoxLayout:
是否水平 = (布局.方向() 是 从左到右 或 从右到左)

// 计算鼠标在布局方向上的坐标
鼠标坐标 = 是否水平 ? 位置.x : 位置.y

// 遍历控件,找到插入位置
插入索引 = 布局.数量()
for i in 0..布局.数量():
控件 = 布局.第i项().控件()
if 控件 可见:
中心 = 是否水平 ? 控件.几何().中心().x : 控件.几何().中心().y
if 鼠标坐标 < 中心:
插入索引 = i
break

布局.插入控件(插入索引, 子控件)

7. 布局持久化

7.1 数据流

1
2
3
4
5
6
7
8
9
10
11
保存:
布局控件基类::save(控件)
├── 保存基本属性(位置、尺寸、边距、间距…)
└── 布局管理器::saveLayoutToMap(控件.布局(), 子map)
└── 写入 map["LayoutData"]

加载:
布局控件基类::load(控件, map)
├── 恢复基本属性
└── 读取 map["LayoutData"]
└── 布局管理器::loadLayoutFromMap(控件, 子map)

7.2 序列化

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
saveLayoutToMap(布局, map):
// 类型判断
if 布局 是 QHBoxLayout: map["Type"] = "HBox"
else if 布局 是 QVBoxLayout: map["Type"] = "VBox"
else if 布局 是 QGridLayout:
// 通过父控件的类型标记区分 网格 与 表单
类型 = 父控件.读取类型标记()
map["Type"] = (类型 是 表单布局) ? "Form" : "Grid"
else: 返回

// 保存公共属性
map["Margin"] = 布局.边距.左
map["Spacing"] = 布局.间距

// 盒式布局:顺序 + 拉伸因子
if 布局 是 QBoxLayout:
for i in 0..布局.数量():
控件 = 布局.第i项().控件()
顺序列表.追加(控件.读取唯一标识())
拉伸列表.追加(布局.拉伸(i))
map["Order"] = 顺序列表
map["Stretches"] = 拉伸列表

// 网格布局:控件位置 + 行列拉伸
else if 布局 是 QGridLayout:
for i in 0..布局.数量():
(行, 列, 跨行, 跨列) = 布局.获取项位置(i)
控件 = 布局.第i项().控件()
item.唯一标识 = 控件.读取唯一标识()
item.行 / 列 / 跨行 / 跨列 = 对应值
GridItems.追加(item)
map["GridItems"] = GridItems

map["ColStretches"] = [列拉伸 for each 列]
map["RowStretches"] = [行拉伸 for each 行]

7.3 反序列化

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
loadLayoutFromMap(容器, map):
if 容器 为空 或 map 无 "Type" 键:
返回

类型 = map["Type"]
边距 = map["Margin"] 默认 5
间距 = map["Spacing"] 默认 6

// 关键:先清理旧布局(兼容布局控件自带初始布局的情况)
if 容器 已有布局:
for each 旧布局项:
子控件 = 旧布局项.控件()
if 子控件 是 占位符: 延迟删除
else: 重新指定父控件为容器
删除 旧布局

// 创建新布局
if 类型 == "HBox": 新布局 = new QHBoxLayout(容器)
else if 类型 == "VBox": 新布局 = new QVBoxLayout(容器)
else if 类型 == "Grid": 新布局 = new QGridLayout(容器)
else if 类型 == "Form": 新布局 = new QGridLayout(容器); 标记为表单
else: 返回

新布局.设置边距 / 间距

// 获取直接子控件,按唯一标识恢复控件位置(逆向 saveLayoutToMap)
...

// 表单布局恢复类型标记
if 是表单:
容器.设置类型标记(表单布局)

新布局.激活()

8. 布局复制

8.1 复制流程

1
2
3
4
5
6
7
8
9
10
11
递归复制(源布局控件/容器)
├── 创建新控件
├── 复制属性
├── 旧→新 映射表[源] = 新
└── 若是容器 → 深拷贝子控件
├── 复制所有子控件(递归处理嵌套容器)
└── duplicateLayout(源, 目标, 映射表)
├── 关键①:先清理目标上的旧布局
├── 创建相同类型的新布局
├── 按映射表恢复控件位置和拉伸
└── 关键②:恢复类型标记(保证表单身份不丢失)

8.2 关键点

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
duplicateLayout(源, 目标, 映射表):
// 防重入守卫
if 正在复制: 返回
标记为正在复制
RAII 守卫:退出时自动取消标记

源布局 = 源.布局()
if 源布局 为空: 返回

// 关键①:清理目标上的旧布局(解决布局控件自带初始布局的问题)
if 目标 已有布局:
for each 旧布局项:
子控件 = 旧布局项.控件()
if 子控件 是 占位符: 延迟删除
else: 重新指定父控件为目标
删除 旧布局

// 复制网格布局
if 源布局 是 QGridLayout:
新布局 = new QGridLayout(目标)
复制边距、间距、行列拉伸
按映射表恢复控件位置
新布局.激活()

// 关键②:恢复类型标记
源类型 = 源.读取类型标记()
if 源类型 != 0: 目标.设置类型标记(源类型)
返回

// 复制盒式布局(用 direction() 判断方向,而非类型)
if 源布局 是 QBoxLayout:
方向 = 源布局.方向()
新布局 = (方向 是 水平) ? new QHBoxLayout : new QVBoxLayout
按映射表恢复控件和拉伸
新布局.激活()

源类型 = 源.读取类型标记()
if 源类型 != 0: 目标.设置类型标记(源类型)

9. 打破布局

9.1 目标控件查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
breakLayout():
目标 = null
for each 选中控件 w:
// ① 标签页的当前页(特殊处理)
if w 是 QTabWidget:
if w.当前页 且 当前页.有布局:
目标 = w.当前页; break

// ② 控件自身有布局
if w.有布局(): 目标 = w; break

// ③ 直接父控件有布局
if w.父控件 且 w.父控件.有布局():
目标 = w.父控件; break

// 从布局取出所有控件,恢复绝对定位
...

9.2 各容器打破行为

选中对象 打破行为
分组框(自身有布局) 打破分组框的布局
分组框内的子控件 打破分组框的布局(直接父控件有布局)
标签页(自身无布局) 打破当前页的布局
标签页分页内的控件 打破该分页的布局
布局控件 打破布局控件内部的布局
嵌套容器内的控件 只打破直接父容器的布局,不影响外层

10. 关键设计决策

决策 原因
容器右键布局直接设置布局对象 不增加对象树深度,符合 Qt Designer 风格
布局控件使用 QWidget + 内部布局 需要可选中、可复制、可删除的特性
表单布局内部用 QGridLayout 避免 QFormLayout 自动创建标签;固定 2 列通过列上限控制
替换布局前先清理旧布局 兼容布局控件自带初始布局的场景,避免 Qt 拒绝设置第二个布局
复制完成后恢复类型标记 防御性编程,避免复制路径中属性丢失,确保表单身份识别
用 direction() 而非类型判断布局方向 QBoxLayout 是抽象基类,用 qobject_cast<QHBoxLayout*> 可能失败

11. 涉及模块清单

模块 职责
布局管理器 布局管理核心(应用 / 打破 / 序列化 / 插入)
布局控件基类 + 4 个派生类 各布局控件的实现
主窗口 集成、右键菜单、拖拽落点、复制逻辑
枚举与字符串映射 布局相关枚举、类型字符串互转
控件栏 布局分组
各容器类型 通过统一的持久化接口参与布局序列化

12. 扩展指南

12.1 新增布局类型

  1. 枚举中增加新类型
  2. 布局管理器的布局类型枚举增加项
  3. 字符串映射表增加对应关系
  4. 实现对应的布局派生类
  5. 布局创建分支添加新 case
  6. 序列化 / 反序列化添加支持
  7. 控件栏添加条目
  8. 右键菜单添加项

12.2 新增支持布局的容器

  1. 确保 isContainer() 返回 true
  2. save 中调用布局管理器序列化接口,写入 LayoutData
  3. loadLayoutData 中调用反序列化接口
  4. containerForWidget 返回实际容纳子控件的 QWidget

13. 总结

维度 成果
操作模式 容器右键布局(动态)+ 布局控件拖拽(静态)
布局类型 水平 / 垂直 / 网格 / 表单(固定 2 列)
持久化 所有容器通过统一接口参与布局序列化
复制支持 布局控件、嵌套容器均支持完整复制
兼容性 先清理旧布局再创建新布局,兼容自带初始布局的控件
可扩展性 新增布局类型或容器只需实现接口,无需修改核心逻辑

核心设计原则:

  1. 接口抽象:布局逻辑下沉到基类,主窗口只做流程调度
  2. 职责分离:容器负责持久化接口,布局管理器负责序列化实现
  3. 防御性编程:所有布局替换前先清理旧布局,避免 Qt 状态冲突
  4. 单一职责:每种布局类型独立类实现,新增类型不影响现有逻辑

后续计划

这是”Qt 上位机开发”系列的第一篇。后续将陆续分享:

  • 拖拽落点计算与高亮指示器实现
  • 属性面板的树形编辑与数据绑定
  • 内置 AI 问答助手(离线 + 在线混合方案)
  • 工控机环境下的性能优化实践

欢迎关注交流。


改写说明

所有具体代码已替换为伪代码,主要处理:

  1. 类名 / 函数名:WidgetProperty_LayoutBase → 布局控件基类;FormWindowLayoutManager → 布局管理器;FormWindow → 主窗口
  2. 方法调用:w->setGeometry(r) → 控件.设置几何(矩形);grid->getItemPosition(i, ...) → 布局.获取项位置(i)
  3. 属性名:WidgetType、ObjectIndex、_isPlaceholder 全部抽象化
  4. Qt 公开 API 保留:QHBoxLayout、QGridLayout、QBoxLayout::direction() 等属于公开文档知识,无泄密风险
  5. 表格 / 架构图:保留原结构,只把具体实现细节改为抽象描述

这样既保留技术文章的价值(架构思路、设计决策),又不会暴露公司具体实现。

1
2
3
4
5
6
7
8
9
10
## 后续计划

这是"Qt 上位机开发"系列的第一篇。后续将陆续分享:

- 拖拽落点计算与高亮指示器实现
- 属性面板的树形编辑与数据绑定
- 内置 AI 问答助手(离线 + 在线混合方案)
- 工控机环境下的性能优化实践

欢迎关注交流。