定时提醒小助手2.0是我最近花时间重写的一款本地待办提醒工具,它的三个关键词很直白:半透明、指定时间、语音提示。说白了,就是在桌面上放一块几乎不占地方的半透明面板,你往里丢几条待办事项,设定好时间,到点它就用系统语音开口提醒你,而不是像普通弹窗一样悄无声息地消失。这版2.0重点解决了上一版“提醒太轻、界面太吵、数据不可靠”三个问题,也让整个工具从“一个脚本”变成了“一款真正能每天挂机运行的桌面软件”。如果你经常用待办清单却总是错过提醒,或者想做一款小而美的桌面小工具,这篇内容会比较适合你。
我先说个背景:我自己习惯每天列待办,但市面上的待办工具大多走“账号+同步+多端App”路线,为了记一件事还得打开手机、等同步。而本地桌面工具又往往只有简单弹窗,提醒弹在角落里根本没注意。所以这款软件的核心定位一直是:本地优先、离线可用、到点开口,不搞复杂功能。2.0新增的半透明常驻界面和聚合语音播报,都是为了一个目标——让提醒真的被看见、被听见。
1. 项目定位与整体方案设计
1.1 核心需求拆解:从“弹窗通知”到“音画同振”
做这个项目之前,我先把手头几个常用提醒工具的缺点列了一下,基本集中在三件事上。
第一是提醒形式太轻。系统通知弹出来,过几秒自动消失,人还坐在屏幕前盯着文档,可注意力根本没分给那条通知。第二是操作成本太高。很多工具要做完一次提醒,得先打开App、登录、等同步,再新建待办。第三是缺少语音通道。人眼在专注工作时很难分神去看屏幕,但耳朵是可以接收信息的,合适的语音播报能强制把注意力拉回来。
所以2.0的功能需求其实很明确,可以整理成一张功能矩阵:
| 功能模块 | 具体需求 | 优先级 |
|---|---|---|
| 待办管理 | 添加、编辑、删除、标记完成 | 必须 |
| 定时规则 | 指定时间触发、每日重复、间隔重复 | 必须 |
| 提醒方式 | 半透明弹窗 + 语音播报同时触发 | 核心特色 |
| 常驻体验 | 托盘运行、开机自启、置顶显示 | 必要 |
| 数据存储 | 本地保存,重启不丢 | 必须 |
| 多端同步 | 账号体系、云同步 | 2.0不做 |
这个版本我明确砍掉了多端同步和自然语言解析。原因很简单:单机场景下,本地SQLite足够可靠,删掉同步逻辑可以让项目保持轻量,也不会出现“服务器挂了提醒就失效”的荒诞情况。自然语言解析则属于体验增强,放到后续版本再看。小工具的规划逻辑就应该是这样:先把稳定性和核心提醒链路打磨好,再做加分项。
1.2 技术选型:为什么仍然选Python + PySide6
关于桌面工具的选型,我在几个方案之间犹豫过一阵。Electron那套做半透明界面确实好看,但启动速度和安装包体积都太“豪华”了,一个提醒工具打包出来上百兆实在说不过去。Tauri轻量很多,但调试成本高,而且Windows下的系统语音调用绕一层系统桥,出问题不好排查。C#的WPF很合适,不过跨平台就差一些,Mac和Linux用户没法直接用。
最终我选了Python加PySide6的方案。四个原因:
- PySide6对半透明窗口有原生支持,
WA_TranslucentBackground加上无边框标志就能做出毛玻璃效果,不需要额外引第三方库。 - 语音模块直接调系统TTS引擎,离线可用,延迟低,不依赖网络。
- Python的开发速度快,改需求、写脚本、跑实验都方便,尤其是初期频繁调整提醒逻辑时,改完就能跑。
- 项目维护成本低。哪怕用户不懂代码,也能自己打开脚本改播报模板、调透明度。
当然,Python方案也有明显问题,比如打包体积大、性能上限低。但用在待办提醒这类低负载场景里,这些缺点几乎不构成影响。合理的工程选择不是“用最强的语言”,而是“用最匹配场景的方案”。
1.3 界面与交互设计:半透明不是为了炫技
半透明界面经常被误解成视觉效果,其实放在常驻桌面工具里,它更多是一种交互策略。如果提醒面板完全不透明,放在右下角就会挡内容;如果做成纯透明,信息又看不清。半透明的意义是降低视觉侵入感,让面板一直存在、但不打断当前工作。
2.0的界面设计遵循几个原则:
- 面板默认透明度在0.6到0.7之间,文字保持高对比,保证一瞥就能读清。
- 鼠标滚轮可以直接调节透明度,临时需要专注时调低,需要看清时调高。
- 窗口支持无边框拖拽,可以贴靠在屏幕边缘,双击标题栏切换置顶状态。
- 提醒触发时,面板边框会短暂高亮,配合语音形成“音画同振”的效果。
我测试过很多透明度数值,低于0.45之后文字可读性明显下降,高于0.85又和普通窗口没区别。所以默认0.65是一个比较舒服的平衡点,同时允许用户自行调节上下限。
2. 功能拆解与核心技术点
2.1 待办事项管理:用SQLite把数据固定住
待办管理是整个软件的数据底座。这个版本我坚定不移地选了SQLite,而不是JSON文件。很多人觉得“就一个提醒工具,用JSON存就够了”,但实际用起来,待办数据会被反复读写、修改状态,JSON在频繁写入和进程异常退出时很容易损坏。SQLite有事务和崩溃恢复机制,抗折腾能力强得多。
数据库表设计很直接,一张todos表就够:
CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, remind_at TEXT NOT NULL, remind_type TEXT DEFAULT 'once', repeat_interval INTEGER DEFAULT 0, priority INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', created_at TEXT DEFAULT (datetime('now', 'localtime')), reminded_at TEXT, completed_at TEXT ); CREATE INDEX idx_todos_due ON todos(status, remind_at);字段说明:
remind_type:once表示单次,daily表示每日重复,interval表示按固定间隔重复。repeat_interval:配合interval类型使用,单位是分钟。比如每隔90分钟提醒一次。status:pending待提醒,done已完成,expired过期未处理。
这里有个设计上容易犯的错误:很多人会把“每日重复”做成“提醒完之后把状态改回pending”,这样看起来简单,但一旦中间断了一天,历史记录就乱了,而且无法判断这条待办当天是否真的提醒过。我采用的做法是:单次任务一旦触发,生成一条新的pending记录,旧记录状态改为done并记录completed_at。这样每天的任务都有独立记录,统计和补发都方便。
时间格式建议统一用%Y-%m-%d %H:%M:%S字符串存储,不要为了“省几个字节”去存时间戳。虽然字符串看起来占空间,但可读性好,调试排查时直接用SQL就能看明白,不需要层层转换。
2.2 定时调度引擎:单次定时器加到期重算
定时调度是最容易出问题的模块,也是整个软件的灵魂。很多新手会写一个死循环每秒查一次数据库,这个方案不是不行,只是太粗暴。每秒轮询不仅持续占CPU,还会频繁唤醒系统,而且在系统休眠恢复后容易出现定时不准的连锁问题。
2.0的调度引擎换成了“单次定时器加到期重算”方案。核心思路就是:每次都只算下一个最近的提醒时间,给QTimer设置一个单次触发,触发后再从数据库拉取下一批到期任务,循环往复。代码骨架大概是这样的:
class Scheduler(QObject): remind_triggered = Signal(list) def __init__(self): super().__init__() self.timer = QTimer() self.timer.setSingleShot(True) self.timer.timeout.connect(self.schedule_next) def schedule_next(self): now = datetime.now() due_items = fetch_due_todos(now) if due_items: self.remind_triggered.emit(due_items) mark_reminded(due_items) next_time = fetch_next_deadline(now) if next_time is None: self.timer.stop() return delay_ms = int((next_time - datetime.now()).total_seconds() * 1000) if delay_ms < 0: delay_ms = 0 self.timer.start(delay_ms)这个方案的好处很明显:没有定时器的时间不需要占用CPU,触发时机也精确到毫秒级。还有几点细节必须注意:
- 如果下一条提醒在24小时以上,
delay_ms可能超过部分系统定时器的上限。虽然QTimer在多数平台上支持很大的间隔,但为了稳妥,可以分段启动,先休眠较短间隔再重算。 - 系统休眠是调度的天敌。休眠期间QTimer不会触发,唤醒后可能一次性涌入一堆到期任务。所以不仅要启动时重算,最好在系统唤醒事件里也强制调用一次
schedule_next,用墙钟时间重新校准。 - 任务触发后不能只更新内存状态,必须写回数据库。因为语音播报和UI展示用的都是一个数据源,数据源一旦不一致,后面补发、统计都会错乱。
2.3 语音提示模块:让待办事项真正“开口”
语音提示是这次升级的重点。上一版只有弹窗,效果太差;这一版我用pyttsx3调用系统内置的TTS引擎。选它有两个原因:一是完全离线,不依赖任何网络服务,隐私和安全都更可控;二是延迟低,触发后几十毫秒就能开口。
语音播报的内容模板很重要。最初我写的是“到时间了,请处理你的待办事项”,实验了几轮发现这样的播报信息量太低。后来改成“先报总数,再逐条念标题”的聚合模式:
import pyttsx3 class Speaker: def __init__(self): self.engine = pyttsx3.init() self.engine.setProperty('rate', 180) self.engine.setProperty('volume', 0.9) def announce_todos(self, todos): if not todos: return now = datetime.now().strftime("%H:%M") lines = [f"现在是{now},你有{len(todos)}项待办需要处理。"] for i, item in enumerate(todos, 1): lines.append(f"第{i}项:{item['content']}") text = "。".join(lines) self.engine.say(text) self.engine.runAndWait()播报示例就是:“现在是09:30,你有3项待办需要处理。第1项:给客户回复方案邮件。第2项:整理项目周报。第3项:预约会议室。”
这个模板看起来简单,实际打磨过很多次。一开始是一件事播报一件事,连续三条语音要播几分钟,听的人根本没耐心,播到第三条已经分了神。改成聚合播报后,用户能先知道总量,再选择性接收细节,注意力浪费小很多。
还有一个必须踩的坑:runAndWait是阻塞调用,直接放在主线程会让整个UI卡死。解决方式也简单,把播报放到一个单独的QThread里,主线程只负责接收信号和更新界面状态。另外,如果语音引擎初始化失败、或者机器上没有任何可用语音,要自动降级成弹窗提醒加系统提示音,保证提醒不会因为TTS故障而丢失。
2.4 半透明置顶窗口:Qt样式表与无边框的配合
半透明窗口的核心代码并不复杂,难点在于组合配置。窗口要用无边框、置顶、工具窗口三种标志一起配合,少任何一个都会出问题:
class TipWindow(QWidget): def __init__(self): super().__init__() self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setStyleSheet(""" QWidget#root { background-color: rgba(10, 12, 16, 0.65); border-radius: 10px; } """)三个标志位各有用处:
FramelessWindowHint去掉系统边框,否则无法实现圆角和半透明。WindowStaysOnTopHint让面板始终置顶,保证提醒弹出时能被看到。Tool让窗口不占用任务栏位置,更像一个小挂件而不是一个独立应用。
WA_TranslucentBackground是让窗口背景真正透明的前提。没有这个属性,直接设置半透明样式表,窗口底层往往是黑色或者灰色矩形,非常难看。有了它之后,再配合样式表里rgba背景色和圆角,视觉上才是一块圆角半透明面板。
无边框窗口有个副作用是不能拖动,需要自己实现鼠标事件。我的做法是在mousePressEvent里记录按下位置,在mouseMoveEvent里移动窗口:
def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._drag_start = event.globalPosition().toPoint() - self.frameGeometry().topLeft() event.accept() def mouseMoveEvent(self, event): if event.buttons() & Qt.MouseButton.LeftButton: self.move(event.globalPosition().toPoint() - self._drag_start) event.accept()滚动调节透明度则通过重写wheelEvent实现,每次滚动将root的background-color透明度增减几个百分点,并限制在0.45到0.85之间。这块看似小功能,但实测下来是用户反馈最多、最喜欢的小细节。
3. 从零复现:构建一个可用的2.0版本
3.1 项目骨架与环境准备
整个项目文件不多,我按功能拆成几个模块:
timer_helper/ ├── main.py # 程序入口,负责QApplication和信号连接 ├── db.py # SQLite初始化与待办增删改查 ├── scheduler.py # 定时调度器 ├── speaker.py # 语音播报模块 ├── ui_main.py # 半透明主界面 └── assets/ # 图标、样式等环境准备就三步。建议用虚拟环境,避免污染全局的Python环境:
mkdir timer_helper cd timer_helper python -m venv venv source venv/bin/activate # 桌面操作系统下也可能直接进入虚拟环境目录的Scripts文件夹 pip install PySide6 pyttsx3这里有两个容易踩的坑:一是不要在QApplication创建之前就初始化UI或者语音引擎,PySide6的很多组件依赖QApplication实例;二是在某些Linux环境下需要额外安装TTS语音包,否则pyttsx3.init()会直接报错。Windows环境一般开箱即用,系统自带语音引擎就能工作。
3.2 数据库模块:增删改查与到期查询
db.py里最核心的是两个查询函数。一个是查到期任务,一个是查下一条即将到期的任务:
import sqlite3 from datetime import datetime DB_PATH = "todos.db" def fetch_due_todos(now): now_str = now.strftime("%Y-%m-%d %H:%M:%S") conn = sqlite3.connect(DB_PATH) rows = conn.execute( "SELECT * FROM todos WHERE status='pending' AND remind_at <= ?", (now_str,) ).fetchall() conn.close() return [dict(row) for row in rows] def fetch_next_deadline(now): now_str = now.strftime("%Y-%m-%d %H:%M:%S") conn = sqlite3.connect(DB_PATH) row = conn.execute( "SELECT MIN(remind_at) FROM todos WHERE status='pending' AND remind_at > ?", (now_str,) ).fetchone() conn.close() return parse_time(row[0]) if row and row[0] else None注意连接数据库要记得关闭。项目规模小,很多人不在乎连接管理,但长期运行的桌面程序一旦连接泄漏,数据库文件会被锁住,到后面连写入都失败。
插入待办时,还要处理重复规则。如果是daily类型,插入时先用当天的日期算出目标时间;如果是interval类型,就根据当前时间和间隔生成未来的remind_at。这块逻辑不复杂,但字段之间容易互相冲突,我的建议是写一个统一的create_todo(content, remind_at, remind_type, repeat_interval)函数,所有入口都走这一个函数,不可以在UI里直接拼接SQL。
3.3 调度器与语音播报联动
调度器触发后,难点在于不阻塞UI、又能完成“标记已提醒、生成下一轮重复任务、播报”这一连串动作。我的实现是让调度器发出remind_triggered信号,界面层收到信号后,先刷新列表,再启动一个后台播报线程:
class ReminderApp: def __init__(self): self.scheduler = Scheduler() self.scheduler.remind_triggered.connect(self.on_remind) self.speaker = Speaker() self.speaker_thread = None def on_remind(self, todos): self.ui.refresh_list() self.speaker_thread = threading.Thread(target=self.speaker.announce_todos, args=(todos,)) self.speaker_thread.start()这里用threading.Thread而不是每次都创建QThread,是因为语音播报是一次性任务,用完就结束,没必要引入线程生命周期管理。但在关闭程序时,主线程里要join一下这个线程,避免语音还没播完程序就退出。
还有一点:如果同一时间涌入多批任务,语音线程会叠在一起。我在模块里加了一个is_speaking标志,如果当前已经在播报,新任务只记入待播队列,等上一轮播完再取最新的队列内容。这样不会有多个声音同时开口的灾难。
3.4 半透明界面:主面板、待办列表、快捷添加
主界面的布局不复杂:顶部是标题栏,中间是待办列表,底部是快捷输入框和按钮。因为这个面板是常驻桌面角落的,所以整体高度控制在300到400像素之间,宽度克制在280到320像素,避免挡住太多屏幕。
界面里最需要注意的坑是样式表的作用域。半透明背景不要设置在主窗口上,而是设置在主窗口内的一个根QWidget上。因为主窗口本身一旦设置了不透明背景,WA_TranslucentBackground就失效了。代码结构就是上面给的:根QWidget使用rgba背景、圆角,列表和文字放在这个根容器上。
另外,待办列表本身要透明。QListWidget默认有白色背景,如果不设置透明样式,它会在半透明面板上盖出一块白底:
self.list_widget.setStyleSheet(""" QListWidget { background: transparent; border: none; } QListWidget::item { color: #F2F3F5; padding: 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } """)快捷添加框用QLineEdit,按回车就插入待办。输入框里可以做一个简单解析:如果包含“明天”“下午3点”之类的关键词,就自动转换成具体时间;如果只是普通文字,则默认使用快捷输入框右侧的时间控件来定时。这些解析逻辑不放进2.0核心,但做一层轻量的支持会让日常使用舒服很多。
3.5 打包与开机自启设置
调试完成后,用PyInstaller打包。命令里有两个隐藏导入必须要写,否则打包出来的程序在别的机器上可能无法语音播报:
pyinstaller -F -w --hidden-import=pyttsx3.drivers --hidden-import=pyttsx3.drivers.sapi5 -i assets/app.ico main.py-F生成单个可执行文件,-w不显示控制台窗口。打包产物通常会到几十兆,这是Python方案绕不过去的体积,但换来的是开发和维护效率,可以接受。
开机自启我用了最稳妥的“启动文件夹”方案。往当前用户的启动目录放一个快捷方式,不需要管理员权限,也不会被安全软件拦。实现思路是获取用户主目录下的启动文件夹路径,然后把打包后的exe路径写进一个.lnk快捷方式文件。如果有版本更新,直接替换启动目录里的快捷方式指向即可。
4. 实测过程中踩过的坑与排查思路
4.1 定时为什么不准了
第一个版本我用的是“每秒轮询数据库”方案,结果发现两个问题:第一,程序挂着的时候CPU占用虽然不高,但每次查询都会唤醒数据库,整体耗电偏高;第二,系统休眠恢复后,轮询点会跳过,导致一批提醒在唤醒后同时弹出来。
换用单次QTimer之后,定时精度提升非常明显。但还有一个坑是事件循环阻塞。如果主线程里做了耗时操作,比如语音播报没放线程,QTimer的定时信号就会被排队,看起来就像定时器没有触发。所以我把所有可能阻塞的调用全部放到了线程里,保证主线程的事件循环始终通畅。
还有一个隐蔽问题:某些系统上QTimer的间隔设置成很大值时会溢出。实测中有一条跨了30天的待办,计算出的毫秒数超过了32位整数范围。解决方法是拆段定时,比如延时超过一天就分成多段,每段最多12小时,触发后重新计算和重设。
4.2 语音播报为什么不出声或者破音
语音模块常见的故障有三个。
第一是系统里没有任何可用的中文语音。此时pyttsx3虽然能初始化,但say之后毫无反应。排查方法很简单,写一个枚举脚本看看当前有哪些语音:
import pyttsx3 engine = pyttsx3.init() for voice in engine.getProperty('voices'): print(voice.id, voice.name)如果列表是空的,就要去系统设置里安装对应语言的基础语音包。
第二是音量太小或被系统静音。我最初把播放音量卡在0.9,结果在部分设备上听起来很闷。后来改成播报前先查询系统默认音量,如果低于某个阈值就临时抬高,播完再恢复。这个逻辑比较适合提醒场景,既能保证被听到,又不会永久改动用户的音量设置。
第三是语速太快导致“破音”。TTS引擎在语速超过200后,中英文混排的内容经常会吞字。我最后把语速固定在170到185之间,在清晰度和效率之间取平衡。
4.3 半透明背景变成纯黑或纯白矩形
我刚开始写半透明窗口时,犯了一个很典型的错误:直接把样式表的background-color设在了主窗口上,结果窗口底部是一块黑色矩形,圆角和透明度都没有。
原因是setAttribute(WA_TranslucentBackground)生效后,主窗口本身需要保持透明,绘制工作应该交给子控件。我的处理方式是让主窗口不设置任何背景,在界面里单独放一个QWidget作为根容器,所有背景、圆角、阴影都写在根容器的样式表里。子控件可以继续使用普通样式,不必担心透明穿透。
另外,如果调试时发现整个窗口完全看不见,先检查一下窗口大小是否为零,或者窗口坐标是否跑到屏幕外去了。这类问题不是样式的问题,而是布局错误。
4.4 程序关闭后待办丢失
这个坑出现在我用相对路径创建数据库的时候。PyInstaller打包后,程序的工作目录可能和预期不一样,导致SQLite文件被创建到了临时目录里,程序一关文件就被清理,所有待办消失。
解决方法是让数据库路径固定指向用户主目录下的应用配置目录,不随工作目录变化:
import os from pathlib import Path APP_DIR = Path.home() / ".timer_helper" APP_DIR.mkdir(exist_ok=True) DB_PATH = str(APP_DIR / "todos.db")这样即便程序被安装到任意位置,待办数据都会稳定落在同一个地方。还有个小习惯:每天首次启动时对自己做一次数据库完整性检查,用PRAGMA integrity_check,如果发现损坏就自动备份旧文件并重建,这事我在版本迭代中遇到过几次,提前加了这个保护之后省了很多事。
4.5 常见问题速查表
| 问题 | 可能原因 | 处理方式 |
|---|---|---|
| 到点不提醒 | 调度器线程阻塞或QTimer被延迟 | 检查主线程是否卡顿,耗时任务放到线程 |
| 语音无声音 | 系统没有可用语音、音量被静音 | 枚举voices,安装语言包,播放前检测音量 |
| 只弹窗不播报 | TTS初始化失败 | 初始化时加异常捕获,失败时降级为系统提示音 |
| 半透明背景发黑 | 主窗口直接设置了不透明背景 | 改用根QWidget承载背景样式,保持主窗口透明 |
| 面板被其他窗口遮挡 | 置顶标志丢失 | 检查WindowStaysOnTopHint是否被重置,必要时重设 |
| 程序退出后待办丢失 | 数据库路径在工作目录 | 数据库固定到用户主目录的应用配置目录 |
| 重复任务不续期 | 触发后没有生成下一条记录 | 在状态流转时先插入新任务再标记旧任务完成 |
| 开机自启失效 | 快捷方式指向了旧路径 | 重新生成启动目录快捷方式,确认exe路径无变化 |
表格里最后一条我在实测中真遇到过:打包新版本后,自启快捷方式还指向上一次编译的旧exe路径,旧文件被删掉之后启动目录就失效了。所以打包流程里,开机自启快捷方式要在安装完成后重新刷新,而不是只创建一次。
我自己用下来最大的感受是,提醒工具的核心不是功能列表长短,而是稳定性。到点必须响,隔天不能丢,这两件事做到位,比任何花哨的界面都重要。半透明界面只是让人愿意把它挂在桌面上,真正留住用户的是每一次提醒都恰到好处。如果继续做3.0,我大概率会优先加跨设备同步和自然语言输入,但从2.0这个版本看,本地方案已经能覆盖绝大多数日常待办场景,值得每个写小工具的开发者动手复刻一次。