python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python PyQt MVVM桌面应用

基于Python+PyQt+MVVM方式开发桌面应用

作者:yuanpan

本文通过一个简单的待办事项小工具,介绍了使用Python和PyQt6实现MVVM架构的方法,文章详细解释了什么是MVVM,并展示了如何将项目分为Model、View和ViewModel三层,从而使代码更加清晰、可维护,文中还提供了项目结构建议、代码示例和安装方法,需要的朋友可以参考下

很多 Python 开发者第一次写 PyQt 桌面应用时,代码很容易变成这样:

小 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,处理命令、状态、校验、通知界面刷新不直接依赖具体界面布局

简单说:

二、PyQt 里为什么适合用 MVVM

Qt 本身有两个非常重要的机制:

  1. Signals / Slots:信号和槽,用来做对象之间的通信。
  2. Model / View:模型视图机制,用来把数据和显示控件分离。

Qt 官方文档中提到,Model/View 架构用于管理数据与显示方式之间的关系,数据变化可以通过信号通知视图刷新。Signals/Slots 则是 Qt 对象之间通信的核心机制。

这两个机制和 MVVM 很契合:

需要注意的是,Qt 里的 Model/View 和 MVVM 里的 Model 不是一回事:

初学时先把这两件事区分开,后面架构会清晰很多。

三、本文要做的演示程序

我们做一个简单的待办事项桌面应用:

功能包括:

  1. 输入任务名称。
  2. 点击按钮添加任务。
  3. 在列表中显示任务。
  4. 双击任务切换完成状态。
  5. 选中任务后可以删除。
  6. 底部显示统计信息。
  7. 输入为空时给出提示。

这个功能不复杂,但足够演示 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

这个类非常简单,只表达业务数据:

它不关心界面,也不依赖 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 做了几件事:

  1. 保存业务数据列表:self._items
  2. 提供给界面显示的 Qt 模型:self.list_model
  3. 提供界面可调用的命令:add_task()remove_task()toggle_task()
  4. 通过信号告诉界面状态变化:message_changedstats_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 层主要做三件事:

  1. 创建界面控件。
  2. 绑定控件事件。
  3. 绑定 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)

小工具没问题。

但项目一旦变复杂,就会出现这些问题:

  1. 窗口类越来越长。
  2. 业务逻辑和界面逻辑混在一起。
  3. 很难写单元测试。
  4. 多个窗口复用同一套逻辑很麻烦。
  5. 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 的界面数据模型,主要服务于 QListViewQTableViewQTreeView

MVVM 的重点是:

误区 3:所有逻辑都要拆得很细

也不是。

如果只是一个 200 行的小工具,没必要强行拆 10 个文件。可以先用单文件版本写清楚:

等功能变多,再拆目录。

十四、继续扩展这个 demo

这个 demo 可以继续扩展成更像真实项目的结构:

  1. 加入文件保存:把任务列表保存为 JSON。
  2. 加入数据库:用 SQLite 保存任务。
  3. 加入搜索框:View 调用 ViewModel 的过滤方法。
  4. 加入任务优先级:Model 增加 priority 字段。
  5. 加入多窗口:多个 View 共用同一个 ViewModel 或共享 Service。
  6. 加入单元测试:直接测试 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 的核心价值是分层:

在 PyQt 中,可以使用:

来搭建一套简单但清晰的 MVVM 架构。

对于 Python 开发者来说,刚开始不需要追求完美架构。建议先记住一句话:

让窗口类少写业务逻辑,让 ViewModel 管状态和命令。

只要做到这一点,你的 PyQt 项目就会比“所有代码都写在 MainWindow 里”更容易维护。

以上就是基于Python+PyQt+MVVM方式开发桌面应用的详细内容,更多关于Python PyQt MVVM桌面应用的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文