记录一次从“拖一拖就能跑”到“手拧螺丝”的转向。

场景:做一个日志管理与点云浏览的小工具,迭代很快,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 会更省时间。

我现在的习惯是:先用纸画出层级与伸缩,直接写代码搭“骨架”;难的时候就加临时边框看几何;信号与槽只连一次,复杂流转走中转信号。

界面是拧出来的,不是堆出来的。手感一旦对了,后面就全是熟练工。