初始化

This commit is contained in:
rxy
2026-05-18 11:33:59 +08:00
commit b22e46dd60
20 changed files with 1440 additions and 0 deletions

496
docs/MVVM学习教程.md Normal file
View File

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