基于Python+PyQt+MVVM方式开发桌面应用
作者:yuanpan
很多 Python 开发者第一次写 PyQt 桌面应用时,代码很容易变成这样:
- 按钮点击逻辑写在窗口类里。
- 数据处理也写在窗口类里。
- 数据校验、状态刷新、列表更新都写在窗口类里。
- 项目越写越大,最后
MainWindow变成几千行。
小 demo 这样写没问题,但如果你要做一个长期维护的桌面应用,比如数据处理工具、内部管理软件、设备控制客户端、图像处理工具,就需要更清晰的代码组织方式。
这篇文章用一个简单的待办事项小工具,带大家入门 Python + PyQt6 + MVVM 的开发方式。
说明:本文使用 PyQt6 演示。根据 PyPI 页面,PyQt6 是 Qt6 的 Python 绑定,截至 2026-04-22 可见的最新版本为 6.11.0,安装要求 Python >=3.10。PyQt6 使用 GPL v3 或商业授权,正式商业项目请提前确认授权。
一、什么是 MVVM
MVVM 是 Model - View - ViewModel 的缩写。
在桌面应用中,可以这样理解:
| 层 | 负责什么 | 不应该负责什么 |
|---|---|---|
| Model | 业务数据、业务规则、数据结构 | 不关心按钮、窗口、控件 |
| View | 窗口、按钮、输入框、列表等界面控件 | 不写复杂业务逻辑 |
| ViewModel | 连接 View 和 Model,处理命令、状态、校验、通知界面刷新 | 不直接依赖具体界面布局 |
简单说:
Model关心“数据是什么”。View关心“界面长什么样”。ViewModel关心“界面操作后应该怎么改变数据和状态”。
二、PyQt 里为什么适合用 MVVM
Qt 本身有两个非常重要的机制:
Signals / Slots:信号和槽,用来做对象之间的通信。Model / View:模型视图机制,用来把数据和显示控件分离。
Qt 官方文档中提到,Model/View 架构用于管理数据与显示方式之间的关系,数据变化可以通过信号通知视图刷新。Signals/Slots 则是 Qt 对象之间通信的核心机制。
这两个机制和 MVVM 很契合:
- View 点击按钮,调用 ViewModel 的方法。
- ViewModel 修改 Model。
- ViewModel 通过信号通知 View 更新状态。
- View 使用 Qt 的 item model 显示列表、表格、树。
需要注意的是,Qt 里的 Model/View 和 MVVM 里的 Model 不是一回事:
- MVVM 的
Model是业务模型,比如TodoItem、User、Order。 - Qt 的
QStandardItemModel、QAbstractListModel是给界面控件显示数据用的模型。
初学时先把这两件事区分开,后面架构会清晰很多。
三、本文要做的演示程序
我们做一个简单的待办事项桌面应用:
功能包括:
- 输入任务名称。
- 点击按钮添加任务。
- 在列表中显示任务。
- 双击任务切换完成状态。
- 选中任务后可以删除。
- 底部显示统计信息。
- 输入为空时给出提示。
这个功能不复杂,但足够演示 MVVM 的基本分层。
四、项目结构
建议使用下面的目录结构:
pyqt_mvvm_demo/
requirements.txt
main.py
models/
__init__.py
todo_item.py
viewmodels/
__init__.py
todo_list_view_model.py
views/
__init__.py
main_window.py每个目录的职责:
| 文件 | 职责 |
|---|---|
models/todo_item.py | 定义业务数据结构 |
viewmodels/todo_list_view_model.py | 管理任务列表、处理添加/删除/切换完成状态 |
views/main_window.py | 创建窗口和控件,绑定 ViewModel |
main.py | 程序入口 |
五、安装 PyQt6
先创建虚拟环境:
python -m venv .venv
Windows 激活:
.venv\Scripts\activate
macOS / Linux 激活:
source .venv/bin/activate
安装 PyQt6:
pip install PyQt6
requirements.txt:
PyQt6>=6.11.0
如果你的环境暂时不能安装最新版本,也可以先不锁版本:
PyQt6
六、编写 Model:业务数据
创建 models/todo_item.py:
from dataclasses import dataclass
@dataclass
class TodoItem:
title: str
done: bool = False
这个类非常简单,只表达业务数据:
title:任务标题。done:是否完成。
它不关心界面,也不依赖 PyQt。
这就是 MVVM 里 Model 层应该保持的状态:干净、简单、可测试。
七、编写 ViewModel:业务状态和界面命令
创建 viewmodels/todo_list_view_model.py:
from PyQt6.QtCore import QObject, Qt, pyqtSignal
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from models.todo_item import TodoItem
class TodoListViewModel(QObject):
message_changed = pyqtSignal(str)
stats_changed = pyqtSignal(str)
def __init__(self):
super().__init__()
self._items: list[TodoItem] = []
self.list_model = QStandardItemModel()
self.list_model.itemChanged.connect(self._on_item_changed)
self._update_stats()
def add_task(self, title: str) -> bool:
title = title.strip()
if not title:
self.message_changed.emit("任务名称不能为空")
return False
item = TodoItem(title=title)
self._items.append(item)
self._append_item_to_view_model(item)
self.message_changed.emit(f"已添加任务:{title}")
self._update_stats()
return True
def remove_task(self, row: int) -> None:
if row < 0 or row >= len(self._items):
self.message_changed.emit("请先选择要删除的任务")
return
removed = self._items.pop(row)
self.list_model.removeRow(row)
self.message_changed.emit(f"已删除任务:{removed.title}")
self._update_stats()
def toggle_task(self, row: int) -> None:
if row < 0 or row >= len(self._items):
return
item = self._items[row]
item.done = not item.done
qt_item = self.list_model.item(row)
qt_item.setCheckState(
Qt.CheckState.Checked if item.done else Qt.CheckState.Unchecked
)
self.message_changed.emit(
f"已完成:{item.title}" if item.done else f"已取消完成:{item.title}"
)
self._update_stats()
def _append_item_to_view_model(self, todo: TodoItem) -> None:
qt_item = QStandardItem(todo.title)
qt_item.setEditable(False)
qt_item.setCheckable(True)
qt_item.setCheckState(
Qt.CheckState.Checked if todo.done else Qt.CheckState.Unchecked
)
qt_item.setData(todo, Qt.ItemDataRole.UserRole)
self.list_model.appendRow(qt_item)
def _on_item_changed(self, qt_item: QStandardItem) -> None:
row = qt_item.row()
if row < 0 or row >= len(self._items):
return
self._items[row].done = qt_item.checkState() == Qt.CheckState.Checked
self._update_stats()
def _update_stats(self) -> None:
total = len(self._items)
done = sum(1 for item in self._items if item.done)
pending = total - done
self.stats_changed.emit(f"总数:{total},已完成:{done},未完成:{pending}")
这一层是本文最关键的部分。
ViewModel 做了几件事:
- 保存业务数据列表:
self._items。 - 提供给界面显示的 Qt 模型:
self.list_model。 - 提供界面可调用的命令:
add_task()、remove_task()、toggle_task()。 - 通过信号告诉界面状态变化:
message_changed、stats_changed。
ViewModel 不应该做的事:
- 不创建窗口。
- 不直接操作按钮样式。
- 不关心控件放在第几行第几列。
这样以后即使 View 从 QWidget 改成 QML,或者从桌面窗口改成别的界面形式,很多业务逻辑仍然可以复用。
八、编写 View:窗口和控件
创建 views/main_window.py:
from PyQt6.QtCore import QModelIndex
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QListView,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
from viewmodels.todo_list_view_model import TodoListViewModel
class MainWindow(QMainWindow):
def __init__(self, view_model: TodoListViewModel):
super().__init__()
self.view_model = view_model
self.setWindowTitle("PyQt6 + MVVM 待办事项")
self.resize(520, 360)
self.task_input = QLineEdit()
self.task_input.setPlaceholderText("输入任务名称")
self.add_button = QPushButton("添加")
self.remove_button = QPushButton("删除选中")
self.remove_button.setEnabled(False)
self.todo_list = QListView()
self.todo_list.setModel(self.view_model.list_model)
self.message_label = QLabel("请输入任务名称,然后点击添加")
self.stats_label = QLabel()
self._build_layout()
self._bind_events()
self._bind_view_model()
def _build_layout(self) -> None:
input_layout = QHBoxLayout()
input_layout.addWidget(self.task_input)
input_layout.addWidget(self.add_button)
button_layout = QHBoxLayout()
button_layout.addStretch()
button_layout.addWidget(self.remove_button)
root_layout = QVBoxLayout()
root_layout.addLayout(input_layout)
root_layout.addWidget(self.todo_list)
root_layout.addLayout(button_layout)
root_layout.addWidget(self.stats_label)
root_layout.addWidget(self.message_label)
container = QWidget()
container.setLayout(root_layout)
self.setCentralWidget(container)
def _bind_events(self) -> None:
self.add_button.clicked.connect(self._on_add_clicked)
self.task_input.returnPressed.connect(self._on_add_clicked)
self.remove_button.clicked.connect(self._on_remove_clicked)
self.todo_list.doubleClicked.connect(self._on_task_double_clicked)
self.todo_list.selectionModel().currentChanged.connect(self._on_selection_changed)
def _bind_view_model(self) -> None:
self.view_model.message_changed.connect(self.message_label.setText)
self.view_model.stats_changed.connect(self.stats_label.setText)
def _on_add_clicked(self) -> None:
ok = self.view_model.add_task(self.task_input.text())
if ok:
self.task_input.clear()
self.task_input.setFocus()
def _on_remove_clicked(self) -> None:
row = self.todo_list.currentIndex().row()
self.view_model.remove_task(row)
def _on_task_double_clicked(self, index: QModelIndex) -> None:
self.view_model.toggle_task(index.row())
def _on_selection_changed(self, current: QModelIndex) -> None:
self.remove_button.setEnabled(current.isValid())
View 层主要做三件事:
- 创建界面控件。
- 绑定控件事件。
- 绑定 ViewModel 的信号。
这里的窗口类没有直接修改 _items,也没有自己计算任务统计信息。它只负责把用户操作转交给 ViewModel。
这就是 MVVM 的核心思想:View 尽量薄,业务状态放到 ViewModel。
九、编写程序入口
创建 main.py:
import sys
from PyQt6.QtWidgets import QApplication
from viewmodels.todo_list_view_model import TodoListViewModel
from views.main_window import MainWindow
def main() -> int:
app = QApplication(sys.argv)
view_model = TodoListViewModel()
window = MainWindow(view_model)
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
运行:
python main.py
十、为了方便复制,这里给一个单文件版本
如果你只是想先跑起来,可以把下面代码保存为 app.py。
import sys
from dataclasses import dataclass
from PyQt6.QtCore import QModelIndex, QObject, Qt, pyqtSignal
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QLineEdit,
QListView,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
@dataclass
class TodoItem:
title: str
done: bool = False
class TodoListViewModel(QObject):
message_changed = pyqtSignal(str)
stats_changed = pyqtSignal(str)
def __init__(self):
super().__init__()
self._items: list[TodoItem] = []
self.list_model = QStandardItemModel()
self.list_model.itemChanged.connect(self._on_item_changed)
self._update_stats()
def add_task(self, title: str) -> bool:
title = title.strip()
if not title:
self.message_changed.emit("任务名称不能为空")
return False
todo = TodoItem(title=title)
self._items.append(todo)
qt_item = QStandardItem(todo.title)
qt_item.setEditable(False)
qt_item.setCheckable(True)
qt_item.setCheckState(Qt.CheckState.Unchecked)
qt_item.setData(todo, Qt.ItemDataRole.UserRole)
self.list_model.appendRow(qt_item)
self.message_changed.emit(f"已添加任务:{title}")
self._update_stats()
return True
def remove_task(self, row: int) -> None:
if row < 0 or row >= len(self._items):
self.message_changed.emit("请先选择要删除的任务")
return
removed = self._items.pop(row)
self.list_model.removeRow(row)
self.message_changed.emit(f"已删除任务:{removed.title}")
self._update_stats()
def toggle_task(self, row: int) -> None:
if row < 0 or row >= len(self._items):
return
todo = self._items[row]
todo.done = not todo.done
qt_item = self.list_model.item(row)
qt_item.setCheckState(
Qt.CheckState.Checked if todo.done else Qt.CheckState.Unchecked
)
self.message_changed.emit(
f"已完成:{todo.title}" if todo.done else f"已取消完成:{todo.title}"
)
self._update_stats()
def _on_item_changed(self, qt_item: QStandardItem) -> None:
row = qt_item.row()
if row < 0 or row >= len(self._items):
return
self._items[row].done = qt_item.checkState() == Qt.CheckState.Checked
self._update_stats()
def _update_stats(self) -> None:
total = len(self._items)
done = sum(1 for item in self._items if item.done)
pending = total - done
self.stats_changed.emit(f"总数:{total},已完成:{done},未完成:{pending}")
class MainWindow(QMainWindow):
def __init__(self, view_model: TodoListViewModel):
super().__init__()
self.view_model = view_model
self.setWindowTitle("PyQt6 + MVVM 待办事项")
self.resize(520, 360)
self.task_input = QLineEdit()
self.task_input.setPlaceholderText("输入任务名称")
self.add_button = QPushButton("添加")
self.remove_button = QPushButton("删除选中")
self.remove_button.setEnabled(False)
self.todo_list = QListView()
self.todo_list.setModel(self.view_model.list_model)
self.stats_label = QLabel()
self.message_label = QLabel("请输入任务名称,然后点击添加")
self._build_layout()
self._bind_events()
self._bind_view_model()
def _build_layout(self) -> None:
input_layout = QHBoxLayout()
input_layout.addWidget(self.task_input)
input_layout.addWidget(self.add_button)
button_layout = QHBoxLayout()
button_layout.addStretch()
button_layout.addWidget(self.remove_button)
root_layout = QVBoxLayout()
root_layout.addLayout(input_layout)
root_layout.addWidget(self.todo_list)
root_layout.addLayout(button_layout)
root_layout.addWidget(self.stats_label)
root_layout.addWidget(self.message_label)
container = QWidget()
container.setLayout(root_layout)
self.setCentralWidget(container)
def _bind_events(self) -> None:
self.add_button.clicked.connect(self._on_add_clicked)
self.task_input.returnPressed.connect(self._on_add_clicked)
self.remove_button.clicked.connect(self._on_remove_clicked)
self.todo_list.doubleClicked.connect(self._on_task_double_clicked)
self.todo_list.selectionModel().currentChanged.connect(self._on_selection_changed)
def _bind_view_model(self) -> None:
self.view_model.message_changed.connect(self.message_label.setText)
self.view_model.stats_changed.connect(self.stats_label.setText)
def _on_add_clicked(self) -> None:
ok = self.view_model.add_task(self.task_input.text())
if ok:
self.task_input.clear()
self.task_input.setFocus()
def _on_remove_clicked(self) -> None:
self.view_model.remove_task(self.todo_list.currentIndex().row())
def _on_task_double_clicked(self, index: QModelIndex) -> None:
self.view_model.toggle_task(index.row())
def _on_selection_changed(self, current: QModelIndex) -> None:
self.remove_button.setEnabled(current.isValid())
def main() -> int:
app = QApplication(sys.argv)
view_model = TodoListViewModel()
window = MainWindow(view_model)
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
运行:
python app.py
十一、这个例子里的 MVVM 关系
可以用一张简单的关系图理解:
用户点击按钮
|
v
View(MainWindow)
|
v
ViewModel(add_task / remove_task / toggle_task)
|
v
Model(TodoItem)
|
v
ViewModel 发出 message_changed / stats_changed 信号
|
v
View 更新 QLabel / QListView
关键点是:View 不直接处理业务。
比如添加任务时,View 只做:
ok = self.view_model.add_task(self.task_input.text())
至于:
- 标题能不能为空。
- 添加到哪里。
- 统计信息怎么更新。
- 错误信息怎么提示。
这些都放在 ViewModel 中。
十二、为什么不用 MainWindow 直接写完
当然可以直接写在 MainWindow 里:
def on_add_clicked(self):
title = self.task_input.text().strip()
if not title:
self.message_label.setText("不能为空")
return
self.items.append(title)
self.list_widget.addItem(title)
小工具没问题。
但项目一旦变复杂,就会出现这些问题:
- 窗口类越来越长。
- 业务逻辑和界面逻辑混在一起。
- 很难写单元测试。
- 多个窗口复用同一套逻辑很麻烦。
- UI 改版时容易误伤业务逻辑。
MVVM 的价值不是让代码变少,而是让代码边界更清楚。
十三、初学者常见误区
误区 1:ViewModel 里不能出现任何 Qt 类型
在 PyQt 项目里,ViewModel 继承 QObject,使用 pyqtSignal 是很常见的做法。它确实依赖 Qt,但它不应该依赖具体窗口控件。
可以接受:
class UserViewModel(QObject):
message_changed = pyqtSignal(str)
不建议:
class UserViewModel(QObject):
def __init__(self, label: QLabel):
self.label = label
前者是用 Qt 的通信机制,后者是直接操控界面控件。
误区 2:用了 QStandardItemModel 就等于用了 MVVM
不是。
QStandardItemModel 是 Qt 的界面数据模型,主要服务于 QListView、QTableView、QTreeView。
MVVM 的重点是:
- 业务数据放在哪里。
- 用户命令放在哪里。
- View 和业务逻辑是否解耦。
- 状态变化如何通知界面。
误区 3:所有逻辑都要拆得很细
也不是。
如果只是一个 200 行的小工具,没必要强行拆 10 个文件。可以先用单文件版本写清楚:
- Model 类。
- ViewModel 类。
- View 类。
等功能变多,再拆目录。
十四、继续扩展这个 demo
这个 demo 可以继续扩展成更像真实项目的结构:
- 加入文件保存:把任务列表保存为 JSON。
- 加入数据库:用 SQLite 保存任务。
- 加入搜索框:View 调用 ViewModel 的过滤方法。
- 加入任务优先级:Model 增加
priority字段。 - 加入多窗口:多个 View 共用同一个 ViewModel 或共享 Service。
- 加入单元测试:直接测试 ViewModel 的添加、删除、统计逻辑。
例如,保存 JSON 时可以加一个 services/todo_repository.py:
import json
from pathlib import Path
from models.todo_item import TodoItem
class TodoRepository:
def __init__(self, path: str = "todos.json"):
self.path = Path(path)
def load(self) -> list[TodoItem]:
if not self.path.exists():
return []
data = json.loads(self.path.read_text(encoding="utf-8"))
return [TodoItem(**item) for item in data]
def save(self, items: list[TodoItem]) -> None:
data = [item.__dict__ for item in items]
self.path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
然后 ViewModel 依赖 Repository,而不是在 View 里读写文件。
十五、总结
使用 PyQt 开发桌面应用时,最容易犯的错误是把所有代码都塞进窗口类。短期看写得快,长期看维护成本会越来越高。
MVVM 的核心价值是分层:
- Model:保存业务数据。
- View:显示界面,接收用户操作。
- ViewModel:处理界面命令、业务状态和信号通知。
在 PyQt 中,可以使用:
QObjectpyqtSignalQStandardItemModelQListViewQMainWindow
来搭建一套简单但清晰的 MVVM 架构。
对于 Python 开发者来说,刚开始不需要追求完美架构。建议先记住一句话:
让窗口类少写业务逻辑,让 ViewModel 管状态和命令。
只要做到这一点,你的 PyQt 项目就会比“所有代码都写在 MainWindow 里”更容易维护。
以上就是基于Python+PyQt+MVVM方式开发桌面应用的详细内容,更多关于Python PyQt MVVM桌面应用的资料请关注脚本之家其它相关文章!
