初始化
This commit is contained in:
129
.gitignore
vendored
Normal file
129
.gitignore
vendored
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Python-MVVM
|
||||||
|
|
||||||
|
这是一个用 Python 实现的极简 MVVM 示例项目。它通过“发布-订阅”模式实现
|
||||||
|
Model 与 View 的数据绑定,并用 PyQt5 做了一个日志窗口 demo。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
Python-MVVM-master/
|
||||||
|
├── framework.py # 可单独复制使用的 MVVM 小框架
|
||||||
|
├── demo/
|
||||||
|
│ ├── start_up.py # demo 程序入口
|
||||||
|
│ ├── design/
|
||||||
|
│ │ ├── main_win.ui # Qt Designer 原始界面文件
|
||||||
|
│ │ └── main_win.py # 由 .ui 生成的 Python 界面代码
|
||||||
|
│ └── mvvm/
|
||||||
|
│ ├── framework.py # demo 内使用的框架代码
|
||||||
|
│ ├── model.py # Model 层
|
||||||
|
│ ├── view.py # View 层
|
||||||
|
│ ├── view_model.py # ViewModel 层
|
||||||
|
│ └── binder.py # 绑定 View 与 ViewModel
|
||||||
|
└── docs/
|
||||||
|
└── MVVM学习教程.md # 详细学习文档/课件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行 demo
|
||||||
|
|
||||||
|
进入 `demo` 目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python start_up.py
|
||||||
|
```
|
||||||
|
|
||||||
|
如果提示缺少 PyQt5,需要先安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install PyQt5
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心思想
|
||||||
|
|
||||||
|
MVVM 的目标是让 View 不直接处理业务逻辑,让 ViewModel 不直接操作界面控件。
|
||||||
|
本项目中,一次按钮点击的大致流程是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户点击按钮
|
||||||
|
-> PyQt clicked 信号
|
||||||
|
-> ViewModel.event_log_yes/event_log_no
|
||||||
|
-> 修改 StringModel.value
|
||||||
|
-> Model 发布 log_to_view 事件
|
||||||
|
-> EventChannel 分发事件
|
||||||
|
-> LogView.signal_proxy 刷新 QTextBrowser
|
||||||
|
```
|
||||||
|
|
||||||
|
详细讲解见 [docs/MVVM学习教程.md](docs/MVVM学习教程.md)。
|
||||||
2
demo/.idea/.gitignore
generated
vendored
Normal file
2
demo/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/workspace.xml
|
||||||
8
demo/.idea/demo.iml
generated
Normal file
8
demo/.idea/demo.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
demo/.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
demo/.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
4
demo/.idea/misc.xml
generated
Normal file
4
demo/.idea/misc.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.6 (audio_project_py36) (2)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
8
demo/.idea/modules.xml
generated
Normal file
8
demo/.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/demo.iml" filepath="$PROJECT_DIR$/.idea/demo.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
demo/.idea/vcs.xml
generated
Normal file
6
demo/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
demo/design/__init__.py
Normal file
1
demo/design/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Qt Designer 生成界面代码所在包。"""
|
||||||
87
demo/design/main_win.py
Normal file
87
demo/design/main_win.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Form implementation generated from reading ui file 'main_win.ui'
|
||||||
|
#
|
||||||
|
# Created by: PyQt5 UI code generator 5.13.0
|
||||||
|
#
|
||||||
|
# WARNING! All changes made in this file will be lost!
|
||||||
|
#
|
||||||
|
# 学习说明:
|
||||||
|
# 这个文件由 Qt Designer 的 .ui 文件生成,真实项目中通常不手动维护。
|
||||||
|
# 这里添加中文注释是为了帮助理解界面树、布局和控件名称。
|
||||||
|
|
||||||
|
|
||||||
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
|
||||||
|
class Ui_MainWindow(object):
|
||||||
|
"""Qt Designer 生成的主窗口界面类。"""
|
||||||
|
|
||||||
|
def setupUi(self, MainWindow):
|
||||||
|
"""
|
||||||
|
创建控件并设置布局。
|
||||||
|
|
||||||
|
MainWinView 会调用 self.setupUi(self),因此这里创建出来的控件
|
||||||
|
会成为 MainWinView 的成员,例如 self.LogBrowser、self.YesBtn。
|
||||||
|
"""
|
||||||
|
# 设置主窗口对象名,Qt 的样式和自动连接机制可能会用到它。
|
||||||
|
MainWindow.setObjectName("MainWindow")
|
||||||
|
# 设置主窗口初始大小。
|
||||||
|
MainWindow.resize(475, 571)
|
||||||
|
|
||||||
|
# 创建中心控件。QMainWindow 必须通过 setCentralWidget 设置主区域。
|
||||||
|
self.centralwidget = QtWidgets.QWidget(MainWindow)
|
||||||
|
self.centralwidget.setObjectName("centralwidget")
|
||||||
|
|
||||||
|
# 创建垂直布局:上方放日志框,下方放按钮区域。
|
||||||
|
self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
|
||||||
|
self.verticalLayout.setObjectName("verticalLayout")
|
||||||
|
|
||||||
|
# 创建日志显示控件,View 层会把它包装成 LogView。
|
||||||
|
self.LogBrowser = QtWidgets.QTextBrowser(self.centralwidget)
|
||||||
|
self.LogBrowser.setObjectName("LogBrowser")
|
||||||
|
self.verticalLayout.addWidget(self.LogBrowser)
|
||||||
|
|
||||||
|
# 创建按钮区域 frame,用来承载水平布局。
|
||||||
|
self.frame = QtWidgets.QFrame(self.centralwidget)
|
||||||
|
self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||||
|
self.frame.setFrameShadow(QtWidgets.QFrame.Raised)
|
||||||
|
self.frame.setObjectName("frame")
|
||||||
|
|
||||||
|
# 创建水平布局:左右 spacer 负责把两个按钮推到中间。
|
||||||
|
self.horizontalLayout = QtWidgets.QHBoxLayout(self.frame)
|
||||||
|
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||||
|
spacerItem = QtWidgets.QSpacerItem(132, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||||
|
self.horizontalLayout.addItem(spacerItem)
|
||||||
|
|
||||||
|
# 创建 Yes 按钮。binder.py 中会绑定它的 clicked 信号。
|
||||||
|
self.YesBtn = QtWidgets.QPushButton(self.frame)
|
||||||
|
self.YesBtn.setObjectName("YesBtn")
|
||||||
|
self.horizontalLayout.addWidget(self.YesBtn)
|
||||||
|
|
||||||
|
# 创建 No 按钮。binder.py 中会绑定它的 clicked 信号。
|
||||||
|
self.NoBtn = QtWidgets.QPushButton(self.frame)
|
||||||
|
self.NoBtn.setObjectName("NoBtn")
|
||||||
|
self.horizontalLayout.addWidget(self.NoBtn)
|
||||||
|
|
||||||
|
# 右侧弹性空白,与左侧 spacer 一起让按钮组居中。
|
||||||
|
spacerItem1 = QtWidgets.QSpacerItem(131, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||||
|
self.horizontalLayout.addItem(spacerItem1)
|
||||||
|
self.verticalLayout.addWidget(self.frame)
|
||||||
|
|
||||||
|
# 设置拉伸比例:日志区域占 10 份,按钮区域占 1 份。
|
||||||
|
self.verticalLayout.setStretch(0, 10)
|
||||||
|
self.verticalLayout.setStretch(1, 1)
|
||||||
|
MainWindow.setCentralWidget(self.centralwidget)
|
||||||
|
|
||||||
|
# 设置可翻译文本,例如窗口标题和按钮文字。
|
||||||
|
self.retranslateUi(MainWindow)
|
||||||
|
# Qt 自动槽连接机制,本 demo 没有依赖它,但生成代码会保留。
|
||||||
|
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
||||||
|
|
||||||
|
def retranslateUi(self, MainWindow):
|
||||||
|
"""设置界面上的文字,便于后续做国际化翻译。"""
|
||||||
|
_translate = QtCore.QCoreApplication.translate
|
||||||
|
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
|
||||||
|
self.YesBtn.setText(_translate("MainWindow", "yes"))
|
||||||
|
self.NoBtn.setText(_translate("MainWindow", "no"))
|
||||||
78
demo/design/main_win.ui
Normal file
78
demo/design/main_win.ui
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>MainWindow</class>
|
||||||
|
<widget class="QMainWindow" name="MainWindow">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>611</width>
|
||||||
|
<height>571</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>MainWindow</string>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="centralwidget">
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0">
|
||||||
|
<item>
|
||||||
|
<widget class="QTextBrowser" name="LogBrowser"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QFrame" name="frame">
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::StyledPanel</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Raised</enum>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>132</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="YesBtn">
|
||||||
|
<property name="text">
|
||||||
|
<string>yes</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="NoBtn">
|
||||||
|
<property name="text">
|
||||||
|
<string>no</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer_2">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>131</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
1
demo/mvvm/__init__.py
Normal file
1
demo/mvvm/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""MVVM demo 包:包含框架、模型、视图、视图模型和绑定逻辑。"""
|
||||||
24
demo/mvvm/binder.py
Normal file
24
demo/mvvm/binder.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
绑定层。
|
||||||
|
|
||||||
|
binder 的职责是把 View 和 ViewModel 连接起来:
|
||||||
|
1. 把界面控件信号连接到 ViewModel 的事件函数。
|
||||||
|
2. 把 Model 与 View 交给框架建立数据绑定。
|
||||||
|
"""
|
||||||
|
from mvvm.view_model import *
|
||||||
|
from mvvm.view import *
|
||||||
|
|
||||||
|
|
||||||
|
def binding_main_ui(ui: MainWinView, vm: MainWinVM):
|
||||||
|
"""绑定主窗口 UI 与主窗口 ViewModel。"""
|
||||||
|
|
||||||
|
# 绑定 PyQt 控件事件:按钮点击后调用 ViewModel 中的业务方法。
|
||||||
|
ui.YesBtn.clicked.connect(vm.event_log_yes)
|
||||||
|
ui.NoBtn.clicked.connect(vm.event_log_no)
|
||||||
|
|
||||||
|
# 建立双向绑定。
|
||||||
|
# 当前 demo 实际使用的是 Model -> View:model_log 改变后刷新 view_log。
|
||||||
|
# 因为 view_log 没有主动发布事件,所以 View -> Model 方向暂时不会触发。
|
||||||
|
fw_proxy.binding(vm.model_log, ui.view_log)
|
||||||
210
demo/mvvm/framework.py
Normal file
210
demo/mvvm/framework.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
MVVM 小框架核心模块。
|
||||||
|
|
||||||
|
这个文件实现了一个极简的“发布-订阅”事件通道,并在它之上封装
|
||||||
|
Model 与 View 的绑定关系。demo 中的界面刷新不是由 ViewModel 直接
|
||||||
|
调用 View 完成,而是由 Model 发布事件,再由订阅者 View 响应事件。
|
||||||
|
"""
|
||||||
|
from threading import Thread, Lock
|
||||||
|
from queue import Queue
|
||||||
|
from enum import Enum, unique
|
||||||
|
|
||||||
|
|
||||||
|
class Subscriber(object):
|
||||||
|
"""
|
||||||
|
订阅者。
|
||||||
|
|
||||||
|
一个订阅者由两部分组成:
|
||||||
|
1. topic:它关心的事件主题。
|
||||||
|
2. callback:主题事件发生时要执行的回调函数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, topic, callback):
|
||||||
|
# 保存订阅主题,例如 "log_to_view"。
|
||||||
|
self._topic = topic
|
||||||
|
# 保存事件到来时要调用的函数,例如 view.signal_proxy。
|
||||||
|
self._callback = callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回订阅者关注的主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def callback(self):
|
||||||
|
"""返回事件触发时要执行的回调函数。"""
|
||||||
|
return self._callback
|
||||||
|
|
||||||
|
|
||||||
|
class Publisher(object):
|
||||||
|
"""
|
||||||
|
发布者/事件对象。
|
||||||
|
|
||||||
|
这里的 Publisher 不是一个长期存在的对象,而是一次事件发布时
|
||||||
|
放进队列里的消息包:topic 表示发给谁,content 表示传什么数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, topic, content):
|
||||||
|
# 事件主题,决定这条消息会被哪些订阅者消费。
|
||||||
|
self._topic = topic
|
||||||
|
# 事件内容,也就是回调函数收到的参数。
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回事件主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content(self):
|
||||||
|
"""返回事件内容。"""
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
|
||||||
|
# 保留主题:当前 demo 没有使用,可作为以后“不触发实际业务”的占位主题。
|
||||||
|
KEEP_TOPIC = 'keep'
|
||||||
|
|
||||||
|
|
||||||
|
class EventChannel(Thread):
|
||||||
|
"""
|
||||||
|
事件通道。
|
||||||
|
|
||||||
|
它继承 Thread,内部维护一个 Queue。发布事件时先把事件放入队列;
|
||||||
|
线程运行后不断从队列取事件,再根据 topic 找到对应订阅者并执行回调。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super(EventChannel, self).__init__()
|
||||||
|
|
||||||
|
# 控制事件循环是否继续运行。
|
||||||
|
self._working = True
|
||||||
|
# 保护订阅关系表,避免多个线程同时读写造成状态错乱。
|
||||||
|
self._mutex = Lock()
|
||||||
|
# 存放待处理事件的线程安全队列。
|
||||||
|
self._queue = Queue()
|
||||||
|
# 订阅关系表:{"topic": [Subscriber, Subscriber, ...]}。
|
||||||
|
self._resigster_container = {}
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
# 对象销毁时尝试停止循环。注意:如果线程正阻塞在 queue.get(),
|
||||||
|
# 仅设置 False 不一定能立即退出,这里只作为简单 demo 处理。
|
||||||
|
self._working = False
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""线程入口:持续消费事件队列,并通知对应订阅者。"""
|
||||||
|
while self._working:
|
||||||
|
# Queue.get() 会阻塞等待新事件,因此线程不会空转占用 CPU。
|
||||||
|
puber = self._queue.get()
|
||||||
|
with self._mutex:
|
||||||
|
print('pop event {} : {}'.format(puber.topic, puber.content))
|
||||||
|
# 找到该 topic 下的所有订阅者;无人订阅时返回空列表。
|
||||||
|
group = self._resigster_container.get(puber.topic, [])
|
||||||
|
for suber in group:
|
||||||
|
# 将事件内容传给订阅者的回调函数。
|
||||||
|
suber.callback(puber.content)
|
||||||
|
|
||||||
|
def publish(self, topic: str, content):
|
||||||
|
"""发布事件:把主题和内容包装成 Publisher 后放入队列。"""
|
||||||
|
puber = Publisher(topic, content)
|
||||||
|
self._queue.put(puber)
|
||||||
|
|
||||||
|
def subscribe(self, topic: str, callback):
|
||||||
|
"""订阅事件:把某个 topic 与对应回调函数登记到订阅关系表中。"""
|
||||||
|
suber = Subscriber(topic, callback)
|
||||||
|
with self._mutex:
|
||||||
|
group = self._resigster_container.get(suber.topic, [])
|
||||||
|
group.append(suber)
|
||||||
|
self._resigster_container[suber.topic] = group
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(object):
|
||||||
|
"""
|
||||||
|
Model 基类。
|
||||||
|
|
||||||
|
子类修改自身数据时,一般会通过 fw_proxy.channel.publish 发布事件;
|
||||||
|
当 View 的事件反向更新 Model 时,则由 signal_proxy 接收新值。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, topic: str):
|
||||||
|
# 每个 Model 都有自己的主题,用来向外通知“我变了”。
|
||||||
|
self._topic = topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回 Model 对应的发布主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
def signal_proxy(self, *args):
|
||||||
|
"""事件代理函数。子类通常会重写它来更新自身状态。"""
|
||||||
|
print('{} event proxy run'.format(type(self)))
|
||||||
|
|
||||||
|
|
||||||
|
class BaseView(object):
|
||||||
|
"""
|
||||||
|
View 基类。
|
||||||
|
|
||||||
|
View 负责界面显示。它收到 Model 发布的事件后,在 signal_proxy 中
|
||||||
|
把数据刷新到具体控件上。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, topic: str):
|
||||||
|
# 每个 View 也有自己的主题,做双向绑定时可用来通知 Model。
|
||||||
|
self._topic = topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回 View 对应的发布主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
def signal_proxy(self, *args):
|
||||||
|
"""事件代理函数。子类通常会重写它来刷新界面控件。"""
|
||||||
|
print('{} event proxy run'.format(type(self)))
|
||||||
|
|
||||||
|
|
||||||
|
@unique
|
||||||
|
class BindingStyle(Enum):
|
||||||
|
"""绑定方式枚举。"""
|
||||||
|
|
||||||
|
DOUBLE_BINDING = 0 # 双向绑定:Model -> View,同时 View -> Model。
|
||||||
|
MODEL_TO_VIEW = 1 # 单向绑定:Model 改变后刷新 View。
|
||||||
|
VIEW_TO_MODEL = 2 # 单向绑定:View 改变后更新 Model。
|
||||||
|
|
||||||
|
|
||||||
|
class Framework(object):
|
||||||
|
"""框架入口对象,负责启动事件通道并建立绑定关系。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 整个框架共用一个事件通道。
|
||||||
|
self.channel = EventChannel()
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
"""启动事件通道线程。"""
|
||||||
|
# 设为守护线程:主程序退出时,该事件线程也会随之结束。
|
||||||
|
self.channel.daemon = True
|
||||||
|
self.channel.start()
|
||||||
|
|
||||||
|
def binding(self, model: BaseModel, view: BaseView, style=BindingStyle.DOUBLE_BINDING):
|
||||||
|
"""
|
||||||
|
建立 Model 与 View 的绑定。
|
||||||
|
|
||||||
|
本质是把某一方的 topic 订阅到另一方的 signal_proxy。
|
||||||
|
"""
|
||||||
|
if style == BindingStyle.DOUBLE_BINDING:
|
||||||
|
# Model 发布变化时,View 响应并刷新界面。
|
||||||
|
self.channel.subscribe(model.topic, view.signal_proxy)
|
||||||
|
# View 发布变化时,Model 响应并更新数据。
|
||||||
|
self.channel.subscribe(view.topic, model.signal_proxy)
|
||||||
|
elif style == BindingStyle.MODEL_TO_VIEW:
|
||||||
|
# 模型改变引起视图改变。
|
||||||
|
self.channel.subscribe(model.topic, view.signal_proxy)
|
||||||
|
elif style == BindingStyle.VIEW_TO_MODEL:
|
||||||
|
# 视图改变引起模型改变。
|
||||||
|
self.channel.subscribe(view.topic, model.signal_proxy)
|
||||||
|
else:
|
||||||
|
raise RuntimeWarning('binding is wrong')
|
||||||
|
|
||||||
|
|
||||||
|
# 框架单例对象:其他模块通过 fw_proxy 使用同一个事件通道。
|
||||||
|
fw_proxy = Framework()
|
||||||
45
demo/mvvm/model.py
Normal file
45
demo/mvvm/model.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Model 层。
|
||||||
|
|
||||||
|
Model 负责保存业务数据。这个 demo 只有一个字符串模型 StringModel,
|
||||||
|
它保存一条日志文本;当日志文本被修改时,会向事件通道发布通知。
|
||||||
|
"""
|
||||||
|
from mvvm.framework import fw_proxy, BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class StringModel(BaseModel):
|
||||||
|
"""字符串数据模型:保存一个字符串,并在值改变时发布事件。"""
|
||||||
|
|
||||||
|
def __init__(self, topic: str, value: str):
|
||||||
|
# topic 用于告诉框架:这个 Model 的变化应该发布到哪个主题。
|
||||||
|
super().__init__(topic)
|
||||||
|
# 真正保存字符串值的私有变量。
|
||||||
|
self._string = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
"""读取当前字符串值。"""
|
||||||
|
return self._string
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, value: str):
|
||||||
|
"""
|
||||||
|
修改字符串值。
|
||||||
|
|
||||||
|
这是 MVVM 自动刷新的关键位置:只要业务代码给 model.value 赋值,
|
||||||
|
Model 就会发布事件,View 会在订阅到事件后自动刷新。
|
||||||
|
"""
|
||||||
|
self._string = value
|
||||||
|
fw_proxy.channel.publish(self._topic, value)
|
||||||
|
|
||||||
|
def signal_proxy(self, value: str):
|
||||||
|
"""
|
||||||
|
接收 View 反向传来的值。
|
||||||
|
|
||||||
|
在双向绑定场景中,如果 View 发布了自己的变化,框架会调用这里,
|
||||||
|
从而把 View 的新值同步回 Model。
|
||||||
|
"""
|
||||||
|
super().signal_proxy()
|
||||||
|
self._string = value
|
||||||
49
demo/mvvm/view.py
Normal file
49
demo/mvvm/view.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
View 层。
|
||||||
|
|
||||||
|
View 负责界面控件的创建、显示和刷新。这里使用 PyQt5 的 QMainWindow
|
||||||
|
作为主窗口,并把 QTextBrowser 包装成一个 LogView,方便接入 MVVM 绑定。
|
||||||
|
"""
|
||||||
|
from PyQt5.QtWidgets import *
|
||||||
|
|
||||||
|
from mvvm.framework import *
|
||||||
|
from design import main_win
|
||||||
|
|
||||||
|
|
||||||
|
class LogView(BaseView):
|
||||||
|
"""日志视图:把收到的日志文本追加显示到 QTextBrowser 中。"""
|
||||||
|
|
||||||
|
def __init__(self, topic: str, browser: QTextBrowser):
|
||||||
|
# topic 表示这个 View 自己发布变化时使用的主题。
|
||||||
|
super().__init__(topic)
|
||||||
|
# 保存真正负责显示日志的 PyQt 控件。
|
||||||
|
self.browser = browser
|
||||||
|
|
||||||
|
def signal_proxy(self, log: str):
|
||||||
|
"""
|
||||||
|
接收 Model 发来的日志文本,并刷新界面。
|
||||||
|
|
||||||
|
在本 demo 中,StringModel.value 被修改后会发布 log 文本,
|
||||||
|
框架事件通道最终会调用这个函数。
|
||||||
|
"""
|
||||||
|
super().signal_proxy()
|
||||||
|
# 将新日志追加到文本浏览器。
|
||||||
|
self.browser.append(log)
|
||||||
|
# 文本框显示到底部,让用户总能看到最新日志。
|
||||||
|
self.browser.moveCursor(self.browser.textCursor().End)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWinView(QMainWindow, main_win.Ui_MainWindow):
|
||||||
|
"""主窗口 View:继承 Qt 主窗口和 Qt Designer 生成的界面类。"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super(MainWinView, self).__init__(parent)
|
||||||
|
# setupUi 会创建 LogBrowser、YesBtn、NoBtn 等控件。
|
||||||
|
self.setupUi(self)
|
||||||
|
# 设置窗口标题。
|
||||||
|
self.setWindowTitle("log演示demo")
|
||||||
|
|
||||||
|
# 包装日志控件,让它成为可绑定的 View 对象。
|
||||||
|
self.view_log = LogView('view_to_log', self.LogBrowser)
|
||||||
26
demo/mvvm/view_model.py
Normal file
26
demo/mvvm/view_model.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
ViewModel 层。
|
||||||
|
|
||||||
|
ViewModel 连接界面操作与业务数据:按钮点击后不直接操作界面,
|
||||||
|
而是修改 Model;Model 再通过事件通道通知 View 刷新。
|
||||||
|
"""
|
||||||
|
from mvvm.model import *
|
||||||
|
|
||||||
|
|
||||||
|
class MainWinVM(object):
|
||||||
|
"""主窗口 ViewModel:保存主窗口需要的数据模型和按钮事件逻辑。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 创建日志内容 Model。
|
||||||
|
# 主题 "log_to_view" 表示:这个 Model 的变化要通知日志 View。
|
||||||
|
self.model_log = StringModel('log_to_view', '启动日志')
|
||||||
|
|
||||||
|
def event_log_yes(self):
|
||||||
|
"""Yes 按钮点击事件:写入一条 yes 日志。"""
|
||||||
|
self.model_log.value = 'log: yes!!!'
|
||||||
|
|
||||||
|
def event_log_no(self):
|
||||||
|
"""No 按钮点击事件:写入一条 no 日志。"""
|
||||||
|
self.model_log.value = 'log: no~~~'
|
||||||
33
demo/start_up.py
Normal file
33
demo/start_up.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
程序入口。
|
||||||
|
|
||||||
|
运行这个文件会启动 PyQt5 应用,创建 View 和 ViewModel,完成绑定,
|
||||||
|
最后启动框架事件通道并进入 Qt 主事件循环。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mvvm.binder import *
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 创建 Qt 应用对象。所有 PyQt 界面程序都需要 QApplication。
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# 创建主窗口 View:负责界面控件与显示。
|
||||||
|
ui_main_win = MainWinView()
|
||||||
|
# 创建主窗口 ViewModel:负责界面事件背后的逻辑。
|
||||||
|
vm_main_win = MainWinVM()
|
||||||
|
|
||||||
|
# 绑定 View 与 ViewModel。
|
||||||
|
binding_main_ui(ui_main_win, vm_main_win)
|
||||||
|
|
||||||
|
# 显示主窗口。
|
||||||
|
ui_main_win.show()
|
||||||
|
|
||||||
|
# 启动 MVVM 框架的事件循环线程。
|
||||||
|
fw_proxy.work()
|
||||||
|
|
||||||
|
# 进入 Qt 主事件循环,直到窗口关闭。
|
||||||
|
sys.exit(app.exec_())
|
||||||
496
docs/MVVM学习教程.md
Normal file
496
docs/MVVM学习教程.md
Normal 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 改 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 负责把事件送到订阅者那里。
|
||||||
|
```
|
||||||
|
|
||||||
|
掌握这四句话,再回头读代码,会轻松很多。
|
||||||
172
framework.py
Normal file
172
framework.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
MVVM 小框架核心模块。
|
||||||
|
|
||||||
|
这个根目录版本与 demo/mvvm/framework.py 作用相同,适合复制到其他项目中
|
||||||
|
单独使用。它提供发布-订阅事件通道,以及 Model/View 的绑定入口。
|
||||||
|
"""
|
||||||
|
from threading import Thread, Lock
|
||||||
|
from queue import Queue
|
||||||
|
from enum import Enum, unique
|
||||||
|
|
||||||
|
|
||||||
|
class Subscriber(object):
|
||||||
|
"""订阅者:记录一个主题以及主题触发时要执行的回调函数。"""
|
||||||
|
|
||||||
|
def __init__(self, topic, callback):
|
||||||
|
# 订阅的事件主题。
|
||||||
|
self._topic = topic
|
||||||
|
# 主题事件发生时执行的函数。
|
||||||
|
self._callback = callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回订阅主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def callback(self):
|
||||||
|
"""返回事件回调函数。"""
|
||||||
|
return self._callback
|
||||||
|
|
||||||
|
|
||||||
|
class Publisher(object):
|
||||||
|
"""发布事件时放入队列的消息对象。"""
|
||||||
|
|
||||||
|
def __init__(self, topic, content):
|
||||||
|
# 事件主题,用于匹配订阅者。
|
||||||
|
self._topic = topic
|
||||||
|
# 事件内容,会作为参数传给订阅回调。
|
||||||
|
self._content = content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回事件主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content(self):
|
||||||
|
"""返回事件内容。"""
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
|
||||||
|
# 保留主题:当前没有使用,预留给未来的占位或空事件。
|
||||||
|
KEEP_TOPIC = 'keep'
|
||||||
|
|
||||||
|
|
||||||
|
class EventChannel(Thread):
|
||||||
|
"""事件通道线程:从队列取事件,并分发给对应 topic 的订阅者。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super(EventChannel, self).__init__()
|
||||||
|
|
||||||
|
# 控制线程循环。
|
||||||
|
self._working = True
|
||||||
|
# 保护订阅关系表的互斥锁。
|
||||||
|
self._mutex = Lock()
|
||||||
|
# 线程安全事件队列。
|
||||||
|
self._queue = Queue()
|
||||||
|
# topic 到订阅者列表的映射。
|
||||||
|
self._resigster_container = {}
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
# 简单停止标记;demo 级写法,不保证立即唤醒阻塞中的 get()。
|
||||||
|
self._working = False
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""线程主循环:消费事件并执行订阅回调。"""
|
||||||
|
while self._working:
|
||||||
|
puber = self._queue.get()
|
||||||
|
with self._mutex:
|
||||||
|
print('pop event {} : {}'.format(puber.topic, puber.content))
|
||||||
|
group = self._resigster_container.get(puber.topic, [])
|
||||||
|
for suber in group:
|
||||||
|
suber.callback(puber.content)
|
||||||
|
|
||||||
|
def publish(self, topic: str, content):
|
||||||
|
"""发布事件:把 topic 和 content 包成 Publisher 放入队列。"""
|
||||||
|
puber = Publisher(topic, content)
|
||||||
|
self._queue.put(puber)
|
||||||
|
|
||||||
|
def subscribe(self, topic: str, callback):
|
||||||
|
"""订阅事件:登记 topic 与回调函数的关系。"""
|
||||||
|
suber = Subscriber(topic, callback)
|
||||||
|
with self._mutex:
|
||||||
|
group = self._resigster_container.get(suber.topic, [])
|
||||||
|
group.append(suber)
|
||||||
|
self._resigster_container[suber.topic] = group
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(object):
|
||||||
|
"""Model 基类:保存主题,并提供可被 View 事件调用的代理方法。"""
|
||||||
|
|
||||||
|
def __init__(self, topic: str):
|
||||||
|
# Model 变化时发布的主题。
|
||||||
|
self._topic = topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回 Model 对应主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
def signal_proxy(self, *args):
|
||||||
|
"""事件代理函数,具体 Model 可重写。"""
|
||||||
|
print('{} event proxy run'.format(type(self)))
|
||||||
|
|
||||||
|
|
||||||
|
class BaseView(object):
|
||||||
|
"""View 基类:保存主题,并提供可被 Model 事件调用的代理方法。"""
|
||||||
|
|
||||||
|
def __init__(self, topic: str):
|
||||||
|
# View 变化时发布的主题。
|
||||||
|
self._topic = topic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topic(self):
|
||||||
|
"""返回 View 对应主题。"""
|
||||||
|
return self._topic
|
||||||
|
|
||||||
|
def signal_proxy(self, *args):
|
||||||
|
"""事件代理函数,具体 View 可重写。"""
|
||||||
|
print('{} event proxy run'.format(type(self)))
|
||||||
|
|
||||||
|
|
||||||
|
@unique
|
||||||
|
class BindingStyle(Enum):
|
||||||
|
"""Model 与 View 的绑定方向。"""
|
||||||
|
|
||||||
|
DOUBLE_BINDING = 0 # 双向绑定。
|
||||||
|
MODEL_TO_VIEW = 1 # Model -> View。
|
||||||
|
VIEW_TO_MODEL = 2 # View -> Model。
|
||||||
|
|
||||||
|
|
||||||
|
class Framework(object):
|
||||||
|
"""框架门面对象:负责启动通道和建立绑定。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 框架内部唯一事件通道。
|
||||||
|
self.channel = EventChannel()
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
"""启动事件通道线程。"""
|
||||||
|
self.channel.daemon = True
|
||||||
|
self.channel.start()
|
||||||
|
|
||||||
|
def binding(self, model: BaseModel, view: BaseView, style=BindingStyle.DOUBLE_BINDING):
|
||||||
|
"""按指定方向绑定 Model 与 View。"""
|
||||||
|
if style == BindingStyle.DOUBLE_BINDING:
|
||||||
|
self.channel.subscribe(model.topic, view.signal_proxy)
|
||||||
|
self.channel.subscribe(view.topic, model.signal_proxy)
|
||||||
|
elif style == BindingStyle.MODEL_TO_VIEW:
|
||||||
|
# 模型改变引起视图改变。
|
||||||
|
self.channel.subscribe(model.topic, view.signal_proxy)
|
||||||
|
elif style == BindingStyle.VIEW_TO_MODEL:
|
||||||
|
# 视图改变引起模型改变。
|
||||||
|
self.channel.subscribe(view.topic, model.signal_proxy)
|
||||||
|
else:
|
||||||
|
raise RuntimeWarning('binding is wrong')
|
||||||
|
|
||||||
|
|
||||||
|
# 框架单例对象,业务代码通过它发布、订阅和绑定。
|
||||||
|
fw_proxy = Framework()
|
||||||
Reference in New Issue
Block a user