admin管理员组

文章数量:1437110

PyQt5 番茄钟实现

前言

最近,一直被效率焦虑困扰:工作时老是分心,时不时打开社交软件,打断了专注,结果一天下来干活像散沙。脑海里忽然闪过一个念头:如果有个桌面定时提醒器,能在指定时间提醒我休息、做番茄钟,甚至弹出一句鼓励的小语,岂不是美滋滋?更何况,我早就想借此练练 PyQt,打造一个属于自己的“效率小助手”。

于是,这篇博客就这样诞生了:我决定利用 PyQt5/6,从零开始实现一款 定时提醒器/番茄钟,包含设置界面、倒计时逻辑、系统通知、声音提醒、系统托盘最小化、持久化配置等功能。文章将讲述我在设计、编码、调试、打包过程中踩过的坑和收获的经验,通过流程图,让你一目了然模块之间的交互。无论你是 PyQt 小白还是想做桌面工具的同学,都能在本文找到灵感。


一、需求与动机

我给自己列了几个“非功能化”需求和“核心”需求:不想写成一堆分点,简单说说当时的想法。每天工作二十五分钟,我需要一个番茄钟,计时结束能提醒我休息五分钟;休息结束时,又需要提醒我继续工作;我还希望能自定义工作时长和休息时长;如果需要其它定时提醒(比如下午三点喝茶),最好也能在同一界面新增;程序最小化到系统托盘后还能继续运行,不占任务栏;最后,提醒时要有声音和桌面通知,保证视听双重反馈。想到这些,我就迫不及待想动手了。


二、为什么用 PyQt

说到桌面应用开发,Python 除了 PyQt,还有 wxPython、Tkinter,甚至 Electron。但是 Electron 打包庞大,网络性能不稳定;Tkinter 界面简陋;wxPython 学习成本也不低。相比之下,PyQt 具备以下优势:

  • 信号槽机制清晰,事件驱动极易管理定时器、按钮回调;
  • Qt 自带丰富的控件,布局管理直观;
  • QTimer 定时器类让时间相关逻辑更简洁;
  • 可以通过 QSystemTrayIcon 实现系统托盘;
  • 丰富的样式表(QSS)支持美化 UI;
  • PyInstaller 打包简单,无需用户预装 Python。

下定决心后,我在本地新建了项目目录 pyqt_timer/,并安装依赖:

代码语言:bash复制
pip install PyQt5      # 或 PyQt6,根据需求
pip install plyer      # 用于跨平台通知(可选)

三、架构与模块设计

着手编码前,我习惯先手绘或画个模块交互图,让思路更清晰。这里用画一下核心流程:

在这里插入图片描述

大致分为以下模块:

  1. MainWindow:主窗口,负责 UI 交互和信号连接;
  2. TimerController:定时器逻辑,封装 QTimer,控制番茄钟和自定义提醒;
  3. ConfigManager:配置管理,加载/保存用户设置(JSON 文件);
  4. NotificationManager:通知管理,桌面消息和声音提醒;
  5. SystemTray:系统托盘图标,支持最小化与恢复;
  6. SoundPlayer:播放本地提示音(可扩展到自定义铃声)。

有了总体规划,接下来就分模块落地。


四、项目目录结构

在决定好模块之后,我在 pyqt_timer/ 目录下创建子文件:

代码语言:python代码运行次数:0运行复制
pyqt_timer/
├── main.py
├── main_window.py
├── timer_controller.py
├── config_manager.py
├── notification_manager.py
├── system_tray.py
├── sound_player.py
├── resources/
│   ├── icons/
│   │   ├── play.png
│   │   ├── pause.png
│   │   ├── reset.png
│   │   ├── tray.png
│   │   └── settings.png
│   ├── style.qss
│   └── alert.wav
└── config.json       # 程序第一次运行后自动生成
  • resources/ 存放图标、样式表和提示音。
  • config.json 存储用户设置。

接着,用 pyrcc5resources/ 打包到 resources_rc.py,方便程序内引用。


五、配置管理 ConfigManager

为了让用户设置持久化,我设计了一个轻量的配置管理类,用 JSON 存储。简单说:启动时读一份配置文件(若不存在,则写入默认配置);更改设置后保存到同一个文件。

代码语言:python代码运行次数:0运行复制
# config_manager.py
import json
import os

class ConfigManager:
    def __init__(self, path="config.json"):
        self.path = path
        self.config = {
            "work_duration": 25 * 60,
            "break_duration": 5 * 60,
            "custom_reminders": []  # [{"time": "15:00", "label": "喝茶"}]
        }
        self.load()

    def load(self):
        if os.path.exists(self.path):
            try:
                with open(self.path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                self.config.update(data)
            except Exception as e:
                print(f"加载配置失败:{e}")

    def save(self):
        try:
            with open(self.path, "w", encoding="utf-8") as f:
                json.dump(self.config, f, ensure_ascii=False, indent=4)
        except Exception as e:
            print(f"保存配置失败:{e}")

    def get(self, key):
        return self.config.get(key)

    def set(self, key, value):
        self.config[key] = value
        self.save()

这里要注意:JSON 文件写入时,需要 ensure_ascii=False 保证中文配置不被转义。


六、定时器逻辑 TimerController

定时器的核心是 QTimer。我将它封装到 TimerController 类,负责番茄钟的“工作/休息”状态切换、自定义多提醒点,以及每秒通知主界面更新剩余时间。

代码语言:python代码运行次数:0运行复制
# timer_controller.py
from PyQt5.QtCore import QObject, QTimer, pyqtSignal, QTime

class TimerController(QObject):
    tick = pyqtSignal(int)                 # 剩余秒数信号
    phase_changed = pyqtSignal(str)        # "work" 或 "break"
    reminder_triggered = pyqtSignal(str)   # 自定义提醒标签

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.timer = QTimer(self)
        self.timer.timeout.connect(self._on_timeout)
        self._init_state()

    def _init_state(self):
        self.phase = "work"
        self.remaining = self.config.get("work_duration")
        self.custom = self._parse_custom()
        self.timer.start(1000)
        self.tick.emit(self.remaining)
        self.phase_changed.emit(self.phase)

    def _parse_custom(self):
        lst = []
        for item in self.config.get("custom_reminders"):
            try:
                t = QTime.fromString(item["time"], "HH:mm")
                sec = t.hour() * 3600 + t.minute() * 60
                lst.append((sec, item["label"]))
            except:
                continue
        return lst

    def _on_timeout(self):
        self.remaining -= 1
        now = QTime.currentTime()
        cur_sec = now.hour() * 3600 + now.minute() * 60 + now.second()
        for sec, label in self.custom:
            if cur_sec == sec:
                self.reminder_triggered.emit(label)
        if self.remaining <= 0:
            if self.phase == "work":
                self.phase = "break"
                self.remaining = self.config.get("break_duration")
            else:
                self.phase = "work"
                self.remaining = self.config.get("work_duration")
            self.phase_changed.emit(self.phase)
        self.tick.emit(self.remaining)

    def start(self):
        if not self.timer.isActive():
            self.timer.start(1000)

    def pause(self):
        if self.timer.isActive():
            self.timer.stop()

    def reset(self):
        self.timer.stop()
        self._init_state()

这个设计带来几个好处:主界面只需要连接三个信号,就能完成时间显示、状态切换和任意时刻的自定义提醒。


七、主界面 MainWindow

UI 部分最耗心思:要同时展示番茄钟倒计时区、自定义提醒列表和设置按钮,还要兼容最小化到托盘。以下是简化版核心逻辑。

代码语言:python代码运行次数:0运行复制
# main_window.py
from PyQt5.QtWidgets import QMainWindow, QLabel, QPushButton, QListWidget, QHBoxLayout, QVBoxLayout, QWidget, QLineEdit, QTimeEdit
from PyQt5.QtGui import QIcon
from PyQt5.QtCore import Qt
from timer_controller import TimerController
from config_manager import ConfigManager
from notification_manager import NotificationManager
from system_tray import SystemTray

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("定时提醒器 / 番茄钟")
        self.config = ConfigManager()
        self.notifier = NotificationManager()
        self.tray = SystemTray(self)
        self._init_ui()
        self._connect_signals()
        self.timer = TimerController(self.config)

    def _init_ui(self):
        self.label_time = QLabel("25:00")
        self.label_time.setAlignment(Qt.AlignCenter)
        self.btn_start = QPushButton(QIcon(":/icons/play.png"), "")
        self.btn_pause = QPushButton(QIcon(":/icons/pause.png"), "")
        self.btn_reset = QPushButton(QIcon(":/icons/reset.png"), "")
        self.list_custom = QListWidget()
        self.time_edit = QTimeEdit(self)
        self.time_edit.setDisplayFormat("HH:mm")
        self.line_label = QLineEdit(self)
        self.btn_add = QPushButton("新增提醒")

        top = QHBoxLayout()
        top.addWidget(self.label_time)
        top.addWidget(self.btn_start)
        top.addWidget(self.btn_pause)
        top.addWidget(self.btn_reset)

        bottom = QHBoxLayout()
        bottom.addWidget(self.time_edit)
        bottom.addWidget(self.line_label)
        bottom.addWidget(self.btn_add)

        main = QVBoxLayout()
        main.addLayout(top)
        main.addWidget(self.list_custom)
        main.addLayout(bottom)

        container = QWidget()
        container.setLayout(main)
        self.setCentralWidget(container)
        self.resize(400, 300)

    def _connect_signals(self):
        self.btn_start.clicked.connect(self.timer.start)
        self.btn_pause.clicked.connect(self.timer.pause)
        self.btn_reset.clicked.connect(self._on_reset)
        self.btn_add.clicked.connect(self._add_custom)
        self.timer.tick.connect(self._update_time)
        self.timer.phase_changed.connect(self._on_phase_change)
        self.timer.reminder_triggered.connect(self._on_custom_trigger)

        self.tray.activated.connect(self._on_tray_activated)

    def _update_time(self, sec):
        m, s = divmod(sec, 60)
        self.label_time.setText(f"{m:02d}:{s:02d}")

    def _on_phase_change(self, phase):
        if phase == "work":
            self.notifier.notify("工作时间到,开始专注!")
        else:
            self.notifier.notify("休息时间到,放松一下!")

    def _add_custom(self):
        t = self.time_edit.time().toString("HH:mm")
        lab = self.line_label.text().strip()
        if not lab:
            return
        lst = self.config.get("custom_reminders")
        lst.append({"time": t, "label": lab})
        self.config.set("custom_reminders", lst)
        self.list_custom.addItem(f"{t} – {lab}")

    def _on_custom_trigger(self, label):
        self.notifier.notify(f"自定义提醒:{label}")

    def _on_reset(self):
        self.timer.reset()

    def closeEvent(self, event):
        event.ignore()
        self.hide()
        self.tray.showMessage("定时提醒器已最小化到托盘", "双击图标可恢复窗口")

要点提示:在 closeEvent 中使用 event.ignore() 而不是 super().closeEvent(event),才能让窗口真正隐藏而非退出程序。


八、通知管理 NotificationManager

如何让提醒更醒目?我同时做了 桌面气泡声音。桌面通知我用 QSystemTrayIcon.showMessage,声音用 QSound(或第三方 playsound/plyer)。示例:

代码语言:python代码运行次数:0运行复制
# notification_manager.py
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon, QSystemTrayIcon
from PyQt5.QtMultimedia import QSound
import resources_rc

class NotificationManager:
    def __init__(self):
        self.tray = QSystemTrayIcon(QIcon(":/icons/tray.png"), QApplication.instance())
        self.tray.show()
        self.sound = QSound(":/alert.wav")

    def notify(self, message):
        self.tray.showMessage("提醒", message, QSystemTrayIcon.Information, 5000)
        self.sound.play()

小细节QSound 播放短音频时延迟很低,几乎无感;如果想兼容 macOS/Linux,可考虑 subprocess 调用 afplayaplay


九、系统托盘 SystemTray

除了主界面最小化,还想右键菜单“退出”、“显示”更灵活。于是封装了 SystemTray

代码语言:python代码运行次数:0运行复制
# system_tray.py
from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon

class SystemTray(QSystemTrayIcon):
    def __init__(self, window):
        super().__init__(QIcon(":/icons/tray.png"), window)
        self.window = window
        menu = QMenu()
        show_act = QAction("显示主窗口", window)
        exit_act = QAction("退出", window)
        menu.addAction(show_act)
        menu.addAction(exit_act)
        self.setContextMenu(menu)

        show_act.triggered.connect(self._show_window)
        exit_act.triggered.connect(window.close)
        self.activated.connect(self._on_activated)

    def _show_window(self):
        self.window.show()

    def _on_activated(self, reason):
        if reason == QSystemTrayIcon.DoubleClick:
            self._show_window()

这样,托盘图标右键能唤出菜单,双击还能快速恢复。


十、界面美化与 QSS

原生窗口有点单调,我在 resources/style.qss 写了样式表:

代码语言:css复制
QMainWindow {
    background: #ffffff;
}

QLabel {
    font-size: 32px;
    color: #333333;
}

QPushButton {
    border: none;
    padding: 5px;
    border-radius: 4px;
}
QPushButton:hover {
    background: #f0f0f0;
}

QListWidget {
    border: 1px solid #ddd;
}

QTimeEdit, QLineEdit {
    border: 1px solid #ccc;
    border-radius: 4px;
    padding: 2px 4px;
}

并在 main.py 中加载它:

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

if __name__ == "__main__":
    app = QApplication(sys.argv)
    with open("resources/style.qss", "r") as f:
        app.setStyleSheet(f.read())
    win = MainWindow()
    win.show()
    sys.exit(app.exec_())

十一、异常处理与容错

在开发过程中,我遇到过几次莫名其妙的崩溃:有时用户把电脑从睡眠唤醒后,QTimer 会突然停止;有时声音文件加载失败;有时 JSON 配置损坏。为此,我给以下关键区域加了 try-except:

  • ConfigManager.load():读取失败,重置为默认,并重写文件;
  • TimerController._on_timeout():捕获所有异常,打印日志,保证定时器不会停;
  • NotificationManager.notify():若通知或声音失败,至少要在控制台打印消息。

此外,还可以在主界面设一个“重置配置”按钮,一键清空 config.json,恢复默认设置。


十二、打包与发布

代码完成后,想让朋友也能方便使用,于是用 PyInstaller 打包:

代码语言:bash复制
pyinstaller --noconfirm --clean --windowed \
    --name PomodoroTimer \
    --add-data "resources/;resources/" \
    main.py

打包选项说明与之前项目类似。生成的 dist/PomodoroTimer/ 下直接分发,用户无需安装任何依赖。

biubiu~

在这里插入图片描述

十三、优化方向和扩展

项目基本达成最初设想,但后续还可以这样升级:

  1. 支持系统启动时自动最小化到托盘;
  2. 增加统计面板,记录完成的番茄钟次数;
  3. 利用 matplotlib 绘制专注时间折线图,可视化每日工作时长;
  4. 支持在线同步配置,跨设备使用;
  5. 丰富提醒方式:文字转语音、桌面气泡、Email 推送。

每项功能都能让这个小工具更贴合个人需求,也能让我进一步钻研 PyQt 的更多特性。


总结

一路走来,从需求梳理、模块设计、编码实现,到美化、异常处理和打包,虽说只是一款小小的番茄钟,但它承载了我对 PyQt 功能的深度探索。更重要的是,每当定时器响起,告诉我该休息或该专注时,仿佛都在提醒我:合理规划时间,专注当前任务,效率必将提升

如果你也想打造属于自己的定时提醒器,或想在此基础上加玩法,欢迎留言交流。希望我的开发历程,能给你一些启发和帮助。祝你高效 Coding,专注常在!


— END —

本文标签: PyQt5 番茄钟实现