Files
mvvm--learn/docs/MVVM学习教程.md
2026-05-18 11:33:59 +08:00

497 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 改 ModelModel 发事件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 # QPushButtonyes 按钮
self.NoBtn # QPushButtonno 按钮
```
`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 负责把事件送到订阅者那里。
```
掌握这四句话,再回头读代码,会轻松很多。