写在前面:为什么要先写数据采集?

如果把 TypeWell 比作一个人,那么:

  • 数据采集是眼睛和耳朵 —— 得先“看见”用户按了什么键
  • 热力图渲染是脸面 —— 把数据变成好看的界面
  • AI 分析是大脑 —— 给出健康建议

没有眼睛耳朵,脸面和大脑都是摆设。所以第一篇,咱们先把“看见”这件事搞定。


开源仓库地址:Gitee TypeWell 雪豹同志
如果对你有帮助,欢迎 Star ⭐️

在这里插入图片描述


一、选型:为什么用 keyboard 库?

Python 里监听键盘,主流方案有这几个:

方案 优点 缺点 适合场景
keyboard 跨平台、纯 Python、全局钩子 Windows 需管理员权限 桌面工具、热键监听
pynput 也跨平台、API 优雅 事件处理稍重 自动化脚本
tkinter 绑定 不用装第三方 只能监听自己窗口 游戏、编辑器插件
ctypes 硬调 API 性能极致 平台相关、代码复杂 底层开发

我选 keyboard 的理由特别简单:

import keyboard
keyboard.on_press(on_key_press)   # 就这一行,搞定

能一行解决的,绝不用两行。 程序员的基本素养。


二、先看整体:数据采集层长什么样

打开 main.py,找到 KeyboardHeatmapApp 类的初始化:

class KeyboardHeatmapApp(QMainWindow):
    def __init__(self):
        super().__init__()
        
        # 1. 内存缓存(核心!)
        self.cached_data = {}
        
        # 2. 初始化数据库
        self.init_db()
        
        # 3. 启动键盘监听
        self.start_keyboard_listener()
        
        # 4. 定时器:每 2.5 秒刷一次数据
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.update_frontend)
        self.timer.start(2500)

代码就这么几行,但每一行背后都有故事。咱们一个一个拆。


三、键盘监听:让 Python 听见每一次敲击

3.1 最简单的监听

def start_keyboard_listener(self):
    keyboard.on_press(self.on_key_press)

on_press 是个全局钩子。什么叫全局钩子?

不管你的焦点在浏览器、编辑器还是游戏里,只要键盘被按下,这个函数就会被调用。

操作系统会在处理键盘事件的时候,顺便通知你的程序一声。

3.2 回调函数里做什么?

def on_key_press(self, event):
    key = self.normalize_key(event.name)          # 1. 标准化按键名
    self.cached_data[key] = self.cached_data.get(key, 0) + 1   # 2. 更新内存缓存

就两件事:

  • 标准化:把 right shift 变成 shift,把 - 变成 minus
  • 更新缓存:内存里记一笔,不写磁盘

四、内存缓存:为什么不能直接写数据库?

4.1 错误示范(千万别这么写)

# ❌ 错误示范:每次按键都写数据库
def on_key_press(self, event):
    conn = sqlite3.connect('keyboard_heatmap.db')
    conn.execute("UPDATE key_usage SET count = count + 1 WHERE key = ?", (key,))
    conn.commit()
    conn.close()

假设你打字速度是每秒 10 个键,那这一秒里就要:

  • 10 次建立数据库连接
  • 10 次磁盘 I/O
  • 10 次事务提交

程序直接卡成 PPT,磁盘寿命也堪忧。

4.2 正确做法:内存缓存 + 批量写入

# ✅ 正确示范:先攒着,定时写
def on_key_press(self, event):
    key = self.normalize_key(event.name)
    self.cached_data[key] = self.cached_data.get(key, 0) + 1   # 内存操作,快如闪电

定时器每 2.5 秒调用一次 update_frontend,集中处理:

def update_frontend(self):
    if not self.cached_data:
        return
    
    # 1. 批量写入数据库
    conn = sqlite3.connect('keyboard_heatmap.db')
    for key, count in self.cached_data.items():
        conn.execute("INSERT OR IGNORE INTO key_usage (key, count) VALUES (?, 0)", (key,))
        conn.execute("UPDATE key_usage SET count = count + ? WHERE key = ?", (count, key))
    conn.commit()
    conn.close()
    
    # 2. 清空缓存
    self.cached_data.clear()

性能提升:100 次磁盘写入 → 1 次磁盘写入。


五、按键标准化:为什么名字会乱?

5.1 看看实际返回的键名

物理按键 keyboard 返回的名字
右 Shift right shift
左 Shift left shift
减号 minus-
Esc escapeesc
空格 space

如果不做处理,数据库里就会出现:

  • shift
  • right shift
  • left shift
  • -
  • minus

同一个物理按键,五六个名字,统计直接崩了。

5.2 为什么 TypeWell 要区分左右键?

这里有个重要的设计决定:TypeWell 保留了左右键的区别

为什么?因为人体工学分析需要知道哪只手在按。左 Shift 是左手小指按的,右 Shift 是右手小指按的。如果把它们统一成 shift,就分不清左右手了。

所以 TypeWell 的做法是:

  • 左右键保留原名left shiftright shiftleft ctrlright ctrl
  • 符号键统一命名-minus 统一成 minus[lbracket 统一成 lbracket

5.3 TypeWell 的标准化函数

def normalize_key(self, key):
    key = key.lower()
    
    # 处理特殊符号(这些需要统一,没有左右之分)
    if key == 'escape':
        return 'esc'
    if key == 'backtick' or key == '`':
        return 'backquote'
    if key == 'minus' or key == '-':
        return 'minus'
    if key == 'equal' or key == '=':
        return 'equal'
    if key == 'bracketleft' or key == '[':
        return 'lbracket'
    if key == 'bracketright' or key == ']':
        return 'rbracket'
    if key == 'backslash' or key == '\\':
        return 'backslash'
    if key == 'semicolon' or key == ';':
        return 'semicolon'
    if key == 'apostrophe' or key == "'":
        return 'quote'
    if key == 'comma' or key == ',':
        return 'comma'
    if key == 'period' or key == '.':
        return 'period'
    if key == 'slash' or key == '/':
        return 'slash'
    
    # 功能键(没有左右之分)
    if key == 'print screen':
        return 'printscreen'
    if key == 'scroll lock':
        return 'scrolllock'
    if key == 'pause break':
        return 'pause'
    if key == 'page up':
        return 'pageup'
    if key == 'page down':
        return 'pagedown'
    
    # 左右键保留原样返回,不做统一
    # 比如 left shift、right shift、left ctrl、right ctrl 都会原样保留
    return key

注意:前端 script.js 里有一个几乎一模一样的 normalizeKey 函数。这是故意的——前后端必须用同一套命名规则,否则数据对不上。

5.4 为什么左右键要保留?

看一眼前端 fingerMap 的定义就明白了:

const fingerMap = {
    'left_pinky': ['q', 'a', 'z', '1', 'tab', 'caps', 'left shift', 'left ctrl'],
    'right_pinky': ['p', ';', '/', '0', '-', '=', 'backspace', 'enter', 'right shift', 'right alt'],
    // ...
};

左右 Shift 分配给不同的手指:

  • left shift → 左手小指
  • right shift → 右手小指

如果把它们统一成 shift,就不知道是哪根小指在受苦了。

在这里插入图片描述


六、数据库设计:简单到不能再简单

def init_db(self):
    conn = sqlite3.connect('keyboard_heatmap.db')
    c = conn.cursor()
    c.execute('''CREATE TABLE IF NOT EXISTS key_usage
                 (key TEXT PRIMARY KEY, count INTEGER DEFAULT 0)''')
    conn.commit()
    conn.close()

就两个字段:

  • key:标准化后的按键名(主键)
  • count:累计敲击次数

为什么这么简单?因为 TypeWell 只关心当前的热力图状态,不需要历史趋势。如果需要按天统计,以后再加表。

设计原则:先跑起来,再优化。


七、程序退出时数据会丢吗?

这是个好问题。

7.1 风险分析

如果用户直接关窗口,内存缓存里的数据还没写进数据库,不就丢了吗?

7.2 TypeWell 的应对

def closeEvent(self, event):
    self.is_closing = True
    if self.timer.isActive():
        self.timer.stop()
    event.accept()

等等,这里没有写数据库?

仔细一想,其实不需要。因为定时器每 2.5 秒刷一次盘,用户关窗口的时候,最多丢 2.5 秒的数据。对于热力图这种统计型应用,2.5 秒的数据丢失影响微乎其微。

如果非要较真,可以再加一次强制刷盘:

def closeEvent(self, event):
    self.update_frontend()  # 强制写入
    super().closeEvent(event)

但 TypeWell 没这么做——够用就好


八、线程安全:有没有坑?

这是一个隐藏的坑

8.1 问题在哪

  • keyboard.on_press独立线程里回调
  • PyQt 主线程在跑 UI 和定时器
  • 两个线程同时操作 self.cached_data
# 监听线程执行
def on_key_press(self, event):
    self.cached_data[key] = self.cached_data.get(key, 0) + 1   # 写

# 主线程执行
def update_frontend(self):
    for key, count in self.cached_data.items():  # 读
        # 写数据库
    self.cached_data.clear()  # 清空(写)

理论上,这会有竞争条件。比如主线程正在遍历 cached_data,监听线程突然往里加了个新键,遍历就可能出错。

8.2 TypeWell 为什么没出事

  1. 监听线程只写,主线程只在刷盘时读+清空
  2. 读写冲突的时间窗口极短
  3. 即使丢一两次计数,热力图整体趋势不受影响

8.3 如果想写得更健壮

import threading

class KeyboardHeatmapApp:
    def __init__(self):
        self.lock = threading.Lock()
        self.cached_data = {}
    
    def on_key_press(self, event):
        with self.lock:
            self.cached_data[key] = self.cached_data.get(key, 0) + 1
    
    def update_frontend(self):
        with self.lock:
            data_to_flush = self.cached_data.copy()
            self.cached_data.clear()
        # 用 data_to_flush 写数据库

TypeWell 为了简洁没加,但你要知道这个坑存在


九、完整流程串联

现在把整个数据采集层串起来看:

用户按键
    ↓
[keyboard.on_press 回调](监听线程)
    ↓
normalize_key() 标准化
    ↓
cached_data 字典 +1(内存操作)
    ↓
【每隔 2.5 秒】
    ↓
[主线程定时器触发 update_frontend]
    ↓
遍历 cached_data,批量写入 SQLite
    ↓
清空 cached_data
    ↓
读取数据库最新数据(下篇讲)
    ↓
推给前端渲染

十、踩坑总结

问题 现象 解决方案
性能差 打字卡顿 内存缓存 + 批量写入
键名混乱 同一个键多个名字 normalize_key 标准化(符号统一,左右保留)
退出丢数据 刚关程序,最后几秒的数据没了 2.5 秒刷一次,影响极小
线程安全 多线程操作同一字典 理论上要加锁,实际没出事
管理员权限 Windows 监听失败 以管理员身份运行

写在最后

这一篇的核心就三句话:

  1. 监听keyboard.on_press 注册回调
  2. 缓存:按键数据先攒在内存里
  3. 刷盘:定时批量写入数据库

代码都在 main.py 里,加起来不到 100 行。但就是这 100 行,决定了 TypeWell 能不能流畅地听见每一次敲击

如果觉得文章不错,欢迎点赞评论收藏,更多内容,敬请关注!

Logo

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

更多推荐