≡articles //snippets ./categories $uses ~about /search

用 PyObjC 画一个 40fps 的胶囊波形动画:NSTimer 里的三个坑

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)里,窗口出现,但画面死了,一动不动。

排查过程:

  1. 先怀疑 drawRect 写错 → 手动调一次 setNeedsDisplay_(True) 能画 → 绘制没问题
  2. 再以为 NSTimer 没启动 → 打印确认 timer 对象非空
  3. 加日志确认 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)

第一版截图出来:一个蓝色方块,左上角一个白色小方点。完全不是球的形状。

NSGradient drawInRect_angle 画出的是方块不是光球

原因: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) 让中心高两端低。

talk2clip 胶囊波形 40fps 动画最终效果

怎么验证动画真的“在动”

窗口在屏但画面死水(坑一的记忆)说明目测不可靠。我的验证路径:

  1. CGWindowListCopyWindowInfo 查窗口在屏(拿窗口 ID、位置)
  2. 线程日志:确认 tick 被持续调用 / 帧数递增
  3. 视觉:400ms 间隔翻两帧对比波形图

坑一那个 bug 恰好是第 1 步过了、第 2 步暴露的(窗口在,但 tick 一次没进),验证动画问题不能只看“窗口出来了没有”。


完整代码在 talk2clip/volume_bar.py。系列到这里:权限、模型、产品、UI 都有了,最后一篇已经写好:把 1000 行代码变成开源项目:git 历史 421MB 怎么清到 288KB、单实例保护踩的坑、README 双语,以及“多少行值得开源”这个问题的实际答案。

© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。

独立开发者,在 WordPress、Nginx 和各种 API 之间切换。不写没用的东西。

相关文章

macOS 15 辅助功能授权添加了却不生效?一次排查记录5 min国内下载 AI 模型太难了?一条实测可行的完整方案(HuggingFace 篇)7 min把 1000 行代码变成开源项目:git 历史清理、单实例保护和“多少行值得开源”5 minUnity 数据持久化完全指南:从 PlayerPrefs 到 SQLite18 min
$ echo "less bullshit, more debugging" | send-to-inbox
新文章直接发到邮箱,一个月 2-4 封。
订阅