记录一次从“拖一拖就能跑”到“手拧螺丝”的转向。
场景:做一个日志管理与点云浏览的小工具,迭代很快,UI 需要频繁改版。
00|为什么我从可视化回到纯代码
那天晚上 23:40,我在 Qt Designer 里把两个 QHBoxLayout 嵌套成了“左树右表”,又加了三层 QSpacerItem。看起来顺手,但两件小事把我劝退了:
- 改动难做“参数化”:想“根据配置隐藏右栏”,在 .ui 里要加占位、条件样式,变成“面向工具工作”,不是“面向需求工作”。
- 版本控制不可读:.ui 是 XML,MR 里几十行 diff 看不出意图,合并时冲突又脆。
后来我用纯代码重搭一遍布局。几百行 Python 全在仓里,谁改了哪一行一眼明白。主题切换、动态插拔控件、AB 测试布局,都更像写程序而不是拼积木。
总结:
-
Designer 优点:上手快、视觉把控直观、适合静态页面。
-
Designer 痛点:动态视图/条件布局吃力、PR 不可读、多人协作容易踩冲突。
-
纯代码优点:参数化、复用、可测试、可 review。
-
纯代码门槛:前期慢;但一旦形成“布局配方”,改动成本下降快。
01|三张“布局配方卡”
配方卡都用 PyQt5(pip install PyQt5),关键是结构与伸缩因子用对。
卡 1:经典三段式(工具栏 / 主区 / 状态栏)
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QToolBar, QLabel, QStatusBar, QPushButton)
class Shell(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("三段式配方")
# 1) 工具栏
tb = QToolBar("工具")
tb.addAction("刷新")
tb.addAction("导出")
self.addToolBar(tb)
# 2) 中心区:左列(过滤器)+ 右列(内容)
center = QWidget()
main = QHBoxLayout(center)
left = QWidget(); left_col = QVBoxLayout(left)
left_col.addWidget(QLabel("过滤器"))
left_col.addStretch(1) # 左列靠上,剩余空间往下消化
right = QWidget(); right_col = QVBoxLayout(right)
right_col.addWidget(QLabel("内容视图"))
right_col.addStretch(1)
main.addWidget(left, 0) # 权重 0:窄栏
main.addWidget(right, 1) # 权重 1:主栏
main.setContentsMargins(12, 12, 12, 12)
main.setSpacing(10)
self.setCentralWidget(center)
self.setStatusBar(QStatusBar())
要点:
- addStretch(1) 把控件“压”到上方/左侧;
- addWidget(widget, stretch) 第二个参数就是权重;
- QMainWindow 必须 setCentralWidget,否则布局不生效(见踩坑 ③)。
卡 2:自适应双栏(
QSplitter
驱动)
from PyQt5.QtWidgets import (QWidget, QSplitter, QListView, QTableView, QVBoxLayout)
from PyQt5.QtCore import Qt
class TwoPane(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(QListView()) # 左:树/列表
splitter.addWidget(QTableView()) # 右:表/画布
splitter.setStretchFactor(0, 0) # 左栏固定
splitter.setStretchFactor(1, 1) # 右栏吃剩余
layout.addWidget(splitter)
layout.setContentsMargins(8, 8, 8, 8)
要点:
- QSplitter 比纯 QHBoxLayout 更可交互,用户可拖动分割线;
- setStretchFactor 管“谁扩张”,resize 时右侧优先吃空间。
卡 3:数据表单(
QFormLayout
+
QGridLayout
混编)
from PyQt5.QtWidgets import (QWidget, QFormLayout, QLineEdit, QSpinBox,
QGridLayout, QPushButton)
class FormPane(QWidget):
def __init__(self):
super().__init__()
grid = QGridLayout(self) # 外层栅格负责两列
form = QFormLayout() # 内层表单负责行对齐
form.setLabelAlignment(Qt.AlignRight)
form.setFormAlignment(Qt.AlignTop)
name = QLineEdit()
age = QSpinBox(); age.setRange(0, 120)
city = QLineEdit()
form.addRow("姓名", name)
form.addRow("年龄", age)
form.addRow("城市", city)
btn_save = QPushButton("保存")
btn_cancel = QPushButton("取消")
grid.addLayout(form, 0, 0, 1, 2) # 表单占两列
grid.addWidget(btn_save, 1, 0)
grid.addWidget(btn_cancel, 1, 1)
grid.setRowStretch(2, 1) # 底部留白向下吃
grid.setContentsMargins(16, 16, 16, 16)
要点:
- QFormLayout 解决“标签对齐”问题;
- 外层 QGridLayout 控制按钮排布与伸缩。
02|“看不顺眼就加边框”:调布局的现场技巧
- 临时加可视化边框排查层级:
w.setStyleSheet("""
QWidget { background: transparent; }
QFrame#debug { border: 1px dashed #999; }
""")
- 打点几何:
print(widget.metaObject().className(), widget.geometry())
- 延迟一次布局(窗口初次 show() 后)再测量:
from PyQt5.QtCore import QTimer
QTimer.singleShot(0, lambda: print(widget.size()))
03|纯代码常用“螺丝刀”
-
边距 & 间距:layout.setContentsMargins(l, t, r, b)、layout.setSpacing(px)
-
对齐:layout.addWidget(w, stretch, alignment=Qt.AlignRight|Qt.AlignVCenter)
-
占位:layout.addStretch(1)、layout.addSpacerItem(QSpacerItem(w, h))
-
尺寸策略:
- w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
- 表格/画布用 Expanding,工具栏/标签多用 Preferred。
-
堆叠页:QStackedLayout 做“模式切换”(空数据页 / 数据页)。
04|踩坑复盘(这些我都真踩过)
① 把布局 set 到错的对象
- 症状:控件显示挤在角落;窗口放大控件不变。
- 原因:把 QVBoxLayout 赋给了子控件,没给父容器。
- 解法:parent_layout.addLayout(child_layout);中心窗口用 self.setCentralWidget(container)。
② 重复 setLayout
- 症状:运行报错 QWidget::setLayout: Attempting to set QLayout "" on …, which already has a layout。
- 解法:要么在构造里一次性 setLayout,要么只对顶层容器 setLayout,其余用 addLayout。
③ QMainWindow 忘记 setCentralWidget
- 症状:工具栏在,内容没托管,状态栏也不跟随。
- 解法:新建 center = QWidget() + self.setCentralWidget(center),再对 center 设置布局。
④ 尺寸策略打架
- 症状:左边本想固定宽,结果被右侧撑开;或滚动条出现/消失跳动。
- 解法:左栏 setFixedWidth 或 setSizePolicy(Preferred, Expanding),右栏 Expanding, Expanding;外层设置合适的 stretch。
⑤ 滚动区域外包层漏了
- 症状:QScrollArea 里内容不滚或滚动只部分生效。
- 解法:必须 scroll.setWidget(container) 且 container.setLayout(…);若内容宽度自适应,scroll.setWidgetResizable(True)。
⑥ 对象名与 QSS 不匹配
- 症状:样式没生效。
- 解法:widget.setObjectName(“toolbar”) 与 QSS #toolbar { … } 一一对应;不要指望类名匹配一切。
05|信号-槽:我遇到的三个问题与修复
Problem A:循环里 connect 把同一个槽连了 N 次
- 现象:点击一次触发 N 次。
- 示例(错误):
for i in range(5):
btn.clicked.connect(self.on_click) # 重复连接
- 修复:连接前先断开,或只连接一次;或用自定义信号转发。
try:
btn.clicked.disconnect(self.on_click)
except TypeError:
pass
btn.clicked.connect(self.on_click)
Problem B:lambda 捕获循环变量晚绑定
- 现象:五个按钮都传最后一个索引。
- 示例(错误):
for i in range(5):
btn.clicked.connect(lambda: self.open_tab(i)) # i 晚绑定
- 修复:用默认参数或 functools.partial。
from functools import partial
for i in range(5):
btn.clicked.connect(partial(self.open_tab, i))
# 或
btn.clicked.connect(lambda _, idx=i: self.open_tab(idx))
Problem C:跨线程更新 UI
- 现象:偶发崩溃/卡死/输出“QObject::setParent: Cannot set parent, new parent is in a different thread”。
- 修复:Worker 放到 QThread,用自定义 pyqtSignal 回主线程;UI 更新只在主线程做。
from PyQt5.QtCore import QObject, QThread, pyqtSignal
class Worker(QObject):
progress = pyqtSignal(int)
done = pyqtSignal(list)
def run(self):
data = []
for i in range(100):
# ... 计算 ...
self.progress.emit(i)
self.done.emit(data)
thread = QThread()
worker = Worker()
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.progress.connect(lambda v: ui.progressBar.setValue(v)) # 主线程安全
worker.done.connect(lambda data: ui.tableModel.reset_with(data))
thread.start()
附加点:需要“消息排队”时可指定 Qt.QueuedConnection;无需强行加锁。
06|小型项目的“连接规范”(减少后期返工)
- 一处连接,一处断开:在控件生命周期明确 connect 与 disconnect 的位置(通常构造/销毁)。
- 不在构造里做复杂逻辑:构造只搭结构,业务 init() 在 showEvent 或外部调用。
- 自定义信号做“中转层”:UI 控件的 clicked → Presenter 的 requestSave → Model;避免直接把控件绑到数据层。
07|一个“从 0 到 1”的纯代码页面示例(可抄)
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QSplitter, QListWidget, QTableWidget,
QTableWidgetItem, QToolBar, QStatusBar, QPushButton, QLabel)
from PyQt5.QtCore import Qt
import sys
class Page(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("手拧出来的界面")
self._build_ui()
self._wire_events()
def _build_ui(self):
tb = QToolBar("工具")
self.act_refresh = tb.addAction("刷新")
self.act_export = tb.addAction("导出")
self.addToolBar(Qt.TopToolBarArea, tb)
center = QWidget(); self.setCentralWidget(center)
root = QVBoxLayout(center)
splitter = QSplitter(Qt.Horizontal)
self.list = QListWidget(); self.list.addItems([f"任务 {i}" for i in range(1, 21)])
self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(["时间", "级别", "消息"])
splitter.addWidget(self.list)
splitter.addWidget(self.table)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
btns = QHBoxLayout()
self.btn_ok = QPushButton("确定")
self.btn_cancel = QPushButton("取消")
btns.addStretch(1)
btns.addWidget(self.btn_ok)
btns.addWidget(self.btn_cancel)
root.addWidget(splitter)
root.addLayout(btns)
self.setStatusBar(QStatusBar())
def _wire_events(self):
self.act_refresh.triggered.connect(self.load_data_once)
self.list.currentTextChanged.connect(self.on_select)
self.btn_ok.clicked.connect(self.on_ok)
self.btn_cancel.clicked.connect(self.close)
# --- slots ---
def load_data_once(self):
self.table.setRowCount(0)
for i in range(5):
r = self.table.rowCount()
self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem(f"12:{i:02d}"))
self.table.setItem(r, 1, QTableWidgetItem("INFO"))
self.table.setItem(r, 2, QTableWidgetItem(f"载入第 {i} 条"))
self.statusBar().showMessage("已刷新", 1500)
def on_select(self, name):
self.statusBar().showMessage(f"选中:{name}", 800)
def on_ok(self):
# TODO: 真正提交动作
self.statusBar().showMessage("已提交", 1000)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = Page()
win.resize(900, 600)
win.show()
sys.exit(app.exec_())
08|回到起点:一个判断题
当你需要“条件化、数据驱动、多人协作”的 UI 时,纯代码是更稳的积木。
而当你要做一个固定结构、一次性 demo,Designer 会更省时间。
我现在的习惯是:先用纸画出层级与伸缩,直接写代码搭“骨架”;难的时候就加临时边框看几何;信号与槽只连一次,复杂流转走中转信号。
界面是拧出来的,不是堆出来的。手感一旦对了,后面就全是熟练工。