Store 模式:base_store.py 信号驱动核心
一句话摘要:智学文枢的 UI 层不直接碰数据库,而是通过一个继承 QObject、仅声明一个
data_changed信号的 17 行基类BaseStore,把所有业务状态收拢成 7 个全局单例 Store,再用 PySide6 的 Signal 把"数据变了"这件事广播给所有关心它的页面。
一、引言
先问一个第一性问题:桌面应用的 UI 为什么需要一层"状态管理"?
回到最朴素的场景。一个学生档案页要显示学生列表,用户在弹窗里新增了一个学生,列表必须立刻刷新;看板页要显示统计卡片,学生被删了,看板也得跟着变;习题管理页把某道题推进到"AI 分析"阶段,进度列要变。这些页面之间没有父子关系,它们共享同一份业务数据------学生、习题、分析结果。
如果让每个页面各自去 SELECT * FROM students,会立刻陷入两个死循环:
- 刷新时机不可控。A 页面改了数据,B 页面怎么知道?轮询?太浪费;定时器?延迟;让 A 通知 B?那 A 就得知道 B 的存在,页面之间互相引用,耦合爆炸。
- 状态分裂。同一个学生,表格页缓存一份、弹窗缓存一份、看板缓存一份,改了一处忘了另一处,界面上出现"幽灵数据"。
所以第一性原理的答案是:把数据集中到一个地方管起来,任何改动都广播出去,谁关心谁订阅 。这就是 Store 模式:一个"状态容器 + 变更通知"的组合。智学文枢用 PySide6 自带的 Signal(信号槽)来做广播通道,而承载这一切的,就是只有 17 行的 BaseStore。
二、核心原理
2.1 信号槽:Qt 世界里的"发布-订阅"
PySide6 中,任何继承 QObject 的类都可以在类体里声明 Signal 字段。信号本身不携带逻辑,它只是一个"事件通道":
- 生产者调用
signal.emit(参数)发广播; - 消费者调用
signal.connect(槽函数)订阅; - 一个信号可以连接任意多个槽,槽函数之间互不感知。
关键特性是信号与槽都运行在所属线程的事件循环里 ,跨线程发射时 Qt 会自动做队列投递,槽函数在接收方线程执行------这为后续 QThread 后台任务(第 21 篇会讲到)打下了安全基础。
2.2 Store 的三件套
一个 Store 通常由三部分组成:
| 组件 | 职责 | 例子 |
|---|---|---|
| 内存缓存 | 页面直接读的"当前状态",避免每次读库 | _students、_current_student |
| 仓库代理 | 内部持有 Repository 并封装 CRUD | StudentRepository()、PaperService() |
| 信号广播 | 数据变化后发射信号通知 UI | students_loaded.emit(...) |
2.3 为什么是模块级单例
UI 侧永远通过 from ui.stores.student_store import student_store 拿同一个实例。桌面应用只有一个进程、一套界面状态,天然适合单例;比"依赖注入容器"更直白,比"每次 new 一个"更省内存且保证状态唯一。
#mermaid-svg-gvHZW9ejOLD5HeHG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gvHZW9ejOLD5HeHG .error-icon{fill:#552222;}#mermaid-svg-gvHZW9ejOLD5HeHG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gvHZW9ejOLD5HeHG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gvHZW9ejOLD5HeHG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gvHZW9ejOLD5HeHG .marker.cross{stroke:#333333;}#mermaid-svg-gvHZW9ejOLD5HeHG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gvHZW9ejOLD5HeHG p{margin:0;}#mermaid-svg-gvHZW9ejOLD5HeHG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG .cluster-label text{fill:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG .cluster-label span{color:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG .cluster-label span p{background-color:transparent;}#mermaid-svg-gvHZW9ejOLD5HeHG .label text,#mermaid-svg-gvHZW9ejOLD5HeHG span{fill:#333;color:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG .node rect,#mermaid-svg-gvHZW9ejOLD5HeHG .node circle,#mermaid-svg-gvHZW9ejOLD5HeHG .node ellipse,#mermaid-svg-gvHZW9ejOLD5HeHG .node polygon,#mermaid-svg-gvHZW9ejOLD5HeHG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gvHZW9ejOLD5HeHG .rough-node .label text,#mermaid-svg-gvHZW9ejOLD5HeHG .node .label text,#mermaid-svg-gvHZW9ejOLD5HeHG .image-shape .label,#mermaid-svg-gvHZW9ejOLD5HeHG .icon-shape .label{text-anchor:middle;}#mermaid-svg-gvHZW9ejOLD5HeHG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gvHZW9ejOLD5HeHG .rough-node .label,#mermaid-svg-gvHZW9ejOLD5HeHG .node .label,#mermaid-svg-gvHZW9ejOLD5HeHG .image-shape .label,#mermaid-svg-gvHZW9ejOLD5HeHG .icon-shape .label{text-align:center;}#mermaid-svg-gvHZW9ejOLD5HeHG .node.clickable{cursor:pointer;}#mermaid-svg-gvHZW9ejOLD5HeHG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gvHZW9ejOLD5HeHG .arrowheadPath{fill:#333333;}#mermaid-svg-gvHZW9ejOLD5HeHG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gvHZW9ejOLD5HeHG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gvHZW9ejOLD5HeHG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gvHZW9ejOLD5HeHG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gvHZW9ejOLD5HeHG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gvHZW9ejOLD5HeHG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gvHZW9ejOLD5HeHG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gvHZW9ejOLD5HeHG .cluster text{fill:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG .cluster span{color:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gvHZW9ejOLD5HeHG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gvHZW9ejOLD5HeHG rect.text{fill:none;stroke-width:0;}#mermaid-svg-gvHZW9ejOLD5HeHG .icon-shape,#mermaid-svg-gvHZW9ejOLD5HeHG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gvHZW9ejOLD5HeHG .icon-shape p,#mermaid-svg-gvHZW9ejOLD5HeHG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gvHZW9ejOLD5HeHG .icon-shape .label rect,#mermaid-svg-gvHZW9ejOLD5HeHG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gvHZW9ejOLD5HeHG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gvHZW9ejOLD5HeHG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gvHZW9ejOLD5HeHG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用方法
调用方法
create_student
查询/写入
emit 信号
students_loaded
data_changed
student_created
页面A
学生档案
StudentStore 单例
页面B
首页看板
弹窗
新增学生
StudentRepository
信号总线
三、代码实现
3.1 基类:全部精华只有 17 行
BaseStore 的完整实现如下:
python
"""Store基类 --- 基于PySide6 Signal的状态管理"""
from PySide6.QtCore import QObject, Signal
class BaseStore(QObject):
"""状态管理基类
子类通过定义 Signal 字段来通知 UI 层数据变化,
UI 层连接这些 Signal 实现自动刷新。
"""
# 数据变化信号(子类可覆盖为带参数的版本)
data_changed = Signal()
def __init__(self):
super().__init__()
源码位置:ui/stores/base_store.py L1-L17
拆解这 17 行:
- 继承
QObject------这是信号槽的入场券。只有 QObject 及其子类才能声明和发射 Signal; - 声明
data_changed = Signal()------一个无参数通用信号,作为"不知道具体发生了什么,但数据肯定变了"的兜底广播口; __init__里调用super().__init__()------QObject 构造必须经过基类初始化,否则运行时直接崩溃。
子类可"覆盖为带参数的版本":例如 StudentStore 重新定义了 data_changed = Signal() 语义更丰富的具体信号,而通用信号被保留给那些只关心"刷新一下"的订阅者。
3.2 子类如何扩展信号
以 StudentStore 为例,它在基类之上声明了 4 个带参数的信号:
python
class StudentStore(BaseStore):
"""学生档案状态管理器"""
# 信号:数据变化时发射
students_loaded = Signal(list) # List[Student]
student_created = Signal(str) # student_id
student_updated = Signal(str) # student_id
student_deleted = Signal(str) # student_id
def __init__(self):
super().__init__()
self._students: List[Student] = []
self._current_student: Optional[Student] = None
源码位置:ui/stores/student_store.py L10-L22
注意信号参数的"收敛"设计:student_created 只发射 student_id(str),而不是整个 Student 对象。这样订阅方按需调用 store.get_student(id) 取最新数据,避免了"信号里携带的旧对象覆盖了内存里的新对象"这类经典竞态。
3.3 典型发射点:加载、创建、删除
python
def load_students(self):
"""加载全部学生"""
repo = StudentRepository()
try:
self._students = repo.get_all()
self.students_loaded.emit(self._students)
self.data_changed.emit()
finally:
repo.close()
源码位置:ui/stores/student_store.py L32-L40
这里能看到 Store 的完整动作链:查库 → 更新缓存 → 广播 。finally: repo.close() 保证连接一定归还,是 Repository 短生命周期模式的标配写法(详见第 12-13 篇)。
3.4 页面侧:连接信号实现自动刷新
页面在 __init__ 里订阅信号,数据一变,槽函数自动执行:
python
analysis_store.variant_started.connect(self._on_variant_started)
analysis_store.variant_finished.connect(self._on_variant_finished)
analysis_store.variant_error.connect(self._on_variant_error)
源码位置:ui/pages/variants/variant_list_page.py L271-L273
学生列表页 StudentRepository StudentStore 新增学生弹窗 用户 学生列表页 StudentRepository StudentStore 新增学生弹窗 用户 #mermaid-svg-URu1RKPp07h2nb5e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-URu1RKPp07h2nb5e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-URu1RKPp07h2nb5e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-URu1RKPp07h2nb5e .error-icon{fill:#552222;}#mermaid-svg-URu1RKPp07h2nb5e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-URu1RKPp07h2nb5e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-URu1RKPp07h2nb5e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-URu1RKPp07h2nb5e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-URu1RKPp07h2nb5e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-URu1RKPp07h2nb5e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-URu1RKPp07h2nb5e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-URu1RKPp07h2nb5e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-URu1RKPp07h2nb5e .marker.cross{stroke:#333333;}#mermaid-svg-URu1RKPp07h2nb5e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-URu1RKPp07h2nb5e p{margin:0;}#mermaid-svg-URu1RKPp07h2nb5e .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-URu1RKPp07h2nb5e text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-URu1RKPp07h2nb5e .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-URu1RKPp07h2nb5e .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-URu1RKPp07h2nb5e .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-URu1RKPp07h2nb5e .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-URu1RKPp07h2nb5e #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-URu1RKPp07h2nb5e .sequenceNumber{fill:white;}#mermaid-svg-URu1RKPp07h2nb5e #sequencenumber{fill:#333;}#mermaid-svg-URu1RKPp07h2nb5e #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-URu1RKPp07h2nb5e .messageText{fill:#333;stroke:none;}#mermaid-svg-URu1RKPp07h2nb5e .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-URu1RKPp07h2nb5e .labelText,#mermaid-svg-URu1RKPp07h2nb5e .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-URu1RKPp07h2nb5e .loopText,#mermaid-svg-URu1RKPp07h2nb5e .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-URu1RKPp07h2nb5e .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-URu1RKPp07h2nb5e .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-URu1RKPp07h2nb5e .noteText,#mermaid-svg-URu1RKPp07h2nb5e .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-URu1RKPp07h2nb5e .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-URu1RKPp07h2nb5e .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-URu1RKPp07h2nb5e .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-URu1RKPp07h2nb5e .actorPopupMenu{position:absolute;}#mermaid-svg-URu1RKPp07h2nb5e .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-URu1RKPp07h2nb5e .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-URu1RKPp07h2nb5e .actor-man circle,#mermaid-svg-URu1RKPp07h2nb5e line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-URu1RKPp07h2nb5e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 填写并保存create_student(...)repo.create(student)落库成功更新内存缓存 _studentsemit student_created(id) / data_changedget_student(id) 取最新数据刷新表格
四、设计权衡 / 踩坑记录
4.1 为什么用 Signal 而不是回调或事件总线
- 回调(callback):改数据的地方要持有回调引用,新增订阅者必须改生产者代码,违反开闭原则;
- 全局事件总线:所有消息走一个总线,类型安全完全丢失,参数全靠约定;
- Qt Signal :既有"发布-订阅"的解耦,又保留类型签名,且和 Qt 的事件循环、线程模型天然融合------不用引第三方库(不像
blinker、pydantic-events)。
4.2 冗余的 data_changed:不是设计缺陷
子类信号(如 student_created)语义精确,但订阅方必须逐个连接;data_changed 语义模糊,却是一个"统一闸口"。实践中二者并存:精确信号给"需要参数做逻辑"的页面,通用信号给"只需要无脑刷新"的页面(如看板统计卡片、多选弹窗)。代价是信号发射略冗余,但桌面应用的数据频率远构不成性能瓶颈。
4.3 踩坑:QObject 不能复制,单例必须保证唯一
QObject 禁拷贝(拷贝构造被删除)。如果谁误 copy.copy(store) 或重复 StudentStore(),轻则信号连接断开、状态分裂,重则内存泄露。因此所有 Store 文件末尾都有一行约定俗成的单例导出:
python
# 全局单例
student_store = StudentStore()
源码位置:ui/stores/student_store.py L217-L218
4.4 对比 MVC / MVVM
经典 MVC 中 Controller 承担了太多"在哪改、改完通知谁"的胶水逻辑,页面一多就变成上帝类;MVVM 的绑定框架(如 Qt 的 QAbstractItemModel)更适合表格类控件,但对"跨页跳转带数据"(第 27 篇)这类非绑定场景反而绕。Store 模式介于两者之间:状态集中、通知显式,配合少量 preset_selection 方法即可完成跨页协作,是这类"页面多、共享数据多、AI 异步任务多"的桌面应用最务实的平衡点。
五、小结
- Store 解决的根本问题是**"数据归谁管、变了怎么通知"**:内存缓存 + Repository 代理 + Signal 广播三件套;
BaseStore只做两件事:继承QObject获得信号能力、声明data_changed兜底信号,剩下全部交给子类扩展;- 信号参数尽量收敛为
id而不是整个对象,避免脏数据覆盖; - 全局单例保证状态唯一,页面之间通过"调方法 + 订阅信号"协作,互不持有对方引用;
- 这套模式是整个第 3 部分的地基:接下来 8 篇看到的每一个 Store、每一次
emit,都建立在这 17 行之上。