admin管理员组

文章数量:1437225

PyQt 多标签批量图片查看器开发纪实

前言

一直以来,我都想做一个轻量的图片查看工具,不要花哨,不要臃肿,最好还能支持多标签浏览,就像浏览器一样,一边看一边切换不同文件夹的图片。而且,我希望界面能漂亮一点,使用也足够流畅,于是便有了这次基于 PyQt 的开发尝试。

这篇文章将带你完整走一遍开发过程,包括构思、架构设计、编码、调试、完善,期间遇到的坑也会细细道来。

希望这次分享,能帮到同样想做 PyQt 项目 或者对多标签设计感兴趣的朋友。


一、起点:确定需求

我拿起笔(其实是打开了 Typora),列了几条最基本的需求:

  • 支持选择文件夹,批量读取其中的图片
  • 每个文件夹,开一个独立标签页,互不干扰
  • 支持图片缩放、平移(鼠标操作)
  • 图片浏览支持前后切换
  • 界面要尽量清爽简洁

思考片刻之后,我又加了些想要实现的细节:

  • 界面左侧有文件树,方便快速切换目录
  • 支持拖拽打开文件夹
  • 未来如果有精力,再加点 快捷键操作,比如翻页、旋转之类的

到这一步,一个大致的产品轮廓已经清晰了。接下来,就是决定怎么实现。


二、选型:为什么用 PyQt?

我有不少选项,比如 Tkinter(太丑了)、PySide(跟 PyQt 类似)、甚至 Electron(性能太差)。

权衡一圈之后,还是决定用老朋友 PyQt5

原因很简单:

  • 成熟稳定,文档资料多
  • 自带丰富的控件(QTabWidget、QGraphicsView 都很好用)
  • 可以自定义界面样式,做出不那么“老气”的界面
  • 最重要的,我用得比较顺手

有了技术选型,接下来就可以进入正式的设计阶段了。


三、架构设计

为了防止后面代码越来越乱,我决定一开始就画个简单的模块流程图

在这里插入图片描述

这张图概括了用户操作和程序内部模块的基本交互。

总结一下主要模块:

  • MainWindow:主窗口,负责管理整个应用
  • FolderTreeView:左侧目录树
  • TabWidget:中间的标签页管理
  • ImageViewer:单个标签页内的图片浏览器

设计上,我打算让每一个标签页包含一个自己的 ImageViewer,互不影响。


四、准备开发环境

很简单,确保本地环境装好:

代码语言:bash复制
pip install PyQt5
pip install PyQt5-tools

然后,我在项目根目录新建了以下结构:

代码语言:python代码运行次数:0运行复制
image_viewer/
    ├── main.py
    ├── main_window.py
    ├── folder_tree.py
    ├── tab_manager.py
    ├── image_viewer.py
    ├── utils.py

模块化组织,后期维护也会更轻松。


五、动手开干:从 MainWindow 开始

主窗口是整个程序的“大脑”,所以我先搭了一个最简单的骨架。

main_window.py 中写下初版:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QMainWindow, QSplitter, QWidget, QVBoxLayout
from folder_tree import FolderTreeView
from tab_manager import TabManager

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("多标签图片查看器")
        self.resize(1200, 800)
        self.init_ui()

    def init_ui(self):
        splitter = QSplitter()

        # 左侧文件夹树
        self.folder_tree = FolderTreeView()
        splitter.addWidget(self.folder_tree)

        # 右侧标签管理
        self.tab_manager = TabManager()
        splitter.addWidget(self.tab_manager)

        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 4)

        container = QWidget()
        layout = QVBoxLayout(container)
        layout.addWidget(splitter)
        self.setCentralWidget(container)

解释一下思路

  • QSplitter 分隔左右两块
  • FolderTreeView 是自定义控件,负责目录浏览
  • TabManager 管理中间的多标签页
  • 最外层用一个 QVBoxLayout 包起来(方便以后扩展)

有了这个骨架,运行起来,至少能看到一个分栏的界面了。


六、目录树 FolderTreeView

接着,我来写左边的文件夹树。

文件夹浏览,PyQt 里有现成的控件:QTreeView + QFileSystemModel

所以 folder_tree.py 里是这样:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QTreeView
from PyQt5.QtGui import QIcon
from PyQt5.QtCore import QFileSystemModel, pyqtSignal

class FolderTreeView(QTreeView):
    folder_selected = pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.model = QFileSystemModel()
        self.model.setRootPath("")
        self.model.setFilter(self.model.filter() | self.model.Dirs)
        self.setModel(self.model)
        self.setHeaderHidden(True)

        self.doubleClicked.connect(self.on_double_click)

    def on_double_click(self, index):
        path = self.model.filePath(index)
        self.folder_selected.emit(path)

这里我做了个小设计:

  • 双击文件夹,就发射一个信号,告诉外部:"用户选了这个目录!"
  • pyqtSignal(str) 定义自定义信号,方便外部绑定槽函数

这样,MainWindow 只需要监听这个 folder_selected 信号,就知道用户想打开哪个文件夹了。


七、标签页管理 TabManager

中间的多标签怎么做?

PyQt 自带 QTabWidget,直接继承就行。

tab_manager.py

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QTabWidget
from image_viewer import ImageViewer

class TabManager(QTabWidget):
    def __init__(self):
        super().__init__()
        self.setTabsClosable(True)
        self.tabCloseRequested.connect(self.close_tab)

    def open_folder(self, folder_path):
        viewer = ImageViewer(folder_path)
        index = self.addTab(viewer, folder_path.split("/")[-1])
        self.setCurrentIndex(index)

    def close_tab(self, index):
        widget = self.widget(index)
        if widget:
            widget.deleteLater()
        self.removeTab(index)

这里有两个重要函数:

  • open_folder(folder_path):打开一个新文件夹,创建一个新的 ImageViewer
  • close_tab(index):关闭并销毁对应的 tab

简单又清晰。


八、核心组件:ImageViewer

最关键的地方来了——图片浏览器

一开始我以为可以用 QLabel + QPixmap,结果发现想支持缩放和平移太麻烦了。

于是,我改用更灵活的 QGraphicsView + QGraphicsScene

image_viewer.py 里,我这样搭建:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
from PyQt5.QtGui import QPixmap
from PyQt5.QtCore import Qt
import os

class ImageViewer(QGraphicsView):
    def __init__(self, folder_path):
        super().__init__()
        self.folder_path = folder_path
        self.image_list = self.load_images(folder_path)
        self.current_index = 0

        self.scene = QGraphicsScene()
        self.setScene(self.scene)

        self.setDragMode(QGraphicsView.ScrollHandDrag)
        self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)

        self.load_image()

    def load_images(self, folder_path):
        exts = ['.png', '.jpg', '.jpeg', '.bmp', '.gif']
        return [os.path.join(folder_path, f) for f in os.listdir(folder_path) if os.path.splitext(f)[-1].lower() in exts]

    def load_image(self):
        self.scene.clear()
        if not self.image_list:
            return
        pixmap = QPixmap(self.image_list[self.current_index])
        item = QGraphicsPixmapItem(pixmap)
        self.scene.addItem(item)
        self.fitInView(item, Qt.KeepAspectRatio)

    def wheelEvent(self, event):
        if event.angleDelta().y() > 0:
            self.scale(1.25, 1.25)
        else:
            self.scale(0.8, 0.8)

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Right:
            self.next_image()
        elif event.key() == Qt.Key_Left:
            self.prev_image()

    def next_image(self):
        if self.image_list:
            self.current_index = (self.current_index + 1) % len(self.image_list)
            self.load_image()

    def prev_image(self):
        if self.image_list:
            self.current_index = (self.current_index - 1) % len(self.image_list)
            self.load_image()

功能一应俱全

  • 滚轮缩放(放大/缩小)
  • 鼠标拖拽移动
  • 左右方向键切换图片

特别要注意的是:

fitInView 方法可以让图片自适应窗口大小,初次加载很自然。

到这,基本的浏览功能已经成型了!


九、美化与主题定制

开发到这里,功能虽然完整,但 UI 看起来还是挺“原生”的 PyQt 风格:灰灰的、按键样式也不是特别协调。作为一个“追求颜值”的程序员,当然要给它来点主题皮肤。说干就干。

1. Qt StyleSheet (QSS) 入门

Qt 的皮肤其实就是一段类 CSS 语法,叫做 QSS。它几乎可以作用到所有 QWidget 派生的控件上。

我先在项目根目录里新增一个 style.qss,写上一点简单样式:

代码语言:css复制
/* style.qss */
QMainWindow {
    background-color: #f0f0f0;
}

QPushButton {
    background-color: #4CAF50;
    color: white;
    padding: 5px 12px;
    border: none;
    border-radius: 4px;
}

QPushButton:hover {
    background-color: #45a049;
}

QTabWidget::pane {
    border: 1px solid #aaa;
    background: white;
    border-radius: 4px;
}

QTabBar::tab {
    background: #ddd;
    padding: 6px;
    margin-right: 2px;
    border-top-left-radius: 4px;
    border-top-right-radius: 4px;
}

QTabBar::tab:selected {
    background: white;
    font-weight: bold;
}

接着,在 main.py 中加载这段样式:

代码语言:python代码运行次数:0运行复制
import sys
from PyQt5.QtWidgets import QApplication
from main_window import MainWindow

def main():
    app = QApplication(sys.argv)
    # 加载样式表
    with open("style.qss", "r") as f:
        app.setStyleSheet(f.read())

    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()

这样,重启程序后,你就会发现:

  • 主窗口背景变成了浅灰色
  • 按钮统一成了绿色风格
  • 标签页有了圆角、选中时加粗

小结:QSS 为 PyQt 带来了极大的定制自由度,而且上手快。只需一份样式表,就能全面提升界面质感。

2. 自定义图标与资源管理

光靠颜色还不够,我还想给按钮、菜单加上图标。

我一般的做法是把所有静态资源(图标、logo 等)放到 resources/ 文件夹里,然后写一个 resources.qrc 文件,之后用 pyrcc5 编译成 Python 模块。这样在代码里就可以通过 :/icons/open.png 的方式直接引用,完全不用管相对路径。

resources.qrc 样例:

代码语言:xml复制
<!DOCTYPE RCC>
<RCC version="1.0">
    <qresource prefix="/icons">
        <file>open.png</file>
        <file>close.png</file>
        <file>next.png</file>
        <file>prev.png</file>
    </qresource>
</RCC>

命令行:

代码语言:bash复制
pyrcc5 resources.qrc -o resources_rc.py

然后在代码最前面导入:

代码语言:python代码运行次数:0运行复制
import resources_rc  # 让 Qt 知道我们的资源

在创建 QActionQPushButton 时,就可以这样用:

代码语言:python代码运行次数:0运行复制
open_action = QAction(QIcon(":/icons/open.png"), "打开文件夹", self)

图标就会自动嵌入到可执行文件里,用户体验更好。


十、异常处理与容错

在持续迭代过程中,我发现各种“奇葩用法”会导致程序崩溃。比如:

  • 用户选了空文件夹
  • 文件夹里有非图片文件
  • 图片文件损坏,无法加载

为了让程序更“稳”,我给关键逻辑都包了一层异常处理

1. 读取图片列表的健壮化

原先的 load_images 直接遍历 os.listdir,碰到非图片直接跳过,但如果文件名带中文、特殊字符,或者文件夹本身没有访问权限,就会抛异常。于是,我改成:

代码语言:python代码运行次数:0运行复制
import imghdr

def load_images(self, folder_path):
    imgs = []
    for filename in os.listdir(folder_path):
        fullpath = os.path.join(folder_path, filename)
        if not os.path.isfile(fullpath):
            continue
        try:
            # 用 imghdr 确认文件确实是图片
            if imghdr.what(fullpath):
                imgs.append(fullpath)
        except Exception as e:
            print(f"Warning: 跳过无法识别的文件 {fullpath},原因:{e}")
    return sorted(imgs)

这样一来:

  • 非文件项(如子文件夹)被自动过滤
  • imghdr 可以判断常见的图片格式
  • 对于权限问题或文件损坏,catch 后打印 Warning,不影响整体流程

2. 加载单张图片时的容错

图片可能损坏,或某些特殊格式无法用 QPixmap 打开,我在 load_image 加了一层:

代码语言:python代码运行次数:0运行复制
def load_image(self):
    self.scene.clear()
    if not self.image_list:
        self._show_placeholder("该文件夹中无图片")
        return

    path = self.image_list[self.current_index]
    try:
        pixmap = QPixmap(path)
        if pixmap.isNull():
            raise ValueError("QPixmap 无法加载图片")
        item = QGraphicsPixmapItem(pixmap)
        self.scene.addItem(item)
        self.fitInView(item, Qt.KeepAspectRatio)
    except Exception as e:
        print(f"Error: 加载图片失败 {path},原因:{e}")
        self._show_placeholder("无法显示此图片")

并实现 _show_placeholder,在场景中央绘制一行文本,提示用户:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QGraphicsTextItem
from PyQt5.QtGui import QFont

def _show_placeholder(self, text):
    self.scene.clear()
    placeholder = QGraphicsTextItem(text)
    placeholder.setDefaultTextColor(Qt.gray)
    placeholder.setFont(QFont("Arial", 16))
    self.scene.addItem(placeholder)
    # 居中
    rect = self.scene.sceneRect()
    placeholder.setPos(rect.width()/2 - placeholder.boundingRect().width()/2,
                       rect.height()/2 - placeholder.boundingRect().height()/2)

用户体验显著提升:就算图片加载失败,也不会看到空白或崩溃,而是优雅的提示。


十一、拖拽打开文件夹

用户往往习惯“拖拖拖”,所以我想让主窗口支持把文件夹拖进来直接打开。

拖拽在 Qt 中其实分两步:

  1. 接受拖拽dragEnterEvent
  2. 处理放下dropEvent

我给 MainWindow 加上这两个方法:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtCore import Qt, QUrl

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        # 允许拖放
        self.setAcceptDrops(True)
        ...

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            # 只要是文件夹就给过
            urls = event.mimeData().urls()
            if any(url.isLocalFile() for url in urls):
                event.acceptProposedAction()

    def dropEvent(self, event):
        for url in event.mimeData().urls():
            path = url.toLocalFile()
            if os.path.isdir(path):
                # 传给 TabManager
                self.tab_manager.open_folder(path)
        event.acceptProposedAction()

拖一堆文件夹进来,就会批量打开,每个文件夹一个标签,酷毙了。


十二、快捷键与右键菜单

到这一步,虽然能用鼠标完成绝大多数操作,但对于“键盘党”来说,还真有些不爽。于是我决定再加点快捷键和右键菜单。

1. 全局快捷键

我在 MainWindow 初始化时,绑定了几个常用动作的快捷键:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QShortcut
from PyQt5.QtGui import QKeySequence

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        ...
        # Ctrl+O 打开文件夹
        open_sc = QShortcut(QKeySequence("Ctrl+O"), self)
        open_sc.activated.connect(self.open_folder_dialog)
        # Ctrl+W 关闭当前标签
        close_sc = QShortcut(QKeySequence("Ctrl+W"), self)
        close_sc.activated.connect(lambda: self.tab_manager.close_tab(self.tab_manager.currentIndex()))
        # Ctrl+Q 退出
        quit_sc = QShortcut(QKeySequence("Ctrl+Q"), self)
        quit_sc.activated.connect(self.close)

open_folder_dialog 则弹出一个 QFileDialog.getExistingDirectory 来选择目录。

2. 右键菜单

ImageViewer 里,重写 contextMenuEvent,弹出一个简单菜单,让用户可以旋转、复制路径、在外部打开等:

代码语言:python代码运行次数:0运行复制
from PyQt5.QtWidgets import QMenu
import subprocess
import platform

class ImageViewer(QGraphicsView):
    ...

    def contextMenuEvent(self, event):
        menu = QMenu(self)
        rotate_action = menu.addAction("旋转 90°")
        copy_path_action = menu.addAction("复制路径")
        open_external_action = menu.addAction("外部打开")

        action = menu.exec_(self.mapToGlobal(event.pos()))
        if action == rotate_action:
            self.rotate_image()
        elif action == copy_path_action:
            QApplication.clipboard().setText(self.image_list[self.current_index])
        elif action == open_external_action:
            self.open_in_explorer()

    def rotate_image(self, angle=90):
        # 简单起见,直接对 pixmap 变换,再加载
        path = self.image_list[self.current_index]
        pixmap = QPixmap(path)
        transform = QTransform().rotate(angle)
        pixmap = pixmap.transformed(transform)
        pixmap.save(path)  # 覆盖原图
        self.load_image()

    def open_in_explorer(self):
        path = self.image_list[self.current_index]
        if platform.system() == "Windows":
            subprocess.Popen(f'explorer /select,"{path}"')
        elif platform.system() == "Darwin":
            subprocess.Popen(["open", "-R", path])
        else:
            subprocess.Popen(["xdg-open", os.path.dirname(path)])

Context Menu 流程图

在这里插入图片描述

这样,鼠标右键一顿操作,用户可以轻松完成更多功能。


十三、打包与发布

功能完善后,最后一步就是给用户一个一键启动的可执行文件。Python 脚本直接发给用户,还得装 Python、装依赖,太麻烦。

我选了 PyInstaller 做打包:

代码语言:bash复制
pip install pyinstaller
pyinstaller --noconfirm --clean --windowed \
    --name MultiImageViewer \
    --add-data "resources/;resources/" \
    --add-data "style.qss;." \
    main.py
  • --windowed:不弹命令行窗口
  • --add-data:把我们的资源文件夹和样式表打进去
  • 打包后,dist/MultiImageViewer/ 下就有.exe 或可执行程序

用户只要下载这个目录,双击就能运行。跨平台时,Mac、Linux 也只要在相应平台打包一次即可。


十四、性能分析与后续优化

  1. 缓存缩略图:undefined对于大量图片的文件夹,每次打开都扫描、加载会有点慢。我打算在 utils.py 里加一个缩略图缓存模块,首次打开时生成 .thumbs/ 缓存,下次直接读。
  2. 多线程加载:undefined当前所有加载都在主线程,若图片超大会卡死界面。可以借助 QThreadPool + QRunnable,把扫描和第一张缩略图加载放到后台。完成后用信号通知主 UI 更新。
  3. 多选操作:undefined未来版本,可以在一个标签里支持在缩略图模式下多选,批量复制、删除、批量重命名等。
  4. 书签与幻灯片模式:undefined给每个文件夹增加“收藏夹”功能,还能一键进入全屏幻灯片。

以上优化,都是下一步可以继续挖坑、填坑的工作。也欢迎大家提意见,一起完善。


十五、总结与感想

回头看看,从最初的 需求收集模块拆分核心功能实现美化打包,整个项目落地花了我整整一个周末。过程中遇到的挑战主要有:

  • QGraphicsView 的使用细节:要把图片居中、缩放体验要好,需要熟悉 fitInViewtransformationAnchor 等 API。
  • 拖拽事件:第一次写 dragEnterEventdropEvent 时,忘记在窗体上 setAcceptDrops(True),导致根本没触发,调试半天才发现。
  • 跨平台小差异:Windows Explorer、macOS Finder、Linux 文件管理器的命令各不相同,一开始只写了 Windows,再补充到 Unix,才算完整。

但最有成就感的是:当我把最终程序交给同事时,他竟然说“这比某些商业软件还好用”。这句话,让我深刻体会到——用心雕琢,哪怕只是一个“个人小工具”,也能给别人带来惊喜

希望这篇开发纪实,不仅分享了核心思路和代码细节,也能让你看到一个真实项目从无到有的全过程。如果你也想做类似工具,或者想给我提建议,欢迎在评论区留言交流。最后,祝你编码顺利,早日产出属于自己的炫酷小工具!


<small>作者:繁依Fanyi | 时间:2025-04-28</small>

本文标签: PyQt 多标签批量图片查看器开发纪实