ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyQt5 QAbstractSpinBox子类化实战:打造自定义步长调节器

PyQt5 QAbstractSpinBox子类化实战:打造自定义步长调节器 写PyQt5的人十有八九都用过QSpinBox和QDoubleSpinBox但真正去碰它们底下的QAbstractSpinBox基类的估计不多。我以前也一直把它当成一个抽象类摆设直到有次接了个需求要做一个输入范围横跨好几个数量级、步长还得随数值大小动态变化的步长调节器QSpinBox那点固定步长的能力根本使不上劲我这才老老实实把QAbstractSpinBox从头到尾啃了一遍。啃完之后最大的感受是这个基类才是整个步长调节器体系里真正干活的角色QSpinBox和QDoubleSpinBox不过是它默认配置好的两个具体实现。这篇文章就把我实际用下来的理解、子类化经验和踩过的坑都写出来给同样想在PyQt5里做自定义步长控件的朋友一个参考。1. 为什么说QAbstractSpinBox才是真正干活的基类1.1 继承关系里藏着的设计意图先看最直接的继承链QAbstractSpinBox继承自QWidgetQSpinBox和QDoubleSpinBox又继承自QAbstractSpinBox。很多初学者会习惯性把QSpinBox当成最基本的控件但只要你点开Qt的帮助文档看一眼就会发现QSpinBox自身几乎没写多少逻辑它只是把QAbstractSpinBox留出来的一堆虚函数重新覆盖了一遍塞进去了整数这个具体类型。真正的步长调节、文本显示、按键响应、滚轮处理、校验修复这些框架性逻辑全在基类里。这个设计思路跟我后来写业务代码时经常用的模板方法模式一模一样基类把整个交互流程定死把每一步具体怎么做留成虚函数交给子类去填。QAbstractSpinBox的流程大概是这样的用户按一下上箭头控件就调用stepBy(1)按一下下箭头调用stepBy(-1)在输入框里敲字符文本一变就触发validate去判断能不能接受提交文本的时候又调用valueFromText把字符串转成内部数值。这套流程对所有可调数字控件都是通用的唯一不同的就是数值用什么类型存步长怎么算字符串怎么解析这些恰恰就是子类需要覆盖的部分。想清楚这一层之后你真的会发现很多问题其实不用去翻QSpinBox的源码直接在基类层面就能找到答案。1.2 QSpinBox和QDoubleSpinBox只是默认实现QSpinBox默认帮你把内部数值类型定为int步长定为1范围是0到99valueFromText直接用QString的toInt()去转换textFromValue直接用QString::number()去格式化。QDoubleSpinBox则把内部值换成double步长定为1.0范围0.0到99.99字符串转换走的是toDouble()。这些默认配置适合80%的普通场景所以大部分教程都在教直接在界面上拖一个QSpinBox设个min、max、step就完事了。但只要需求稍微变一下默认实现就会露馅。举个我真实遇到的例子当时某项目X需要一个税率输入框税率可以是0.03、0.07、0.125这种很别扭的小数还要在界面上显示成3%7%12.5%这样的格式。用QDoubleSpinBox去setSuffix(%)显示出来是0.03%完全不是产品要的效果。这时候唯一的正路就是子类化QAbstractSpinBox重写textFromValue把0.03转成字符串3再手动拼一个百分号后缀。后面我会写一个完整的案例来说明。1.3 基类直接暴露的能力清单我把QAbstractSpinBox里实际用得上的能力和入口整理了一下用表格看起来最清楚能力/入口说明什么时候用stepBy(int steps)步进执行函数上/下键、滚轮最终都走到这里子类化时必重写stepEnabled()返回StepEnabledFlag决定上下箭头是否可点需要动态禁用某方向时setStepType()StepDefault/StepOne/StepAny三种步进语义要让滚轮走任意步长时validate(text, pos)返回可接受/中间态/拒绝三种校验结果控制输入的合法性fixup(text)文本失焦后的自动修正入口把3.修正成3.0textFromValue(value)内部值转显示文本自定义显示格式的核心valueFromText(text)显示文本转内部值自定义解析逻辑的核心lineEdit()拿到内部QLineEdit要操作光标、选中文本时setSpecialValueText()最小值时显示的替代文本显示自动无等语义setCorrectionMode()选择修正模式控制非法输入的回退方式有了这份清单你会发现这个基类就像一个半成品控件剩下的尺寸由你自己按业务去填。接下来的几节我重点拆一下步进和文本验证这两个最核心的机制因为你只有把它们吃透了才能写出真正能用的自定义步长控件。2. 步进逻辑与文本验证两步拆开看才不迷糊2.1 步长调节到底是怎么走起来的先说步进这条链路。你在界面上连续按上箭头或者滚动滚轮QAbstractSpinBox内部不会直接改数值它会先走一遍能不能走的检查再决定下一步怎么走。这个检查就是stepEnabled()。它返回一个QFlags值是StepUpEnabled和StepDownEnabled的组合。默认情况下基类会先问你子类当前能不能往上走、能不能往下走。QSpinBox的实现很朴素如果当前值小于最大值就允许往上大于最小值就允许往下。等于最大值时往上箭头自动置灰等于最小值时往下箭头自动置灰。这一点在你直接用QSpinBox时是完全自动的但一旦子类化很多人会忘了维护stepEnabled()导致数值到了边界箭头还亮着点了又弹回去体验很怪。真正执行步进的是stepBy(int steps)。注意这个参数是步数不是目标值。基类已经把按一下1步滚轮滚一格1步这类语义翻译好了你只需要在stepBy里决定走1步意味着数值变化多少。QSpinBox的做法是newValue value steps * singleStepQDoubleSpinBox同理。但这是固定步长的思路自定义时完全可以不这么算。我在后面做的分钟秒数联动控件里就是根据当前秒数动态决定steps到底代表多少秒的。另外有一个跟步进强相关的枚举叫StepType里面三个值StepDefault、StepOne、StepAny。StepDefault是常规的单步走法StepOne是指每次滚轮滚动都固定走一步StepAny则是让Qt根据滚轮滚动的角度换算出一个任意步数传进stepBy。我建议做精细控制时用StepDefault需要逐格微调的场景用StepOne别去依赖StepAny的换算结果因为不同系统滚轮速度不一样同一个滚动角度可能触发不同步数这在调试时很难复现。关于加速基类有个setAccelerated(bool)开启后长按箭头会随时间推移逐步加快步进频率类似按住自动连发。这个功能在数值范围大的时候很有用比如范围0到100000的金额输入。但要注意它加快的是stepBy的调用频率不是单次步长。如果你希望按钮按得越久、单次步长越大那得自己在stepBy里维护一个按住时长动态增大步长的逻辑基类帮不了你。2.2 validate和fixup输入合法性的一场攻防战步进只是数值变化的一个来源另一个更重要的来源是用户直接在行编辑器里敲字。QAbstractSpinBox的文本处理分三个阶段输入时校验、提交时解析、失焦时修正。输入时校验对应validate(QString text, int pos)这个虚函数。它每次按键后都会被调用返回三个状态之一QValidator::Acceptable表示当前文本完全合法Intermediate表示文本暂时可以接受但还没形成一个完整合法值Invalid表示完全不能接受。典型的Intermediate例子是用户输入了一个3.此时数字没完成但过程合理应该允许继续输入而输入abc就是不合法基类会直接拒绝这个按键。这里有个很多教程没强调的细节validate的第一个参数是QString不是const因为校验器在返回Invalid之前可能会顺手把文本改掉。比如签入一个只允许0-9的写法时你完全可以在validate里把非数字字符直接剔除然后把text改成清理后的版本再返回Acceptable。但pos参数必须跟着同步调整否则用户的光标会突然跳到一个诡异的位置。提交时的解析走valueFromText。基类的默认流程是文本确认提交时调用valueFromText把字符串解析成数值然后rangeCheck去检查是否在最小最大值之间不在的话根据correctionMode来决定是回退到上一个合法值还是就近取最近的一个合法值。QAbstractSpinBox::CorrectToPreviousValue是回退CorrectToNearestValue是取最近值。我实际项目里用的是CorrectToPreviousValue因为范围输入通常不希望用户敲到一半被就近取整悄悄改了数。失焦时的修正走fixup。很多人会忽略这个函数但它在体验上非常重要。用户输了个3.然后直接Tab走此时validate可能已经返回过Acceptable因为3.在解析时可以视作3.0但如果不做fixup文本会一直停在3.提交出来就变成3.看着很业余。我一般会在fixup里把文本规范化比如补全小数位、去掉多余空格、把中文符号转成英文符号然后把清理后的文本重新解析并更新。2.3 textFromValue和valueFromText显示逻辑与存储逻辑的脱钩基类把内部存的值和界面上显示的字完全分开了这可能是整个QAbstractSpinBox最有价值的设计也是很多自定义功能的基础。举个例子QSpinBox内部存的是int但显示时可以带上下标、带上千分位、带任意前后缀QDoubleSpinBox内部存的是double显示时可以控制小数位、控制是否显示尾随零。这些全是textFromValue的功劳。textFromValue是把值变成字符串的唯一通道你在界面上看到的所有文本都得经过它。价值就在于你可以在这个函数里做任意格式化。我当时做税率控件时textFromValue里就把0.12转成12然后在外层setSuffix(%)做金额控件时内部存doubletextFromValue用QString的千分位格式化函数打点顺手去掉多余的零。反过来valueFromText负责把字符串变回值。这里有几个常见的主意点一是要容忍各种格式比如用户可能输入1,000你要先把逗号去掉再toDouble二是要考虑前/后缀基类一般在调用valueFromText之前已经把前缀后缀剥掉了但如果你用了一些奇特的prefix写法最好还是自己在解析前做一次清理三是一定要做边界钳制在valueFromText里return出去的值很可能超过min/max哪怕validate已经拦了一道仍建议在valueFromText内部再做一次min-max裁剪双保险。3. 从零子类化做一个分钟秒数联动的计时器输入控件3.1 最简继承只重写stepBy的起步写法理论讲再多都不如直接写代码。我先给一个最简单的继承写法演示只用QAbstractSpinBox做一个不带小数的时间输入框。这个控件的内部值是一个int表示的秒数但界面上显示的是12分34秒。你看这就绕开了QSpinBox只能显示整数的问题因为显示格式完全自己掌控。from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import QAbstractSpinBox, QLineEdit, QWidget class TimeSpinBox(QAbstractSpinBox): def __init__(self, parent: QWidget None): super().__init__(parent) # 内部以秒为存储单位 self._seconds 0 self.setRange(0, 59 * 60 59) # 最多59分59秒 self.setWrapping(False) self.setAccelerated(True) self.setButtonSymbols(QAbstractSpinBox.UpDownArrows) def setRange(self, min_val: int, max_val: int): self._min min_val self._max max_val if self._seconds self._min: self._seconds self._min if self._seconds self._max: self._seconds self._max self.lineEdit().setText(self.textFromValue(self._seconds)) def stepBy(self, steps: int): # 步进每步固定1秒 new_value self._seconds steps if new_value self._min: new_value self._min if new_value self._max: new_value self._max if new_value ! self._seconds: self._seconds new_value self.lineEdit().setText(self.textFromValue(self._seconds)) def stepEnabled(self): flags QAbstractSpinBox.StepNone if self._seconds self._max: flags | QAbstractSpinBox.StepUpEnabled if self._seconds self._min: flags | QAbstractSpinBox.StepDownEnabled return flags def textFromValue(self, value: int) - str: # 内部秒数 - M分S秒 显示文本 minutes, seconds divmod(value, 60) return f{minutes}分{seconds:02d}秒 def valueFromText(self, text: str) - int: # M分S秒 - 内部秒数 import re m re.search(r(\d)\s*分\s*(\d)\s*秒, text) if m: minutes int(m.group(1)) seconds int(m.group(2)) return minutes * 60 seconds return self._seconds上面这个写法已经能跑但你一定会发现一个缺陷直接敲键盘时validate没重写系统会用默认行为。而默认行为对12分34秒这种带中文单位文本几乎是一脸懵的你敲个1它都不知道该不该接受。所以第二步关键是补上validate和fixup。3.2 补上validate和fixup让键盘输入也有规矩沿用上面的TimeSpinBox我给validate重写的时候把逻辑定为允许用户以任意顺序输入数字最长输入两位分钟数和两位秒数单位符号在失焦时自动补上。因为validate的QString参数可以就地修改文本所以实现比较直接def validate(self, text: str, pos: int): # 允许用户输入过程中的中间文本 if text : # 空文本先放行交给fixup兜底 return (QValidator.Intermediate, text, pos) # 去掉已有的单位符号和空格 cleaned text.replace(分, ).replace(秒, ).replace( , ) if not cleaned.isdigit(): return (QValidator.Invalid, text, pos) # 最多4位数字代表MMSS if len(cleaned) 4: return (QValidator.Invalid, text, pos) # 超过2位数字说明分钟可能会超过9仍然允许最终解析会钳制 return (QValidator.Acceptable, text, pos) def fixup(self, text: str) - str: # 失焦时把 1234 修正为 12分34秒5 修正为 0分05秒 import re cleaned re.sub(r\D, , text) if cleaned : cleaned 0 digits cleaned.rjust(4, 0)[-4:] minutes int(digits[:2]) seconds int(digits[2:]) if seconds 60: minutes seconds // 60 seconds seconds % 60 return f{minutes}分{seconds:02d}秒这样处理之后用户体验变成了用户直接在输入框里敲1234四个数字会直接铺开然后失焦瞬间变成12分34秒如果敲了个90fixup会把90秒折算成1分30秒而不是直接拒绝。这种先宽松接受、失焦修正的模式比每敲一下按键都弹错的交互舒服太多了。3.3 让滚轮和上下键变得听话步进策略的精细控制有时业务会要求在分钟位上滚轮时每格加1分钟在秒位上滚轮时每格加1秒。这个看起来像梦话的需求QAbstractSpinBox还真能做。思路是在stepBy里根据当前光标在lineEdit中的位置决定这一步到底改变多少。复用内部的lineEdit()可以拿到当前光标位置。如果光标停在分字前就认为焦点在分钟位如果光标停留在秒字前就认为焦点在秒位。然后def stepBy(self, steps: int): le self.lineEdit() cursor le.cursorPosition() # 根据光标位置判断是调分还是调秒 text_before le.text()[:cursor] if 分 not in text_before: # 光标在分钟区走分钟步进 minute_step 1 new_seconds self._seconds steps * minute_step * 60 else: new_seconds self._seconds steps * 1 new_seconds max(self._min, min(self._max, new_seconds)) self._seconds new_seconds le.setText(self.textFromValue(self._seconds)) # 尽量把光标放回原来的语义位置 if 分 not in text_before: le.setCursorPosition(le.text().indexOf(分)) else: le.setCursorPosition(le.text().indexOf(秒))这个实现的关键是光标位置判断。实际用下来滚动滚轮时光标在不在那个位置本身是个模糊概念所以我加了另一个更实用的策略不依赖光标而是直接加一个自定义属性_step_unit提供setStepUnit(60)或setStepUnit(1)来切换分钟步进和秒步进。两种方式我都试过光标方案体验上限高但代码复杂属性方案稳定可控。真实的订单录入项目里我最终用的是属性方案因为产品不会要求光标指向那么细只要求这个输入框选分钟模式时滚轮按分钟跳选秒模式时按秒跳。我想强调的一点是不要怕在stepBy里做复杂的分支逻辑。stepBy本身就是子类该发挥的地方基类不限制你一定要做简单的加减法。你甚至可以在这里访问外部业务对象根据业务状态动态改变步进效果这才是子类化的灵活之处。4. 接进业务表单数值格式化、范围限制与交互细节4.1 一个百分比税率控件的完整实现回到我开头提的税率输入场景用一个自绘的PercentSpinBox来解决显示3%、内部存0.03的问题。class PercentSpinBox(QAbstractSpinBox): valueChanged pyqtSignal(float) def __init__(self, parentNone): super().__init__(parent) self._value 0.0 self._min 0.0 self._max 1.0 self._decimals 2 self.setSuffix(%) self.setCorrectionMode(QAbstractSpinBox.CorrectToPreviousValue) self.lineEdit().setText(self.textFromValue(self._value)) def setValue(self, val: float): val max(self._min, min(self._max, val)) if abs(val - self._value) 1e-9: return self._value val self.lineEdit().setText(self.textFromValue(val)) self.valueChanged.emit(val) def value(self) - float: return self._value def textFromValue(self, value: float) - str: # 0.125 - 12.5 - 界面显示 12.5% text f{value * 100:.{self._decimals}f}.rstrip(0).rstrip(.) return 0 if text else text def valueFromText(self, text: str) - float: # 12.5% - 0.125 cleaned text.replace(%, ).replace( , ) try: val float(cleaned) / 100.0 except ValueError: return self._value return max(self._min, min(self._max, val)) def stepBy(self, steps: int): # 税率调整每步调0.5个百分点 new_val self._value steps * 0.005 new_val max(self._min, min(self._max, round(new_val, 4))) self.setValue(new_val) def stepEnabled(self): flags QAbstractSpinBox.StepNone if self._value self._max: flags | QAbstractSpinBox.StepUpEnabled if self._value self._min: flags | QAbstractSpinBox.StepDownEnabled return flags def validate(self, text: str, pos: int): cleaned text.replace(%, ).replace( , ) if cleaned : return (QValidator.Intermediate, text, pos) try: val float(cleaned) except ValueError: return (QValidator.Invalid, text, pos) if val 0 or val 100: # 超过0~100视为中间态允许用户删回 return (QValidator.Intermediate, text, pos) return (QValidator.Acceptable, text, pos) def fixup(self, text: str) - str: cleaned text.replace(%, ).replace( , ) if cleaned : cleaned 0 try: val float(cleaned) except ValueError: val self._value * 100 val max(0.0, min(100.0, val)) text f{val:.{self._decimals}f}.rstrip(0).rstrip(.) return 0 if text else text %这个控件放在表单里用户输入12.5、按下回车或者Tab走人界面就稳定显示12.5%内部拿到0.125直接用value()去参与计算再也不用来回解析字符串了。4.2 文本提交时机与键盘追踪的取舍QAbstractSpinBox有setKeyboardTracking(bool)这个属性直接影响valueChanged信号什么时候发。默认是true也就是用户每敲一个数字控件就会尝试解析当前文本并发射一次valueChanged。这对需要即时计算的场景比如输入金额后下面立刻显示税额是方便的但对某些需要确认后再计算的业务比如订单数量、费率设置经常会遇到用户输入125的过程中连续触发了112125三次计算表单里别的字段被迫跟着抖三次。这种场景把keyboardTracking关掉信号就只在用户按回车或控件失焦时才发射体验会稳得多。我做的项目里凡是带联动计算的输入框都设了setKeyboardTracking(False)然后依赖valueChanged做一次最终联动。如果你需要更精确地控制提交时机还可以直接拦截lineEdit()的returnPressed信号在用户按回车那一刻集中处理这也是很实用的变体。4.3 特殊值显示与空值语义QAbstractSpinBox还有个很有意思的接口setSpecialValueText()它允许当内部值等于最小值时不显示数字文本而是显示一行自定义文字。我做批次数量输入时用到过数量为0时显示自动为1显示1批大于等于2显示2批每批XX个。这个特性的实现其实不需要重写什么虚函数只要在textFromValue里判断一下value是否等于最小值返回特殊字符串即可。不过要注意特殊文本状态下lineEdit()里的内容不是数字如果你在textFromValue之外又通过lineEdit().text()去取数会拿到自动这种非数字字符串。每次都要记得走value()而不是去解析文本框。5. 高频踩坑记录文本同步、焦点丢失与数值漂移5.1 派发信号后文本没更新的幽灵问题子类化QAbstractSpinBox之后最容易遇到的一个问题就是程序里改了内部数值但界面上LineEdit的文本还是旧的或者反过来。究其原因是内部值和行编辑文本之间并没有自动绑定。QSpinBox之所以没这个感觉是因为它内部维护了一套同步机制但你在子类里自己setText之后系统并不知道你的内部值已经变了必须在setText的同时把内部值也更新掉并且保持一致。我推荐一个统一的写值入口像我在PercentSpinBox里写的那样setValue统一负责更新内部值、更新文本、发信号这三件事其他任何地方都不要直接改self._value或者直接setText所有路径都走setValue。这样就把状态分散的风险锁死在一处。这是我从一次线上bug里学到的当时有两个业务模块分别通过两条代码路径改内部值其中一条忘了刷新文本导致界面上显示6%而实际参与计算的已经变成了12%排查了很久。统一入口之后这类问题再没出现过。5.2 焦点切换时fixup和valueChanged的顺序问题另一个容易踩的坑是失焦时信号顺序。用户在一个自绘SpinBox里输了文本然后直接点别的控件Qt的默认行为会先调用fixup再走valueFromText解析然后更新内部值、发射valueChanged。如果你在别的控件里写了槽函数来处理valueChanged此时就会执行。我一开始假设失焦时用户已经认了这个值于是在valueChanged的槽函数里直接提交数据结果发现用户只是路过点了一下别的控件表单就被提交了。正确做法是区分交互中和确认时。只有键盘回车或显式提交按钮才触发提交失焦时的valueChanged只做界面刷新和规则联动绝对不能做持久化。我在订单系统里加了这样一条约定后误提交的bug就清零了。5.3 区域设置、输入法和中文符号的兼容最后这个坑比较隐蔽QString的toDouble、toInt在处理带千分位逗号、中文全角数字、以及某些输入法输入的符号时行为会跟你想的不一样。比如全角数字直接toInt是会失败的而用户在小键盘上按出的100又带着数字键盘的信号标记你拿text()出来看是一样的但统一塞进valueFromText可能就解析不出来。为稳妥我在valueFromText里一律先做一次Unicode归一化把全角数字和全角标点转成半角再去清除非数值字符然后再解析。具体代码我也不多写了核心就是善用str.translate和unicodedata.normalize。区域化数字格式也是个容易被忽略的点。如果你setGroupSeparatorShown(True)让数字能显示千分位那么用户在自行输入1,200时valueFromText如果直接把字符串去点然后toFloat在有些locale下反而是把逗号当成小数点这会使数值乘了1000。处理方法是解析前先根据locale判断逗号和点的语义或者干脆在解析时只认某个统一的字符。我的做法是界面上千分位显示归显示解析时一律把逗号删掉这样不管什么locale都稳定。5.4 步长边界值与stepEnabled不一致的体验问题还有一个小但很影响手感的细节设置了wrapping循环之后stepEnabled要怎么处理。如果setWrapping(True)意味着到了最大值再按上箭头会跳回最小值此时StepUpEnabled其实始终应该是true因为始终可以往上走。但如果你照抄了QSpinBox的stepEnabled写法——等于max就不能再上——你就会得到一个数值还能循环但箭头已经灰掉的bug。正确逻辑是先判断是否启用wrapping是的话就返回StepUpEnabled|StepDownEnabled这两个标志都置上而不是留给基类去判断。这个坑我第一次写就踩了花了半天才反应过来。另外注意配合wrapping和setSpecialValueText也有个交互边界当最小值显示自动这种特殊文本时如果wrapping从最大值跳回最小值界面会出现最大数字突然变成自动文字的跳变。从语义上倒说得通但对用户来说还是有点突兀。如果产品不能接受我建议在业务层就别开wrapping。最后分享一个我自己用的调试技巧每次重写完textFromValue和valueFromText我都会写一小段双向转换测试——从0到max按步长生成所有值每个值都做一次值→文本→值往返断言相等。这种测试几分钟就能写完但能一次性把所有边界问题全部揪出来包括负数、零、极小步长、特殊文本这些容易出事的点。比起手动在界面上按几百次这个办法高效太多了。QAbstractSpinBox其实并不复杂它就是把你平时忽略的如何调节一个数的完整决策过程摆到了你面前。只要记住步进走stepBy、显示走textFromValue、解析走valueFromText、校验走validate再配合一个统一的setValue写值入口你就能快速写出任何业务所需的步长调节器。遇到需求时别再急着拼凑QSpinBox的setSingleStep了停下来想一想这个调节器的步长是真的恒定不变吗显示格式真的只是一个suffix能解决的吗如果答案是否定的就去子类化QAbstractSpinBox吧它比你想的要亲民得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进