背景

有的时候为了快速验证原型设计,需要将部分新功能或页面作为 standalone 运行;而验证之后有希望能够无缝的集成进入系统,为此应该设计一套丝滑的架构同时满足两边的需求。

1. 构建思路

  1. 根目录下的 build.py 作为最外层,其核心是以名为 app.py 的入口作为打包的起点。
  2. app.py 是实际执行的入口文件,如果不需要打包,则再终端启动 app.py 就可以打开应用。
  3. app.py 引用的是提供了 MainWindow 组件功能的 py 文件,其核心在于 window = MainWindow() 这个提供了 MainWindow 功能的组件是后面将项目嵌入并融合的核心,所以这个文件的核心是:self.central_widget = CentralWidget(self)self.setCentralWidget(self.central_widget). 需要注意的是,提供 MainWindow 组件功能的类不一定叫 MainWindow 可以用如下的方式改名:from ui.DataPreProcess import DataPreProcess as MainWindow
  4. CentralWidget 这个提供组件的类就是做业务和视图分离的重点了,通过 CentralWidget(self) 注入到 MainWindowself 上去。
  5. 提供 CentralWidget 组件类的文件一般放置在 widgets 目录下,例如叫做 central_widget.py, 它继承了 QWidget.

2. 具体内容

2.1 打包入口文件 build.py 内容

# build.py
import PyInstaller.__main__
import os
import sys

# 添加项目路径到 sys.path,确保能找到本地模块
project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path:
    sys.path.insert(0, project_dir)

args = [
    'app.py',
    '--name=EmbodyLabeling',
    '--onefile',
    '--windowed',
    # '--icon=icon.ico',
    # 添加资源文件(Windows 用分号,Linux/Mac 用冒号)
    '--add-data=config.yaml;.',  # Windows 用分号,将 config.yaml 添加到根目录
    # '--add-data=images;images',  # Windows 用分号
    # '--add-data=style.qss;.',    # macOS/Linux 用冒号
    # PyQt5 相关
    '--hidden-import=PyQt5.QtCore',
    '--hidden-import=PyQt5.QtGui',
    '--hidden-import=PyQt5.QtWidgets',
    '--collect-all=PyQt5',  # 收集所有 PyQt5 子模块
    # 项目模块
    '--hidden-import=yaml',
    '--hidden-import=pickle',
    '--hidden-import=utils.load_config',
    '--hidden-import=utils.is_server',
    '--hidden-import=main',
    '--hidden-import=sync',
    '--hidden-import=partial',
    '--hidden-import=resources_rc',  # 如果存在资源文件
    # 排除不必要的模块以减少打包大小和避免循环依赖
    '--exclude-module=matplotlib',
    '--exclude-module=IPython',
    '--exclude-module=jupyter',
    '--exclude-module=notebook',
    '--exclude-module=pytest',
    '--exclude-module=pygame',
    '--exclude-module=tkinter',
    '--exclude-module=unittest',
    '--exclude-module=test',
    '--exclude-module=distutils',
    # 其他选项
    '--noconfirm',  # 不询问确认
    '--clean',
]

try:
    PyInstaller.__main__.run(args)
except Exception as e:
    print(f"构建失败: {e}")
    import traceback
    traceback.print_exc()
    sys.exit(1)

其它内容都是基本固定的,需要改变的是 args 数组的第一个元素的值;目前是 app.py 也就是目前是以这个文件作为打包的入口的。

2.2 实际应用执行入口 app.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PyQt5 桌面应用程序
- 启动 main.py 处理流程
- 编辑 config.yaml 配置
- 管理 pkl 文件(查看、删除、增加)
"""

import sys
from PyQt5.QtWidgets import (
    QApplication,
)

from PyQt5.QtGui import QIcon

from ui.DataPreProcess import DataPreProcess as MainWindow

def main():
    app = QApplication(sys.argv)
    
    # 使用 Qt 资源系统设置图标
    # 格式:':/前缀/文件名',这里前缀为空
    app.setWindowIcon(QIcon(':/app.ico'))
    window = MainWindow()
    window.setWindowIcon(QIcon(':/app.ico'))
    
    window.show()
    window.showMaximized()  # 自动最大化窗口
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

可以看出来这个文件中的内容也是相当固定的,可以什么都不改变,然后只需要关注 from ui.DataPreProcess import DataPreProcess as MainWindow 这一行即可。

2.3 standalone 的 MainWindow

之前已经说了,提供 MainWindow 功能的 python 文件不一定需要命名类名也为 MainWindow, 在测试项目中将其放在 ui 子目录下,命名为 DataPreProcess.py 其内容如下所示:

# main_window.py
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.QtGui import QIcon
from widgets.central_widget import CentralWidget
from utils.process_thread import MainProcessThread


class DataPreProcess(QMainWindow):
    """主窗口"""
    
    def __init__(self):
        super().__init__()
        self.process_thread = None
        self.init_ui()
    
    def init_ui(self):
        self.setWindowTitle("数据处理应用程序")
        self.setGeometry(100, 100, 1000, 700)
        
        # 设置图标(如果存在)
        try:
            self.setWindowIcon(QIcon(":/icons/app_icon.png"))
        except:
            pass
        
        # 创建中央部件
        self.central_widget = CentralWidget(self)
        self.setCentralWidget(self.central_widget)
    
    def start_process(self):
        """启动处理流程"""
        if self.process_thread and self.process_thread.isRunning():
            return
        
        self.process_thread = MainProcessThread()
        self.process_thread.output_signal.connect(self.central_widget.append_log)
        self.process_thread.finished_signal.connect(self.on_process_finished)
        self.process_thread.start()
        
        self.central_widget.set_start_button_enabled(False)
        self.central_widget.set_stop_button_enabled(True)
        self.central_widget.set_status_text("状态: 运行中")
        self.central_widget.clear_log()
    
    def stop_process(self):
        """停止处理流程"""
        if self.process_thread and self.process_thread.isRunning():
            self.process_thread.stop()
            self.central_widget.append_log("正在停止处理流程...")
    
    def on_process_finished(self):
        """处理流程结束"""
        self.central_widget.set_start_button_enabled(True)
        self.central_widget.set_stop_button_enabled(False)
        self.central_widget.set_status_text("状态: 已停止")
        self.central_widget.append_log("处理流程已停止")

这个文件就没有上一个 app.py 那么固定了(因为在 app.py 中最简单那情况下只需要修改 import as 即可),其中可变的地方包括:

  • 引入核心组件和支持的依赖
from widgets.central_widget import CentralWidget
from utils.process_thread import MainProcessThread
  • 成员属性和成员函数
    # 在 init 方法中定义成员属性
    def __init__(self):
        ...
        self.process_thread = None
        ...
    # 定义除了构建界面的其它支持方法;这些方法可能会在注入的时候被用到
    # 也就是可能会被在 self.central_widget = CentralWidget(self) 这句中用到
    def start_process(self):
        """启动处理流程"""
        if self.process_thread and self.process_thread.isRunning():
            return
        
        self.process_thread = MainProcessThread()
        self.process_thread.output_signal.connect(self.central_widget.append_log)
        self.process_thread.finished_signal.connect(self.on_process_finished)
        self.process_thread.start()
        
        self.central_widget.set_start_button_enabled(False)
        self.central_widget.set_stop_button_enabled(True)
        self.central_widget.set_status_text("状态: 运行中")
        self.central_widget.clear_log()
    
    def stop_process(self):
        """停止处理流程"""
        if self.process_thread and self.process_thread.isRunning():
            self.process_thread.stop()
            self.central_widget.append_log("正在停止处理流程...")
    
    def on_process_finished(self):
  • 在本项目中使用如下方式将业务代码和 UI 组件联系起来;在嵌入式的项目中可以进行仿照
self.central_widget = CentralWidget(self)

2.4 核心组件 CentralWidget

CentralWidget 是通过注入的方式利用 self 上挂在的方法或者属性完成界面业务的构造的,其完整代码如下所示:

# widgets/central_widget.py
from PyQt5.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, 
    QTextEdit, QTabWidget
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from widgets.config_editor_tab import ConfigEditorTab
from widgets.pkl_editor_tab import PKLEditorTab


class CentralWidget(QWidget):
    """主控制面板widget"""
    
    def __init__(self, parent=None):
        super().__init__(parent)
        self.parent_window = parent
        self.init_ui()
    
    def init_ui(self):
        layout = QVBoxLayout()
        
        # 标题
        title = QLabel("数据处理控制面板")
        title.setFont(QFont("Arial", 16, QFont.Bold))
        title.setAlignment(Qt.AlignCenter)
        layout.addWidget(title)
        
        # 启动/停止按钮
        btn_layout = QHBoxLayout()
        self.start_btn = QPushButton("启动处理流程")
        self.start_btn.setStyleSheet("background-color: #4CAF50; color: white; padding: 10px; font-size: 12px;")
        self.start_btn.clicked.connect(self.start_process)
        self.stop_btn = QPushButton("停止处理流程")
        self.stop_btn.setStyleSheet("background-color: #f44336; color: white; padding: 10px; font-size: 12px;")
        self.stop_btn.clicked.connect(self.stop_process)
        self.stop_btn.setEnabled(False)
        btn_layout.addWidget(self.start_btn)
        btn_layout.addWidget(self.stop_btn)
        btn_layout.addStretch()
        layout.addLayout(btn_layout)
        
        # 状态标签
        self.status_label = QLabel("状态: 未启动")
        self.status_label.setStyleSheet("padding: 5px; background: #f5f5f5; border: 1px solid #ccc;")
        layout.addWidget(self.status_label)
        
        # 输出日志
        log_label = QLabel("处理日志:")
        log_label.setFont(QFont("Arial", 10, QFont.Bold))
        layout.addWidget(log_label)
        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        self.log_text.setFont(QFont("Courier", 9))
        layout.addWidget(self.log_text)
        
        # 标签页
        self.tabs = QTabWidget()
        
        # 配置编辑器标签页
        self.config_tab = ConfigEditorTab()
        self.tabs.addTab(self.config_tab, "配置编辑器")
        
        # PKL 文件编辑器标签页
        self.pkl_tab = PKLEditorTab()
        self.tabs.addTab(self.pkl_tab, "PKL 文件编辑器")
        
        layout.addWidget(self.tabs)
        
        self.setLayout(layout)
    
    def start_process(self):
        """启动处理流程"""
        if self.parent_window:
            self.parent_window.start_process()
    
    def stop_process(self):
        """停止处理流程"""
        if self.parent_window:
            self.parent_window.stop_process()
    
    def set_start_button_enabled(self, enabled):
        """设置启动按钮状态"""
        self.start_btn.setEnabled(enabled)
    
    def set_stop_button_enabled(self, enabled):
        """设置停止按钮状态"""
        self.stop_btn.setEnabled(enabled)
    
    def set_status_text(self, text):
        """设置状态文本"""
        self.status_label.setText(text)
    
    def append_log(self, text):
        """追加日志"""
        self.log_text.append(text)
        # 自动滚动到底部
        scrollbar = self.log_text.verticalScrollBar()
        scrollbar.setValue(scrollbar.maximum())
    
    def clear_log(self):
        """清空日志"""
        self.log_text.clear()

这里 CentralWidget 将从外面接受到的,也就是代表 MainWindow 实例的 self 保存到了一个新的属性中,如 self.parent_window = parent 所示; 正因为如此,才能通过如下代码完成对按钮的绑定事件等:

        self.start_btn.clicked.connect(self.start_process)
        self.stop_btn.clicked.connect(self.stop_process)

        def start_process(self):
            """启动处理流程"""
            if self.parent_window:
                self.parent_window.start_process()
        
        def stop_process(self):
            """停止处理流程"""
            if self.parent_window:
                self.parent_window.stop_process()

3. 抽离出一个模板来

3.1 项目目录如下所示

C:\USERS\DAQI\DOWNLOADS\DATA_POST_PROCESS
|   app.py
|   build.py
|   project_structure.txt
|   
+---ui
|   +---widgets
|   |       CenterWidget.py
|   |       
|   \---windows
|           MainWindow.py
|           
\---utils

上面目录树的生成可以使用 powershell 下面的命令:tree /F /A ./ > 1.txt

3.2 build.py 最简内容

# build.py
import PyInstaller.__main__
import os
import sys

# 添加项目路径到 sys.path,确保能找到本地模块
project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path:
    sys.path.insert(0, project_dir)

args = [
    'app.py',
    '--name=EmbodyLabeling',
    '--onefile',
    '--windowed',
    # '--icon=icon.ico',
    # 添加资源文件(Windows 用分号,Linux/Mac 用冒号)
    # '--add-data=config.yaml;.',  # Windows 用分号,将 config.yaml 添加到根目录
    # '--add-data=images;images',  # Windows 用分号
    # '--add-data=style.qss;.',    # macOS/Linux 用冒号
    # PyQt5 相关
    '--hidden-import=PyQt5.QtCore',
    '--hidden-import=PyQt5.QtGui',
    '--hidden-import=PyQt5.QtWidgets',
    '--collect-all=PyQt5',  # 收集所有 PyQt5 子模块
    # 项目模块
    '--hidden-import=yaml',
    '--hidden-import=pickle',
    '--hidden-import=utils.load_config',
    '--hidden-import=utils.is_server',
    '--hidden-import=main',
    '--hidden-import=sync',
    '--hidden-import=partial',
    '--hidden-import=resources_rc',  # 如果存在资源文件
    # 排除不必要的模块以减少打包大小和避免循环依赖
    '--exclude-module=matplotlib',
    '--exclude-module=IPython',
    '--exclude-module=jupyter',
    '--exclude-module=notebook',
    '--exclude-module=pytest',
    '--exclude-module=pygame',
    '--exclude-module=tkinter',
    '--exclude-module=unittest',
    '--exclude-module=test',
    '--exclude-module=distutils',
    # 其他选项
    '--noconfirm',  # 不询问确认
    '--clean',
]

try:
    PyInstaller.__main__.run(args)
except Exception as e:
    print(f"构建失败: {e}")
    import traceback
    traceback.print_exc()
    sys.exit(1)

这部分内容甚至完全不需要改变任何,入口仍然是 app.py

3.3 app.py 最简内容

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PyQt5 桌面应用程序
- 启动 main.py 处理流程
- 编辑 config.yaml 配置
- 管理 pkl 文件(查看、删除、增加)
"""

import sys
from PyQt5.QtWidgets import (
    QApplication,
)

from PyQt5.QtGui import QIcon

from ui.windows.MainWindow import MainWindow

def main():
    app = QApplication(sys.argv)
    
    # 使用 Qt 资源系统设置图标
    # 格式:':/前缀/文件名',这里前缀为空
    app.setWindowIcon(QIcon(':/app.ico'))
    window = MainWindow()
    window.setWindowIcon(QIcon(':/app.ico'))
    
    window.show()
    window.showMaximized()  # 自动最大化窗口
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

这个文件中基本也没有改变什么内容,只不过是将 app.ico 复制到了根目录下面,不过这个和 CenterWidget 没有什么关系。

3.4 ui.windows.MainWindow.py 最简内容

用方法 dosomething 代替业务成员方法;用 thread 代替业务成员属性。

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.QtGui import QIcon
from ui.widgets.CentralWidget import CentralWidget

class MainWindow(QMainWindow):
    """主窗口"""
    
    def __init__(self):
        super().__init__()
        self.thread = None
        self.init_ui()
    
    def init_ui(self):
        self.setWindowTitle("standalone embed testing")
        self.setGeometry(100, 100, 1000, 700)
        
        # 设置图标(如果存在)
        try:
            self.setWindowIcon(QIcon(":/icons/app_icon.png"))
        except:
            pass
        
        # 创建中央部件
        self.central_widget = CentralWidget(self)
        self.setCentralWidget(self.central_widget)
    
    def dosomething(self):
        """启动处理流程"""
        print(self.thread)

3.5 ui.widgets.CenterWidget.py 最简内容

这是一个空白页面,居中展示一个按钮,调集之后会调用 MainWindow 实例上的方法进行打印。

# widgets/central_widget.py
from PyQt5.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, 
    QTextEdit, QTabWidget
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont

class CentralWidget(QWidget):
    """主控制面板widget"""
    
    def __init__(self, parent=None):
        super().__init__(parent)
        self.parent_window = parent
        self.init_ui()
    
    def init_ui(self):
        layout = QVBoxLayout()
        
        # 标题
        title = QLabel("核心组件页面")
        title.setFont(QFont("Arial", 16, QFont.Bold))
        title.setAlignment(Qt.AlignCenter)
        layout.addWidget(title)
        
        # 添加弹簧,将内容推到中间
        layout.addStretch()
        
        # 按钮容器widget,用于居中
        button_container = QWidget()
        button_container_layout = QVBoxLayout()
        button_container_layout.setAlignment(Qt.AlignCenter)
        
        # 水平布局用于按钮左右居中
        btn_layout = QHBoxLayout()
        btn_layout.addStretch()  # 左侧弹簧
        
        self.start_btn = QPushButton("点我打印")
        self.start_btn.setStyleSheet("background-color: #4CAF50; color: white; padding: 10px; font-size: 12px;")
        self.start_btn.clicked.connect(self.print)
        
        btn_layout.addWidget(self.start_btn)
        btn_layout.addStretch()  # 右侧弹簧
        
        # 将水平布局添加到垂直布局中
        button_container_layout.addStretch()  # 顶部弹簧
        button_container_layout.addLayout(btn_layout)
        button_container_layout.addStretch()  # 底部弹簧
        
        button_container.setLayout(button_container_layout)
        layout.addWidget(button_container)
        
        # 底部弹簧
        layout.addStretch()
        
        # 生成 layout
        self.setLayout(layout)
    
    def print(self):
        """调用容器页面函数"""
        if self.parent_window:
            self.parent_window.dosomething()

如此一来一个简单的可嵌入的 standalone 项目就做好了;既可以作为单独的项目使用;也可以将 ui.widgets.CenterWidget.py 单独摘出去嵌入到其它页面中进行使用,非常的方便。

4. 嵌入到集成项目中去

4.1 集成项目的集合方式 – 使用多页面组件

如上面所示的,ui.widgets.CenterWidget.py 文件中的名为 print 的成员方法那样, print 实际上是对 parent 中的 dosomething 方法的引用,这一点尤为重要,因为通过下面的代码就可以清楚的看到这种引用实际上是将自有的方法外部的方法分开的一种方式。我觉得好的实践应该在此基础之上用方法的前缀以示区分。

  • 在集合项目的 MainWindow 中,在 init 方法中就实例化核心页面组件:
    def __init__(self, collection_flag, exit_flag, show_shared_array):
        super().__init__()
        self.preprocessing_page = CentralWidget(self)
        self.thread = None
        ...
  • 在 init_ui 中创建集合项目自己的中心组件
        # 创建中央部件(用于界面切换)
        self.central_widget = QWidget()
        self.setCentralWidget(self.central_widget)
        self.central_layout = QVBoxLayout(self.central_widget)
        self.central_layout.setContentsMargins(0, 0, 0, 0)
        self.central_layout.setSpacing(0)
  • 在 create_mode_pages 方法中,将各个页面集成到 self.central_layout 中去然后立刻隐藏
self.central_layout.addWidget(self.preprocessing_page)
self.preprocessing_page.hide()
  • 创建搭配使用的 switch_to_mode 方法,依据 name 来切换页面,其内容如下:
    def switch_to_mode(self, mode_name):
        """切换到指定模式界面
        
        Args:
            mode_name: 模式名称('数据采集'、'预处理'、'标注'、'后处理')
        """
        # 隐藏所有界面
        self.annotation_page.hide()
        self.data_collection_page.hide()
        self.preprocessing_page.hide()
        self.postprocessing_page.hide()
        
        # 显示对应的界面
        if mode_name == '数据采集':
            self.data_collection_page.show()
            self.current_mode = '数据采集'
        elif mode_name == '预处理':
            self.preprocessing_page.show()
            self.current_mode = '预处理'
        elif mode_name == '标注':
            self.annotation_page.show()
            self.current_mode = '标注'
        elif mode_name == '后处理':
            self.postprocessing_page.show()
            self.current_mode = '后处理'
        
        # 更新窗口标题
        self.setWindowTitle(f'视频标注软件 - {mode_name}')
  • 当集合应用的 MainWindow 中需要使用嵌入组件上的方法的时候:
    def start_process(self):
        """启动处理流程"""
        if self.process_thread and self.process_thread.isRunning():
            return
        
        self.process_thread = MainProcessThread()
        self.process_thread.output_signal.connect(self.preprocessing_page.append_log)
        ...
  • 设置快捷键是十分方便的,只需要重写父类 QMainWindow 的 keyPressEvent 方法就可以了,不要忘记在最后使用 super 调用基类同名方法
    def keyPressEvent(self, event):
        """键盘快捷键"""
        if event.modifiers() & Qt.ControlModifier:
            if event.key() == Qt.Key_1:
                self.controller.on_start_collection()
            elif event.key() == Qt.Key_2:
                self.controller.on_stop_collection()
            elif event.key() == Qt.Key_3:
                self.controller.on_fail_collection()
        super().keyPressEvent(event)

4.2 使用绝对路径修改引用路径

先在集成应用中创建一个名为 data_post_process 的目录,然后将 standalone 中的所有代码都粘贴过去。

作为 standalone 而言,data_post_process 应用中的引入方式如下所示:

from ui.windows.MainWindow import MainWindow
...
from ui.widgets.CenterWidget import CentralWidget

变成了:

from data_post_process.ui.windows.MainWindow import MainWindow
...
from data_post_process.ui.widgets.CenterWidget import CentralWidget

4.3 只使用项目中的 CenterWidget.py 并指定其别名

如下面碎片化代码所示(这是在集成应用的 MainWindow 类中):

self.postprocessing_page = PostCentralWidget(self)
...
self.central_layout.addWidget(self.postprocessing_page)
self.postprocessing_page.hide()

然后在 switch_to_mode 中根据当前的 page_name 切换隐藏或者显示的状态就可以了。

合并 MainWindow 的时候,需要将 standalone 应用下 MainWindow 的方法也一并移植过来。这里有一个好的实践方式:
如果确定现在就是要做一个既能 standalone 有可以集成的页面,那么最好的方式就是在 standalone 的 MainWindow 的方法或者属性上打上 prefix 这样的话,在合并的时候就不会发生重名的事故了。

如此一来,就实现了在同一个目录中既可以单独运行,又可以被直接作为完整组件引用的功能。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐