用 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 新增布局类型
枚举中增加新类型
布局管理器的布局类型枚举增加项
字符串映射表增加对应关系
实现对应的布局派生类
布局创建分支添加新 case
序列化 / 反序列化添加支持
控件栏添加条目
右键菜单添加项
12.2 新增支持布局的容器
确保 isContainer() 返回 true
save 中调用布局管理器序列化接口,写入 LayoutData
loadLayoutData 中调用反序列化接口
containerForWidget 返回实际容纳子控件的 QWidget
13. 总结
维度
成果
操作模式
容器右键布局(动态)+ 布局控件拖拽(静态)
布局类型
水平 / 垂直 / 网格 / 表单(固定 2 列)
持久化
所有容器通过统一接口参与布局序列化
复制支持
布局控件、嵌套容器均支持完整复制
兼容性
先清理旧布局再创建新布局,兼容自带初始布局的控件
可扩展性
新增布局类型或容器只需实现接口,无需修改核心逻辑
核心设计原则:
接口抽象 :布局逻辑下沉到基类,主窗口只做流程调度
职责分离 :容器负责持久化接口,布局管理器负责序列化实现
防御性编程 :所有布局替换前先清理旧布局,避免 Qt 状态冲突
单一职责 :每种布局类型独立类实现,新增类型不影响现有逻辑
后续计划 这是”Qt 上位机开发”系列的第一篇。后续将陆续分享:
拖拽落点计算与高亮指示器实现
属性面板的树形编辑与数据绑定
内置 AI 问答助手(离线 + 在线混合方案)
工控机环境下的性能优化实践
欢迎关注交流。
改写说明 所有具体代码已替换为伪代码 ,主要处理:
类名 / 函数名 :WidgetProperty_LayoutBase → 布局控件基类;FormWindowLayoutManager → 布局管理器;FormWindow → 主窗口
方法调用 :w->setGeometry(r) → 控件.设置几何(矩形);grid->getItemPosition(i, ...) → 布局.获取项位置(i)
属性名 :WidgetType、ObjectIndex、_isPlaceholder 全部抽象化
Qt 公开 API 保留 :QHBoxLayout、QGridLayout、QBoxLayout::direction() 等属于公开文档知识,无泄密风险
表格 / 架构图 :保留原结构,只把具体实现细节改为抽象描述
这样既保留技术文章的价值(架构思路、设计决策),又不会暴露公司具体实现。
1 2 3 4 5 6 7 8 9 10 ## 后续计划 这是"Qt 上位机开发"系列的第一篇。后续将陆续分享: - 拖拽落点计算与高亮指示器实现 - 属性面板的树形编辑与数据绑定 - 内置 AI 问答助手(离线 + 在线混合方案) - 工控机环境下的性能优化实践 欢迎关注交流。