12 KiB
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 改 Model,Model 发事件,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_())
逐步理解:
QApplication创建 PyQt 应用。MainWinView()创建主窗口和控件。MainWinVM()创建主窗口对应的 ViewModel。binding_main_ui(...)把按钮和业务方法连起来,把 Model 和 View 绑定起来。ui_main_win.show()显示窗口。fw_proxy.work()启动 MVVM 框架自己的事件通道线程。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 # QPushButton,yes 按钮
self.NoBtn # QPushButton,no 按钮
MainWinView 继承了两个类:
class MainWinView(QMainWindow, main_win.Ui_MainWindow):
含义是:
QMainWindow 提供真正的窗口能力
main_win.Ui_MainWindow 提供 setupUi,负责创建控件
所以 MainWinView.__init__ 中调用:
self.setupUi(self)
之后,self.LogBrowser、self.YesBtn、self.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 发布日志事件后,框架会调用这个方法。它做两件事:
- 把日志追加到 QTextBrowser。
- 把光标移动到底部,让最新日志可见。
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)
这是事件分发的核心:
- 从队列取一个事件。
- 根据事件 topic 找订阅者。
- 依次调用订阅者的 callback。
- 把事件 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 按钮,点击后清空日志。
思路如下:
- 在 Qt Designer 里给界面加一个按钮,命名为
ClearBtn。 - 重新生成
design/main_win.py。 - 在
MainWinVM中增加方法:
def event_log_clear(self):
self.model_log.value = ''
- 在
LogView.signal_proxy中判断空字符串时清空:
if log == '':
self.browser.clear()
else:
self.browser.append(log)
- 在
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
你需要做:
- 新增一个
QLineEdit。 - 写一个
LineEditView(BaseView)。 - 监听
textChanged信号。 - 在文本变化时调用
fw_proxy.channel.publish(self.topic, text)。 - 用
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 负责把事件送到订阅者那里。
掌握这四句话,再回头读代码,会轻松很多。