智学文枢:数据归谁管、变了怎么通知

Store 模式:base_store.py 信号驱动核心

一句话摘要:智学文枢的 UI 层不直接碰数据库,而是通过一个继承 QObject、仅声明一个 data_changed 信号的 17 行基类 BaseStore,把所有业务状态收拢成 7 个全局单例 Store,再用 PySide6 的 Signal 把"数据变了"这件事广播给所有关心它的页面。

一、引言

先问一个第一性问题:桌面应用的 UI 为什么需要一层"状态管理"?

回到最朴素的场景。一个学生档案页要显示学生列表,用户在弹窗里新增了一个学生,列表必须立刻刷新;看板页要显示统计卡片,学生被删了,看板也得跟着变;习题管理页把某道题推进到"AI 分析"阶段,进度列要变。这些页面之间没有父子关系,它们共享同一份业务数据------学生、习题、分析结果。

如果让每个页面各自去 SELECT * FROM students,会立刻陷入两个死循环:

  1. 刷新时机不可控。A 页面改了数据,B 页面怎么知道?轮询?太浪费;定时器?延迟;让 A 通知 B?那 A 就得知道 B 的存在,页面之间互相引用,耦合爆炸。
  2. 状态分裂。同一个学生,表格页缓存一份、弹窗缓存一份、看板缓存一份,改了一处忘了另一处,界面上出现"幽灵数据"。

所以第一性原理的答案是:把数据集中到一个地方管起来,任何改动都广播出去,谁关心谁订阅 。这就是 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 行:

  1. 继承 QObject------这是信号槽的入场券。只有 QObject 及其子类才能声明和发射 Signal;
  2. 声明 data_changed = Signal()------一个无参数通用信号,作为"不知道具体发生了什么,但数据肯定变了"的兜底广播口;
  3. __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 行之上。
相关推荐
Misnice2 小时前
Python 的数据类型
java·windows·python
FITA阿泽要努力2 小时前
Sub Agent:上下文管理的艺术
人工智能·python·agent
全栈弄潮儿3 小时前
Python实战第10期:异常处理
后端·python·编程语言
余槐i3 小时前
加了Cookie yt-dlp仍报HTTP 403:3个容易漏的请求头差异
python·http·ffmpeg·开发工具·yt-dlp
菜鸟不秃头3 小时前
手搓 ViT -- 并完成图像分类任务
python
ZhuoAIStage3 小时前
舞台工艺检测表单自动生成:用 Python 与智能体 Skill 打通规范限值到可填表的数据流
python·第三方检测·舞台工艺·表单自动生成·规范性引用
MoRanzhi12034 小时前
第3篇 · 使用 PyQt5 实现 2048 桌面版游戏:MVC 架构与撤销和重做
python·mvc·pyqt5·桌面应用开发·gui开发·2048游戏·撤销与重做
蚰蜒螟4 小时前
从 Java SocketChannel 到 TCP 挥手:一次 close() 的完整调用链与引用计数解析
java·python·tcp/ip
Smoothcloud润云4 小时前
GPU算力平台跑知识库问答,模型回答总跑偏?先排查检索链路
人工智能·python·深度学习·gpu算力·gpu