497 lines
12 KiB
Markdown
497 lines
12 KiB
Markdown
# 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 负责把事件送到订阅者那里。
|
||
```
|
||
|
||
掌握这四句话,再回头读代码,会轻松很多。
|