From b22e46dd605fe9552c4bc1052d8c797a6e1648ac Mon Sep 17 00:00:00 2001 From: rxy <280249380@qq.com> Date: Mon, 18 May 2026 11:33:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 129 +++++ README.md | 55 ++ demo/.idea/.gitignore | 2 + demo/.idea/demo.iml | 8 + .../inspectionProfiles/profiles_settings.xml | 6 + demo/.idea/misc.xml | 4 + demo/.idea/modules.xml | 8 + demo/.idea/vcs.xml | 6 + demo/design/__init__.py | 1 + demo/design/main_win.py | 87 +++ demo/design/main_win.ui | 78 +++ demo/mvvm/__init__.py | 1 + demo/mvvm/binder.py | 24 + demo/mvvm/framework.py | 210 ++++++++ demo/mvvm/model.py | 45 ++ demo/mvvm/view.py | 49 ++ demo/mvvm/view_model.py | 26 + demo/start_up.py | 33 ++ docs/MVVM学习教程.md | 496 ++++++++++++++++++ framework.py | 172 ++++++ 20 files changed, 1440 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 demo/.idea/.gitignore create mode 100644 demo/.idea/demo.iml create mode 100644 demo/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 demo/.idea/misc.xml create mode 100644 demo/.idea/modules.xml create mode 100644 demo/.idea/vcs.xml create mode 100644 demo/design/__init__.py create mode 100644 demo/design/main_win.py create mode 100644 demo/design/main_win.ui create mode 100644 demo/mvvm/__init__.py create mode 100644 demo/mvvm/binder.py create mode 100644 demo/mvvm/framework.py create mode 100644 demo/mvvm/model.py create mode 100644 demo/mvvm/view.py create mode 100644 demo/mvvm/view_model.py create mode 100644 demo/start_up.py create mode 100644 docs/MVVM学习教程.md create mode 100644 framework.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..316d3b4 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/demo/.idea/.gitignore b/demo/.idea/.gitignore new file mode 100644 index 0000000..5c98b42 --- /dev/null +++ b/demo/.idea/.gitignore @@ -0,0 +1,2 @@ +# Default ignored files +/workspace.xml \ No newline at end of file diff --git a/demo/.idea/demo.iml b/demo/.idea/demo.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/demo/.idea/demo.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/demo/.idea/inspectionProfiles/profiles_settings.xml b/demo/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/demo/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/demo/.idea/misc.xml b/demo/.idea/misc.xml new file mode 100644 index 0000000..eaa7d5d --- /dev/null +++ b/demo/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/demo/.idea/modules.xml b/demo/.idea/modules.xml new file mode 100644 index 0000000..c95e899 --- /dev/null +++ b/demo/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/demo/.idea/vcs.xml b/demo/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/demo/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/demo/design/__init__.py b/demo/design/__init__.py new file mode 100644 index 0000000..9990e34 --- /dev/null +++ b/demo/design/__init__.py @@ -0,0 +1 @@ +"""Qt Designer 生成界面代码所在包。""" diff --git a/demo/design/main_win.py b/demo/design/main_win.py new file mode 100644 index 0000000..5d69a6f --- /dev/null +++ b/demo/design/main_win.py @@ -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")) diff --git a/demo/design/main_win.ui b/demo/design/main_win.ui new file mode 100644 index 0000000..8938149 --- /dev/null +++ b/demo/design/main_win.ui @@ -0,0 +1,78 @@ + + + MainWindow + + + + 0 + 0 + 611 + 571 + + + + MainWindow + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Qt::Horizontal + + + + 132 + 20 + + + + + + + + yes + + + + + + + no + + + + + + + Qt::Horizontal + + + + 131 + 20 + + + + + + + + + + + + + diff --git a/demo/mvvm/__init__.py b/demo/mvvm/__init__.py new file mode 100644 index 0000000..8d2699e --- /dev/null +++ b/demo/mvvm/__init__.py @@ -0,0 +1 @@ +"""MVVM demo 包:包含框架、模型、视图、视图模型和绑定逻辑。""" diff --git a/demo/mvvm/binder.py b/demo/mvvm/binder.py new file mode 100644 index 0000000..89626ad --- /dev/null +++ b/demo/mvvm/binder.py @@ -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) diff --git a/demo/mvvm/framework.py b/demo/mvvm/framework.py new file mode 100644 index 0000000..9a49fe0 --- /dev/null +++ b/demo/mvvm/framework.py @@ -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() diff --git a/demo/mvvm/model.py b/demo/mvvm/model.py new file mode 100644 index 0000000..4002094 --- /dev/null +++ b/demo/mvvm/model.py @@ -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 diff --git a/demo/mvvm/view.py b/demo/mvvm/view.py new file mode 100644 index 0000000..9ca59ac --- /dev/null +++ b/demo/mvvm/view.py @@ -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) diff --git a/demo/mvvm/view_model.py b/demo/mvvm/view_model.py new file mode 100644 index 0000000..021a855 --- /dev/null +++ b/demo/mvvm/view_model.py @@ -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~~~' diff --git a/demo/start_up.py b/demo/start_up.py new file mode 100644 index 0000000..c495ad3 --- /dev/null +++ b/demo/start_up.py @@ -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_()) diff --git a/docs/MVVM学习教程.md b/docs/MVVM学习教程.md new file mode 100644 index 0000000..baa64ef --- /dev/null +++ b/docs/MVVM学习教程.md @@ -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 负责把事件送到订阅者那里。 +``` + +掌握这四句话,再回头读代码,会轻松很多。 diff --git a/framework.py b/framework.py new file mode 100644 index 0000000..e94a329 --- /dev/null +++ b/framework.py @@ -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()