# Python-MVVM 学习教程 这份教程的目标不是只让你“看懂每一行”,而是让你能真正掌握这个小项目背后的设计方式:为什么分层、事件怎么流动、怎么自己扩展一个新的绑定。 ## 1. 先建立整体图景 这个项目可以看成五层: ```text start_up.py 程序入口:创建 QApplication、View、ViewModel,启动事件循环 binder.py 绑定层:连接按钮事件,建立 Model 与 View 的绑定关系 view_model.py ViewModel:处理用户操作背后的业务逻辑 model.py Model:保存数据,数据变化时发布事件 view.py View:持有界面控件,收到事件后刷新界面 framework.py 框架核心:发布-订阅、事件队列、绑定方向 design/main_win.py Qt Designer 生成的界面代码 ``` 你可以先记住一句话: > ViewModel 改 Model,Model 发事件,View 收事件并刷新界面。 ## 2. MVVM 是什么 MVVM 是 Model、View、ViewModel 的缩写。 Model:数据和业务状态。 View:用户看到和操作的界面。 ViewModel:把界面动作翻译成业务行为,也把业务数据组织成 View 容易展示的形式。 传统写法里,按钮点击后可能直接执行: ```python self.LogBrowser.append("log: yes!!!") ``` 这样的问题是:界面控件和业务逻辑绑死了。以后如果日志不显示在 QTextBrowser,而是写入文件、发到网络、显示在别的控件里,逻辑代码就要跟着改。 这个项目的写法是: ```python self.model_log.value = 'log: yes!!!' ``` ViewModel 只关心“日志数据变了”。至于日志怎么显示,是 View 的职责。 ## 3. 程序启动流程 入口文件是 `demo/start_up.py`。 关键代码按顺序发生: ```python app = QApplication(sys.argv) ui_main_win = MainWinView() vm_main_win = MainWinVM() binding_main_ui(ui_main_win, vm_main_win) ui_main_win.show() fw_proxy.work() sys.exit(app.exec_()) ``` 逐步理解: 1. `QApplication` 创建 PyQt 应用。 2. `MainWinView()` 创建主窗口和控件。 3. `MainWinVM()` 创建主窗口对应的 ViewModel。 4. `binding_main_ui(...)` 把按钮和业务方法连起来,把 Model 和 View 绑定起来。 5. `ui_main_win.show()` 显示窗口。 6. `fw_proxy.work()` 启动 MVVM 框架自己的事件通道线程。 7. `app.exec_()` 进入 Qt 主事件循环。 这里有两个循环: ```text Qt 主事件循环:负责窗口、按钮点击、绘制等 GUI 事件 EventChannel 线程:负责 Model/View 之间的发布-订阅事件 ``` ## 4. 界面是怎么来的 `demo/design/main_win.ui` 是 Qt Designer 保存的 XML 界面文件。 `demo/design/main_win.py` 是由 `.ui` 文件生成的 Python 代码。它会创建这些成员: ```python self.LogBrowser # QTextBrowser,用来显示日志 self.YesBtn # QPushButton,yes 按钮 self.NoBtn # QPushButton,no 按钮 ``` `MainWinView` 继承了两个类: ```python class MainWinView(QMainWindow, main_win.Ui_MainWindow): ``` 含义是: ```text QMainWindow 提供真正的窗口能力 main_win.Ui_MainWindow 提供 setupUi,负责创建控件 ``` 所以 `MainWinView.__init__` 中调用: ```python self.setupUi(self) ``` 之后,`self.LogBrowser`、`self.YesBtn`、`self.NoBtn` 就可以使用了。 ## 5. View 层怎么工作 `demo/mvvm/view.py` 里有两个类: ```python class LogView(BaseView) class MainWinView(QMainWindow, main_win.Ui_MainWindow) ``` `MainWinView` 是窗口整体。 `LogView` 是对日志控件的 MVVM 包装。 `LogView.signal_proxy` 是最关键的方法: ```python def signal_proxy(self, log: str): super().signal_proxy() self.browser.append(log) self.browser.moveCursor(self.browser.textCursor().End) ``` 当 Model 发布日志事件后,框架会调用这个方法。它做两件事: 1. 把日志追加到 QTextBrowser。 2. 把光标移动到底部,让最新日志可见。 ## 6. ViewModel 层怎么工作 `demo/mvvm/view_model.py` 中的 `MainWinVM` 保存一个日志模型: ```python self.model_log = StringModel('log_to_view', '启动日志') ``` 这里的 `'log_to_view'` 是主题名。你可以理解成广播频道: ```text model_log 在 log_to_view 频道发布消息 view_log 订阅 log_to_view 频道 ``` 两个按钮方法很短: ```python def event_log_yes(self): self.model_log.value = 'log: yes!!!' def event_log_no(self): self.model_log.value = 'log: no~~~' ``` 注意:ViewModel 没有写 `self.LogBrowser.append(...)`。这就是解耦。 ## 7. Model 层怎么工作 `demo/mvvm/model.py` 中的 `StringModel` 继承 `BaseModel`。 核心是 `value` 的 setter: ```python @value.setter def value(self, value: str): self._string = value fw_proxy.channel.publish(self._topic, value) ``` 这段代码表达了一个重要思想: ```text 只要数据变了,就发布事件。 ``` 所以调用: ```python self.model_log.value = 'log: yes!!!' ``` 实际会触发: ```text 保存新值 -> publish('log_to_view', 'log: yes!!!') -> 事件进入队列 -> EventChannel 取出事件 -> 找到订阅 log_to_view 的回调 -> 调用 LogView.signal_proxy('log: yes!!!') ``` ## 8. binder.py 为什么存在 `binder.py` 是专门做连接的地方。 ```python ui.YesBtn.clicked.connect(vm.event_log_yes) ui.NoBtn.clicked.connect(vm.event_log_no) fw_proxy.binding(vm.model_log, ui.view_log) ``` 它做了两类绑定: 第一类是 PyQt 信号绑定: ```text 按钮 clicked -> ViewModel 方法 ``` 第二类是 MVVM 数据绑定: ```text Model topic -> View signal_proxy View topic -> Model signal_proxy ``` 把绑定集中在一个文件里,项目变大后会更清晰:View 不需要知道 ViewModel 怎么创建,ViewModel 也不需要知道具体控件怎么连接。 ## 9. framework.py 核心机制 框架由这几个对象组成: ```text Subscriber 订阅者:topic + callback Publisher 事件对象:topic + content EventChannel 事件通道:队列 + 订阅表 + 分发线程 BaseModel Model 基类 BaseView View 基类 BindingStyle 绑定方向枚举 Framework 框架门面 fw_proxy 全局框架对象 ``` ### 9.1 Subscriber 订阅者保存: ```python self._topic = topic self._callback = callback ``` 比如绑定后可能形成这样的订阅者: ```text topic: "log_to_view" callback: ui.view_log.signal_proxy ``` ### 9.2 Publisher 发布事件时会创建: ```python Publisher(topic, content) ``` 比如: ```text topic: "log_to_view" content: "log: yes!!!" ``` 它就是一个消息包。 ### 9.3 EventChannel `EventChannel` 继承 `Thread`,所以它可以在后台跑。 它有两个关键数据结构: ```python self._queue = Queue() self._resigster_container = {} ``` `_queue` 保存还没处理的事件。 `_resigster_container` 保存订阅关系。 订阅关系大概长这样: ```python { "log_to_view": [Subscriber(callback=LogView.signal_proxy)], "view_to_log": [Subscriber(callback=StringModel.signal_proxy)] } ``` ### 9.4 publish ```python def publish(self, topic: str, content): puber = Publisher(topic, content) self._queue.put(puber) ``` 发布不是立刻执行回调,而是先放进队列。这让发布者和消费者进一步解耦。 ### 9.5 run ```python def run(self) -> None: while self._working: puber = self._queue.get() with self._mutex: group = self._resigster_container.get(puber.topic, []) for suber in group: suber.callback(puber.content) ``` 这是事件分发的核心: 1. 从队列取一个事件。 2. 根据事件 topic 找订阅者。 3. 依次调用订阅者的 callback。 4. 把事件 content 传进去。 ### 9.6 binding ```python def binding(self, model, view, style=BindingStyle.DOUBLE_BINDING): ``` 支持三种绑定方式: ```text DOUBLE_BINDING 双向绑定 MODEL_TO_VIEW 只允许 Model 更新 View VIEW_TO_MODEL 只允许 View 更新 Model ``` demo 里调用: ```python fw_proxy.binding(vm.model_log, ui.view_log) ``` 默认是双向绑定,所以实际订阅了两条关系: ```text model_log.topic -> view_log.signal_proxy view_log.topic -> model_log.signal_proxy ``` 不过当前 demo 中 `LogView` 没有主动发布 View 事件,所以实际运行时主要体现的是 `Model -> View`。 ## 10. 点击 yes 后的完整调用链 这条链建议你反复对照源码看: ```text 用户点击 yes -> Qt 触发 ui.YesBtn.clicked -> binder.py 已经 connect 到 vm.event_log_yes -> MainWinVM.event_log_yes 执行 -> self.model_log.value = 'log: yes!!!' -> StringModel.value.setter 执行 -> fw_proxy.channel.publish('log_to_view', 'log: yes!!!') -> EventChannel._queue 收到 Publisher -> EventChannel.run 取出 Publisher -> 从 _resigster_container 找到 log_to_view 的订阅者 -> 调用 LogView.signal_proxy('log: yes!!!') -> QTextBrowser.append('log: yes!!!') -> 界面出现新日志 ``` 如果你能闭着眼睛说出这条链,这个项目你就学会一半了。 ## 11. 自己扩展一个功能 练习:增加一个 Clear 按钮,点击后清空日志。 思路如下: 1. 在 Qt Designer 里给界面加一个按钮,命名为 `ClearBtn`。 2. 重新生成 `design/main_win.py`。 3. 在 `MainWinVM` 中增加方法: ```python def event_log_clear(self): self.model_log.value = '' ``` 4. 在 `LogView.signal_proxy` 中判断空字符串时清空: ```python if log == '': self.browser.clear() else: self.browser.append(log) ``` 5. 在 `binder.py` 中连接按钮: ```python ui.ClearBtn.clicked.connect(vm.event_log_clear) ``` 这个练习能帮你理解:新增功能时,UI、ViewModel、View 各自该改什么。 ## 12. 再扩展一个真正的双向绑定 当前 demo 主要展示 Model -> View。要练习 View -> Model,可以加一个输入框。 目标: ```text 用户在 QLineEdit 输入文字 -> View 发布 text_changed 事件 -> Model.signal_proxy 收到新值并更新 Model ``` 你需要做: 1. 新增一个 `QLineEdit`。 2. 写一个 `LineEditView(BaseView)`。 3. 监听 `textChanged` 信号。 4. 在文本变化时调用 `fw_proxy.channel.publish(self.topic, text)`。 5. 用 `fw_proxy.binding(model, line_edit_view)` 建立双向绑定。 这样你会看到真正的双向同步。 ## 13. 这个项目的优点与局限 优点: ```text 结构非常小,适合学习 MVVM 和发布-订阅。 ViewModel 不直接操作控件,降低耦合。 框架不依赖具体 GUI 库,理论上可接入其他界面框架。 ``` 局限: ```text 事件线程直接调用 PyQt 控件更新,在大型 PyQt 项目中更推荐使用 Qt Signal 跨线程更新 UI。 EventChannel 没有优雅停止机制,queue.get() 阻塞时不会自动醒来。 没有取消订阅功能,复杂项目中需要 unsubscribe。 没有错误隔离,一个 callback 抛异常可能影响事件线程。 ``` 学习时先理解它的思想;真正做生产级框架时,再补这些工程能力。 ## 14. 推荐阅读顺序 第一遍只看流程: ```text start_up.py -> binder.py -> view_model.py -> model.py -> framework.py -> view.py ``` 第二遍看事件机制: ```text Framework.binding EventChannel.subscribe StringModel.value.setter EventChannel.publish EventChannel.run LogView.signal_proxy ``` 第三遍自己动手: ```text 改按钮文案 改日志内容 新增一个按钮 新增一个 Model 新增一个 View 尝试 View -> Model 绑定 ``` ## 15. 最小心智模型 把这个项目记成四句话: ```text View 只负责显示。 ViewModel 只负责处理界面动作背后的逻辑。 Model 保存数据,数据变了就发布事件。 Framework 负责把事件送到订阅者那里。 ``` 掌握这四句话,再回头读代码,会轻松很多。