Qt 串口调试助手的 UI 工程化(布局 / DPI / 浮层抽屉 / 对齐日志)
主题不再是单点功能堆叠,而是把工具往“长期可用、可维护、跨环境稳定”的方向再推一把:界面结构更清晰、高 DPI 下尺寸更可控、接收区更像日志、低频功能被收纳到更顺手的位置。
1. 布局:从“能摆上去”到“可维护”的结构化拆分
做桌面工具时,最容易掉进去的坑是:靠绝对坐标和“硬 resize”把控件摆出来,短期看很快,长期会被三件事反噬:
-
窗口尺寸变化(用户拖拽、不同分辨率、最大化/还原)
-
字体变化(系统字体、DPI 缩放导致的字号变化)
-
需求变化(多加一个选项,原来的坐标全都得改)
这一轮的学习重点是:把界面当成“页面”,按区域拆分、交给布局管理器,让“可伸缩”成为默认能力。
1.1 先确定“三段式骨架”:顶部 / 中部 / 低频入口
整体用一个垂直布局作为根(root),再把界面拆成:
1) 顶部:左侧控制区 + 右侧接收显示区 2) 中部:发送区(含文件/轮发/属性等) 3) 低频入口:个性化抽屉(把“偶尔用”的功能收起来)
1.2 左侧控制区“固定宽”,右侧接收区“尽量大”
串口工具的核心信息密度在接收区:越大越好、越稳定越好。实践上可以把左侧控制区设成固定宽度,而让右侧接收区用 Expanding 策略吃满剩余空间。
这背后是一个通用经验:高频阅读区(日志/图表/表格)应该优先占空间;低频配置区(参数/开关)应该稳定、紧凑、对齐。
1.3 用 GridLayout 解决“对齐感”:标签列固定、控件列限宽、空白列伸展
在“串口设置”这种表单区域,观感很大程度来自对齐。学习到的做法是:
-
标签列固定宽度,保证每行起点一致;
-
控件列给合理的最大宽度,避免无限拉伸导致“空、散”;
-
余下空间交给 Stretch 列,形成自然留白。
这比“手动挪控件”稳定得多:字体变大、翻译变长、控件换成更宽的版本,都不会造成整体错位。
2. 高 DPI:弄清“逻辑像素”和“物理像素”,才能让尺寸真的可控
在 Windows 上开了 125%/150% 缩放后,Qt6 的窗口几何尺寸使用的是 DIP(设备无关像素,逻辑像素)。用户感知到的“窗口外框物理像素”会变大:你以为设置了 1600×900,实际外框可能变成 2000×1125。
这轮的关键学习点是两句话:
1) 物理像素 = 逻辑像素 × devicePixelRatio 2) resize() 影响的是客户区(client area),不是“窗口外框”(含标题栏/边框)
2.1 为什么要在 showEvent 之后再做一次校准?
窗口真正显示出来后,边框厚度、标题栏高度、所属屏幕等信息才稳定。 因此一个更稳的策略是:
-
构造函数里先给一个“逻辑尺寸”的初始值(保证布局能跑起来)
-
showEvent()里用singleShot延迟到事件循环,再把目标“外框物理像素”反算为“客户区逻辑尺寸”,最后resize()一次
2.2 反推公式:外框目标 → 外框逻辑 → 客户区逻辑
核心思路:
1) 取 dpr = windowHandle()->devicePixelRatio()(或从 screen 兜底) 2) 目标外框逻辑尺寸:desiredOuterLogical = targetOuterPx / dpr 3) 估算边框开销:frameDelta = frameGeometry - geometry 4) 目标客户区逻辑尺寸:desiredClient = desiredOuterLogical - frameDelta 5) resize(desiredClient) 一次到位
这个方法的价值不在“追求完全精确”,而在“足够接近且可解释”。一旦可解释,就能用同样套路做更多窗口/对话框的尺寸一致性。
2.3 同样思路迁移到子窗口:Modbus 工具对话框
对话框也遇到同样问题:希望外框固定为某个物理像素尺寸,但 Qt 的 setFixedSize() 仍然是 client area 的 DIP。 解决办法一样:在 showEvent() 延迟到窗口创建完成后,拿到 DPR + 边框开销,再用 setFixedSize(targetClientLogical) 定住。
3. 接收区“对齐日志模式”:让串口数据像日志一样可读
串口数据本质上是“流”,但人读信息需要“结构”。这一轮把接收区做成两种模式:
-
普通模式:尽量保持原样(文本/HEX)
-
日志模式:统一前缀 + 对齐列 + 可预测的换行规则
3.1 两个前提:等宽字体 + 禁用自动换行
只要涉及“列对齐”,等宽字体就是必要条件;否则同样字符数量会显示出不同宽度。 此外必须禁用自动换行,否则 Qt 会根据窗口宽度随时改变折行点,你算出来的列宽就失效了。
3.2 自动计算“每行多少列”:用 FontMetrics 做近似估算
固定列数不是写死 16,更合理的做法是:根据当前接收区 viewport 宽度动态计算一次,然后在“本次显示周期”内固定下来。
计算时用到的信息:
-
charW:一个字符大概多宽(用QFontMetrics取'0'的宽度) -
viewportPx:可视区域像素宽度 -
availChars = viewportPx / charW:大概能放多少字符 -
fixedChars:时间字段 + T/R 字段 + 分隔空格的固定开销 -
cellChars:每个字节单元格占的字符数(含空格),需要能放下列号的位数
为了让列号(0..N-1)始终对齐,这里还做了一个小迭代:先估算 cols,再根据 cols 的位数回推 cellChars,直到稳定。
3.3 输出策略:表头只打一次,内容按固定列换行
日志模式的输出原则是:
1) 首次输出前打印表头:TIME | T/R | 00 01 02 ... 2) 每帧输出时,第一行带时间和 TX/RX;如果一帧被拆成多行,后续行用空白前缀占位,保证“数据从同一列开始” 3) HEX 模式按固定列数分块;文本模式只保证起始对齐,不强制拆成列
这种显示方式在“看协议帧、对比多次收发”时特别省眼睛:定位字节位置、对齐字段、回看上下文都更快。
3.4 模式切换要重置:避免“表头”和“内容”不一致
日志模式依赖“固定列数”。当用户切换“显示时间/日志模式”时,最安全的做法是:
-
清空接收区
-
重置列数/表头状态
-
让下一次输出重新计算列数并打印新表头
这看似“粗暴”,但比“继续沿用旧配置导致错位”更可控。
4. 个性化组件抽屉:用浮层收纳低频功能,让主界面更清爽
工具做大之后,一个典型矛盾是:功能越多越强,但主界面越挤越乱。 解决思路是把功能分成两类:
-
高频:必须“一眼能看到、一步能点到”
-
低频/高级:可以“收纳”,但要“随时可达”
4.1 为什么选“覆盖式浮层”,而不是新开一个 Tab?
Tab 适合“并列的大功能模块”,但这里更多是“工具入口 + 高级参数卡片”。 浮层抽屉更像“系统菜单/工具箱”:点一下展开,选完就收起,不打断主流程。
4.2 实现关键:不参与布局 + 位置重算 + 几何动画
抽屉面板使用一个独立 QWidget,父对象挂在 centralwidget 上,但不放进任何 layout,这样:
-
展开/收起不会挤压主布局,也就不会改变窗口整体尺寸
-
可以用
setGeometry()做精确定位,并在窗口 resize 时重算位置
动画使用 QPropertyAnimation 作用在 geometry 上,配合 OutCubic 让展开更自然。 收起时在动画结束回调里 hide(),避免隐藏控件继续吃鼠标事件。
4.3 内容组织:卡片化 + 可滚动 + 高度自适应
抽屉内部用 ScrollArea 包裹多个“卡片”(例如快捷工具、轮发高级、文件发送等)。 一个小但很实用的点是:让抽屉高度跟内容自适应,避免固定高度导致出现大片“空白区域”,观感会更像成熟软件。
5. 细节打磨:可用性往往就藏在“小功能”里
5.1 波特率支持“自定义…”
真实现场常见的波特率并不总在下拉框里。把波特率下拉框改成:
-
editable(可直接输入) -
validator(限制成正整数范围) -
加一个“自定义…”入口(用对话框输入并回填)
这类交互属于“做完就回不去”的提升:用户不需要为了一个特殊波特率改代码或改配置。
5.2 端口列表“点开即刷新”:用 eventFilter 做更顺手的刷新策略
端口会热插拔,虚拟串口也可能运行时出现。把刷新动作绑定到“用户展开下拉框”的时刻,比提供一个单独按钮更自然。
做法是给端口下拉框装 eventFilter,在鼠标按下、或键盘触发下拉(F4/Alt+Down)时刷新一次列表。 这样用户的心理预期是:我只要点开,就一定是最新的端口。
结语:这次学到的“工程化”不是炫技,而是把不确定性变小
第六次迭代里,我最大的收获是:桌面工具的体验好坏,很少来自“某个大功能”,更多来自对不确定性的处理:
-
不确定的窗口尺寸(DPI / 边框 / 屏幕)→ 用 DPR + frameDelta 让尺寸可控
-
不确定的布局变化(控件增减 / 字体变化)→ 用布局管理器把变化局部化
-
不确定的数据形态(文本/二进制/长帧/多行)→ 用对齐日志模式把流结构化
-
不确定的功能膨胀(越做越多)→ 用抽屉把低频功能收纳但保持可达
接下来如果继续迭代,我会更关注两点: 1) 抽屉内容的“插件化/模块化”组织(更像一个工具箱);









