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

12 KiB
Raw Permalink Blame History

Python-MVVM 学习教程

这份教程的目标不是只让你“看懂每一行”,而是让你能真正掌握这个小项目背后的设计方式:为什么分层、事件怎么流动、怎么自己扩展一个新的绑定。

1. 先建立整体图景

这个项目可以看成五层:

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 容易展示的形式。

传统写法里,按钮点击后可能直接执行:

self.LogBrowser.append("log: yes!!!")

这样的问题是:界面控件和业务逻辑绑死了。以后如果日志不显示在 QTextBrowser而是写入文件、发到网络、显示在别的控件里逻辑代码就要跟着改。

这个项目的写法是:

self.model_log.value = 'log: yes!!!'

ViewModel 只关心“日志数据变了”。至于日志怎么显示,是 View 的职责。

3. 程序启动流程

入口文件是 demo/start_up.py

关键代码按顺序发生:

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 主事件循环。

这里有两个循环:

Qt 主事件循环:负责窗口、按钮点击、绘制等 GUI 事件
EventChannel 线程:负责 Model/View 之间的发布-订阅事件

4. 界面是怎么来的

demo/design/main_win.ui 是 Qt Designer 保存的 XML 界面文件。

demo/design/main_win.py 是由 .ui 文件生成的 Python 代码。它会创建这些成员:

self.LogBrowser  # QTextBrowser用来显示日志
self.YesBtn      # QPushButtonyes 按钮
self.NoBtn       # QPushButtonno 按钮

MainWinView 继承了两个类:

class MainWinView(QMainWindow, main_win.Ui_MainWindow):

含义是:

QMainWindow              提供真正的窗口能力
main_win.Ui_MainWindow   提供 setupUi负责创建控件

所以 MainWinView.__init__ 中调用:

self.setupUi(self)

之后,self.LogBrowserself.YesBtnself.NoBtn 就可以使用了。

5. View 层怎么工作

demo/mvvm/view.py 里有两个类:

class LogView(BaseView)
class MainWinView(QMainWindow, main_win.Ui_MainWindow)

MainWinView 是窗口整体。
LogView 是对日志控件的 MVVM 包装。

LogView.signal_proxy 是最关键的方法:

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 保存一个日志模型:

self.model_log = StringModel('log_to_view', '启动日志')

这里的 'log_to_view' 是主题名。你可以理解成广播频道:

model_log 在 log_to_view 频道发布消息
view_log 订阅 log_to_view 频道

两个按钮方法很短:

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

@value.setter
def value(self, value: str):
    self._string = value
    fw_proxy.channel.publish(self._topic, value)

这段代码表达了一个重要思想:

只要数据变了,就发布事件。

所以调用:

self.model_log.value = 'log: yes!!!'

实际会触发:

保存新值
-> publish('log_to_view', 'log: yes!!!')
-> 事件进入队列
-> EventChannel 取出事件
-> 找到订阅 log_to_view 的回调
-> 调用 LogView.signal_proxy('log: yes!!!')

8. binder.py 为什么存在

binder.py 是专门做连接的地方。

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 信号绑定:

按钮 clicked -> ViewModel 方法

第二类是 MVVM 数据绑定:

Model topic -> View signal_proxy
View topic  -> Model signal_proxy

把绑定集中在一个文件里项目变大后会更清晰View 不需要知道 ViewModel 怎么创建ViewModel 也不需要知道具体控件怎么连接。

9. framework.py 核心机制

框架由这几个对象组成:

Subscriber    订阅者topic + callback
Publisher     事件对象topic + content
EventChannel  事件通道:队列 + 订阅表 + 分发线程
BaseModel     Model 基类
BaseView      View 基类
BindingStyle  绑定方向枚举
Framework     框架门面
fw_proxy      全局框架对象

9.1 Subscriber

订阅者保存:

self._topic = topic
self._callback = callback

比如绑定后可能形成这样的订阅者:

topic: "log_to_view"
callback: ui.view_log.signal_proxy

9.2 Publisher

发布事件时会创建:

Publisher(topic, content)

比如:

topic: "log_to_view"
content: "log: yes!!!"

它就是一个消息包。

9.3 EventChannel

EventChannel 继承 Thread,所以它可以在后台跑。

它有两个关键数据结构:

self._queue = Queue()
self._resigster_container = {}

_queue 保存还没处理的事件。
_resigster_container 保存订阅关系。

订阅关系大概长这样:

{
    "log_to_view": [Subscriber(callback=LogView.signal_proxy)],
    "view_to_log": [Subscriber(callback=StringModel.signal_proxy)]
}

9.4 publish

def publish(self, topic: str, content):
    puber = Publisher(topic, content)
    self._queue.put(puber)

发布不是立刻执行回调,而是先放进队列。这让发布者和消费者进一步解耦。

9.5 run

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

def binding(self, model, view, style=BindingStyle.DOUBLE_BINDING):

支持三种绑定方式:

DOUBLE_BINDING  双向绑定
MODEL_TO_VIEW   只允许 Model 更新 View
VIEW_TO_MODEL   只允许 View 更新 Model

demo 里调用:

fw_proxy.binding(vm.model_log, ui.view_log)

默认是双向绑定,所以实际订阅了两条关系:

model_log.topic -> view_log.signal_proxy
view_log.topic  -> model_log.signal_proxy

不过当前 demo 中 LogView 没有主动发布 View 事件,所以实际运行时主要体现的是 Model -> View

10. 点击 yes 后的完整调用链

这条链建议你反复对照源码看:

用户点击 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 中增加方法:
def event_log_clear(self):
    self.model_log.value = ''
  1. LogView.signal_proxy 中判断空字符串时清空:
if log == '':
    self.browser.clear()
else:
    self.browser.append(log)
  1. binder.py 中连接按钮:
ui.ClearBtn.clicked.connect(vm.event_log_clear)

这个练习能帮你理解新增功能时UI、ViewModel、View 各自该改什么。

12. 再扩展一个真正的双向绑定

当前 demo 主要展示 Model -> View。要练习 View -> Model可以加一个输入框。

目标:

用户在 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. 这个项目的优点与局限

优点:

结构非常小,适合学习 MVVM 和发布-订阅。
ViewModel 不直接操作控件,降低耦合。
框架不依赖具体 GUI 库,理论上可接入其他界面框架。

局限:

事件线程直接调用 PyQt 控件更新,在大型 PyQt 项目中更推荐使用 Qt Signal 跨线程更新 UI。
EventChannel 没有优雅停止机制queue.get() 阻塞时不会自动醒来。
没有取消订阅功能,复杂项目中需要 unsubscribe。
没有错误隔离,一个 callback 抛异常可能影响事件线程。

学习时先理解它的思想;真正做生产级框架时,再补这些工程能力。

14. 推荐阅读顺序

第一遍只看流程:

start_up.py -> binder.py -> view_model.py -> model.py -> framework.py -> view.py

第二遍看事件机制:

Framework.binding
EventChannel.subscribe
StringModel.value.setter
EventChannel.publish
EventChannel.run
LogView.signal_proxy

第三遍自己动手:

改按钮文案
改日志内容
新增一个按钮
新增一个 Model
新增一个 View
尝试 View -> Model 绑定

15. 最小心智模型

把这个项目记成四句话:

View 只负责显示。
ViewModel 只负责处理界面动作背后的逻辑。
Model 保存数据,数据变了就发布事件。
Framework 负责把事件送到订阅者那里。

掌握这四句话,再回头读代码,会轻松很多。