talk2clip 录音时屏幕顶部有一个 40fps 跳动的胶囊波形。实现它本来以为 20 分钟,实际踩了三个坑:一是 NSTimer 在 rumps 环境根本不触发(画面死水),二是 PyObjC 里 NSGradient 画出的是“方块光球”(不是圆的),三是把 UI 调用塞进后台线程的线程安全。下面按踩坑的顺序写,最后是现在在用的写法。
接系列前两篇(macOS 15 权限地狱 和 HuggingFace 模型下载),这篇是 PyObjC 纯 UI 实现的记录:talk2clip 的录音波形指示器,按住右⌘说话时屏幕顶部跳动的那个胶囊。
先说这个需求本身:录音时用户最需要的是“我在被接收”的即时反馈。命令行下只有一行 录音中… 太弱,菜单栏图标用户说“刘海屏看不到了”,所以方案是屏幕顶部悬浮一个胶囊,15 根波形柱随音量跳动,松手消失。
坑一:NSTimer 在 rumps 里根本不触发
第一版动画驱动用的是最直觉的方案:
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
1 / 15, self, "tick:", None, True) # 15fps 定时器
tick 回调里更新相位、setNeedsDisplay_ 重绘。单独写个脚本测试:窗口出现、动画流畅、一切正常。
接到真实程序(rumps 菜单栏 app)里,窗口出现,但画面死了,一动不动。
排查过程:
- 先怀疑 drawRect 写错 → 手动调一次
setNeedsDisplay_(True)能画 → 绘制没问题 - 再以为 NSTimer 没启动 → 打印确认 timer 对象非空
- 加日志确认 tick: 是否被调 → 一次都没调
结论先说事实:在这套组合下,NSTimer 的回调从未被调用过(日志为证),窗口第一帧之后再没重绘,这是能确定的部分。
机理是推测:scheduledTimerWithTimeInterval 把 timer 挂到当前线程的 default runloop mode,标准 AppKit 程序的主循环恰好处理这个 mode,所以单独测试能跑;rumps 的 App().run() 事件循环在 idle 时处理的 mode 和 default 不完全重合,timer 就一直没有触发机会。这个解释我不敢说 100% 对(PyObjC 老手可能能给出更精确的 runloop mode 细节),但实测如此 + 绕行方案验证有效是确定的,下面的线程驱动方案在真实环境跑通了。
这类问题网上教程很少(教程里的 NSTimer 例子都是标准 AppKit 应用,验证不了这种场景),排查全靠打日志确认回调没被调,UI “不可见地坏了”是最难查的 bug 之一,因为没有任何报错。
坑二:PyObjC 里 NSGradient 画出的是方块,不是光球
解决完上一个坑,我想换个花样:做个 Siri 风格的光球(径向渐变圆 + 光晕),视觉效果更精致。关键代码:
grad.drawInRect_angle_(NSMakeRect(x, y, r*2, r*2), 120)
第一版截图出来:一个蓝色方块,左上角一个白色小方点。完全不是球的形状。

原因:drawInRect_angle_ 是在矩形里线性/径向渐变填充,它不关心你想要的视觉是什么,只在你给的 NSRect 里画,角度是渐变方向。光球效果必须先用圆形路径画,或者把渐变裁到圆里。想直接画出“球”是做不到的,圆必须靠路径裁出来。
这一步的实际教训:PyObjC 绘图 API 不像 CSS 那样“给个外观描述”,全是最底层的“往这个 rect 里画”。想画圆,得用 NSBezierPath 构造圆形路径再填充,“圆角/圆形”这种语义在 PyObjC 里全靠路径,没有任何自动处理。
坑三:线程安全——UI 调用必须在主线程
第二个方案:放弃 NSTimer,独立线程驱动动画(这是当时已经确认能跑的方向):
def _loop(self):
while self._alive:
if self._window.isVisible():
self._view.set_level(lv)
self._view.setNeedsDisplay_(True) # ← 这行有隐患
time.sleep(1 / 40)
线程里 setNeedsDisplay_,测试能跑,能画,看起来没问题。但这是 Cocoa 明确禁止的:NSView 的所有 UI 操作必须在主线程,后台线程调用在苹果文档和运行时断言里都有约定(_AssertAutoLayoutOnAllowedThreadsOnly 类错误就是它的变种)。
正确写法:线程只更新数据,把重绘派发到主队列:
def _loop(self):
while self._alive:
with self._lock:
lv = self._level
if self._window.isVisible():
self._view.level = lv
self._view.t = time.time() * 2.6
# Cocoa UI 必须在主线程 → 主队列派发
self._run_on_main(lambda: self._view.setNeedsDisplay_(True))
time.sleep(1 / 40)
def _run_on_main(self, fn):
from Foundation import NSOperationQueue
NSOperationQueue.mainQueue().addOperationWithBlock_(fn)
帧率再高,也不该从后台线程直接碰 UI。动画数据在后台线程算,用锁保护共享的 level 状态;UI 更新走主队列,这个拆分让“40fps 线程驱动 + UI 安全”同时成立。
最终结构(验证过的)
独立 daemon 线程(40fps) 主线程(runloop)
│ 读音量 level(锁保护) │
│ 算相位 t │ drawRect_ 每帧绘制
│ 更新 _view.level / _view.t │
└─ NSOperationQueue.mainQueue ──► setNeedsDisplay_ 派发
三个组件:
NSWindow:NSWindowStyleMaskBorderless(无边框)+setOpaque_(False)(透明)+setLevel_(NSStatusWindowLevel)(悬浮在所有窗口上)NSView子类:重写drawRect_,用NSBezierPath画圆角胶囊 + 15 根波形柱(中心高两侧低的衰减 + 相位错开的涌动)- 线程/队列:数据后台算、UI 主线程画
波形涌动公式(能看但调参余地大,直接贴出来):
wave = 0.45 + 0.55 * abs(
math.sin(t * 1.0 + i * 0.9) * 0.65 +
math.sin(t * 2.3 + i * 1.7) * 0.35)
amp = lv * falloff * wave
两个正弦叠加(不同频率)制造“快慢两层波动”的质感,falloff = 0.28 + 0.72 * cos(d * π / 2) 让中心高两端低。

怎么验证动画真的“在动”
窗口在屏但画面死水(坑一的记忆)说明目测不可靠。我的验证路径:
CGWindowListCopyWindowInfo查窗口在屏(拿窗口 ID、位置)- 线程日志:确认 tick 被持续调用 / 帧数递增
- 视觉:400ms 间隔翻两帧对比波形图
坑一那个 bug 恰好是第 1 步过了、第 2 步暴露的(窗口在,但 tick 一次没进),验证动画问题不能只看“窗口出来了没有”。
完整代码在 talk2clip/volume_bar.py。系列到这里:权限、模型、产品、UI 都有了,最后一篇已经写好:把 1000 行代码变成开源项目:git 历史 421MB 怎么清到 288KB、单实例保护踩的坑、README 双语,以及“多少行值得开源”这个问题的实际答案。
© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。