静听一个从 1.x.x 拖到 2.2.1 的锁屏 Bug,最后是怎么被 GPT-5.6 Sol 定位的

发布于

静听 2.2.0 已经在 7 月 18 日,也就是上周六上传到 App Store Connect。

以前提交新版本,通常当天晚上就会进入审核,快的时候很快就能知道结果。这一次有点反常。截至 7 月 23 日,状态仍然是等待审核,看起来还得继续等。

审核在排队,开发不能停。2.2.1 补丁版本已经做得差不多了,主要处理 2.2.0 测试阶段和用户反馈中发现的问题,包括逐字歌词、歌曲时长、切歌卡顿、音频打断恢复,以及锁屏和 App 内播放状态不同步。

最后这个问题最难处理。

它不是 2.2.0 新出现的。回头翻以前的代码和测试记录,静听从 1.x.x 开始就存在这个问题:

在 App 内点击播放或暂停,声音和 App 里的按钮已经变化,但锁屏界面的播放状态没有同步。

这个 Bug 不会导致崩溃,歌曲也能正常播放。锁屏控制、切歌和后台播放大多数时候都没问题,所以它一直夹在其他需求中间,没有被彻底解决。

## Cursor 和 Antigravity 都处理过,但一直没修干净

以前我用 Cursor 和 Antigravity 排查过很多次,也改过几轮。

当时主要怀疑的是播放状态和系统媒体中心之间存在竞态,因此尝试过不少办法:

- 统一 `isPlaying` 的状态来源;
- 更新 `MPNowPlayingInfoPropertyPlaybackRate`;
- 设置 `MPNowPlayingInfoCenter.playbackState`;
- 调整 `playCommand`、`pauseCommand` 和 `togglePlayPauseCommand`;
- App 回到前台时重新发布播放状态;
- 播放真正开始后补发一次 Now Playing;
- 避免频繁重建包含封面的完整元数据。

每一种改法看起来都有理由。有些确实修掉了旁边的问题,但这个 Bug 一直还在。

到了 2.2.1,我又处理了微信语音、语音转文字和抖音打断后的自动恢复。打断恢复终于正常了,锁屏同步的问题却变得更明显:

从锁屏操作,App 可以跟着变化;从 App 内操作,锁屏按钮还是停留在旧状态。

2.2.0 还在等待审核,2.2.1 已经接近收尾。我不想再把这个从 1.x.x 留下来的问题继续带到下一个版本,于是改用 Codex 的 GPT-5.6 Sol,并把推理强度调到极高,重新排查。

## GPT-5.6 Sol 也没有一次猜中

一开始,它同样从常见方向入手:状态竞态、远程命令配置、Now Playing 刷新、MediaRemote 限流。

代码改了几轮,工程都能正常编译,但真机测试仍然失败。

有一次它认为问题可能是 App 同时使用了业务状态和底层引擎状态,导致两个状态源互相覆盖。统一状态后,没解决。

后来又怀疑 play、pause 命令互斥启用会让锁屏保留旧状态。调整后,还是没解决。

再后来,它把完整的 Now Playing 重建改成只更新动态字段,避免封面和元数据更新被系统限流。逻辑更干净了,锁屏按钮仍然不同步。

这段过程反而说明了一件事:模型再强,只看代码也可能在错误的层级里打转。

真正让排查发生变化的,是它停止继续猜,提出直接连接真机,给整条播放链路加日志。

## Codex 是怎么接管真机调试的

Codex 和项目在同一台 Mac 上运行,所以它可以操作本地工程、Xcode 命令行工具和已连接的 iPhone。

第一步是检查当前有哪些设备:

```bash
xcrun devicectl list devices
```

它检测到一台已连接的 iPhone 12,状态是 `connected`。

接着查询 `LightMP3iOS` scheme 支持的运行目标:

```bash
xcodebuild \
-workspace LightMP3App/LightMP3App.xcworkspace \
-scheme LightMP3iOS \
-showdestinations
```

输出中包含这台 iPhone,工程里的开发团队、Bundle ID 和自动签名配置也都可用。

设备和签名没问题,接下来就是加日志。

## 先把整条状态链路串起来

这次没有只在 `MPNowPlayingInfoCenter` 附近打印两行,而是给整条播放链路统一加上 `[NowPlayingSync]` 前缀。

日志覆盖了这些位置:

```text
App 点击播放或暂停
→ MusicPlayerManager 接收操作
→ 业务播放状态变化
→ 底层 PlayerNode 状态
→ AVAudioEngine 状态
→ Now Playing 写入
→ MPNowPlayingInfoCenter 立即读回
→ App UI 发布状态
```

记录的内容包括:

```text
appPlaying
enginePlaying
nodePlaying
engineRunning
playbackRate
playbackState
currentTime
isNowPlayingUpdatesSuspended
```

这样做的目的很简单:不再问“哪里可能有问题”,而是找出状态第一次出现分歧的位置。

日志加完后,Codex 直接构建真机 Debug 包:

```bash
xcodebuild build \
-workspace LightMP3App/LightMP3App.xcworkspace \
-scheme LightMP3iOS \
-configuration Debug \
-destination 'platform=iOS,id=<device-id>' \
-allowProvisioningUpdates
```

第一次真机构建并没有成功。

诊断日志有一处插错了方法,三个变量不在当前作用域,Swift 编译器直接报错。Codex 根据错误文件和行号找到位置,修正后重新构建。

第二次构建通过。

## 安装到真机,并直接读取控制台

构建产物位于 Xcode 的 DerivedData 目录。Codex 找到 `.app` 后,通过 `devicectl` 安装到手机:

```bash
xcrun devicectl device install app \
--device <device-id> \
<DerivedData>/Build/Products/Debug-iphoneos/LightMP3iOS.app
```

因为 Bundle ID 不变,这次安装保留了原来的 Realm 数据和音乐库,不需要重新准备测试环境。

安装完成后,它直接启动静听并接管实时控制台:

```bash
xcrun devicectl device process launch \
--device <device-id> \
--terminate-existing \
--console \
com.mou.lightmusic
```

接下来我只需要在手机上操作:

1. 播放一首歌;
2. 在 App 内点击暂停;
3. 锁屏查看按钮;
4. 返回 App 恢复播放;
5. 再次查看锁屏。

Codex 在另一边持续读取真机输出。

这和以前把日志复制出来再发给 AI 分析不太一样。它自己构建、安装、启动、等待操作,然后继续读同一个进程的日志。发现问题后还能直接改代码,再走一遍相同流程。

## 第一轮真机日志排除了大部分怀疑对象

暂停时,日志显示:

```text
appPlaying=false
enginePlaying=false
playbackRate=0
playbackState=paused
suspended=false
```

这几行说明:

- App 的业务状态已经是暂停;
- 播放器状态已经是暂停;
- `MPNowPlayingInfoPropertyPlaybackRate` 成功写成了 `0`;
- 从 `MPNowPlayingInfoCenter` 立即读回来仍然是 `paused`;
- Now Playing 更新没有被中断恢复逻辑拦截。

也就是说,前面一直怀疑的几个地方其实都没错。

不是 App 按钮没更新,也不是通知没发送。远程命令已经注册,Now Playing 字典也写成功了,没有旧状态在后面覆盖新状态。

继续看底层日志,终于出现了第一个真正的分歧:

```text
AVAudioPlayerNode.isPlaying=false
AVAudioEngine.isRunning=true
```

播放节点暂停了,但承载它的 `AVAudioEngine` 还在运行。

## 根因不在状态,而在音频图

原来的暂停逻辑大致是:

```swift
playerNode.pause()
_isPlaying = false
```

这段代码在 App 内看不出问题。声音停了,按钮变了,进度也不再前进。

但真机上的锁屏媒体状态不只参考 App 写入的 `playbackRate`。系统还会结合实际音频会话和播放图判断当前媒体是否活跃。

暂停之后,iOS 实际上收到了一组互相矛盾的信号:

```text
App:已经暂停
Now Playing:已经暂停
PlayerNode:已经暂停
AVAudioEngine:仍在运行
```

于是锁屏界面继续保留旧的播放状态。

这也解释了为什么以前反复更新 `playbackRate`、`playbackState` 和 Now Playing 元数据都没有效果。改动一直发生在状态表现层,真正的问题却在更下面。

## 最终修改只有几行

本地原生音频暂停时,在暂停节点后同时停止 Engine:

```swift
playerNode.pause()

if avEngine.isRunning {
avEngine.stop()
}

_isPlaying = false
```

这里不能直接销毁整个播放链。

`AVAudioSession` 需要继续保留,否则锁屏发出的“继续播放”命令可能无法回到静听。`AVAudioPlayerNode` 已有的 schedule 也不能清除,否则恢复播放会退化成重新打开文件,可能重新带来延迟和卡顿。

恢复播放时先启动 Engine,再从原来的调度继续:

```swift
确认 AVAudioSession
→ ensureEngineRunning()
→ playerNode.play()
→ 发布 playing 状态
→ 更新 Now Playing
```

修改完成后,Codex 重新构建、安装并启动真机控制台。

我又做了一轮相同操作。

这一次,暂停日志变成了:

```text
appPlaying=false
enginePlaying=false
nodePlaying=false
engineRunning=false
readbackRate=0
readbackState=paused
```

锁屏按钮同步了。

恢复播放时,`AVAudioEngine` 重新启动,歌曲从暂停位置继续,没有重新打开文件。微信语音、语音转文字和抖音打断后的恢复逻辑也没有被破坏。

这个从静听 1.x.x 留到 2.2.x 的问题,终于准备在 2.2.1 里修掉。

## 为什么以前一直没有解决

回头看,Cursor 和 Antigravity 当时给出的很多方向并没有错。

看到“锁屏播放状态不同步”,正常都会先检查:

```text
isPlaying
playbackRate
playbackState
MPRemoteCommandCenter
通知时序
主线程写入
```

这些都属于常规排查范围。

问题在于,这次真正的原因无法单靠静态代码阅读确认。必须在真机上同时观察 App 状态、PlayerNode、AVAudioEngine 和 MediaPlayer,才会看到那个 `engineRunning=true`。

模拟器也不能替代这一步。模拟器更多依赖 Now Playing 中的播放速率,而真机会结合实际音频会话和播放状态决定锁屏控制的显示。

以前的排查停留在“代码看起来哪里不对”。这次变成了“运行时第一个错误状态出现在哪里”。

差别就在这里。

## GPT-5.6 Sol 真正帮到我的是什么

如果只看最后的代码改动,这个问题似乎很简单:

```swift
avEngine.stop()
```

但真正花时间的从来不是写出这一行,而是证明应该在这里停。

GPT-5.6 Sol 也没有一开始就知道答案。前面几轮修改同样没有解决问题。它的优势出现在后半段:发现静态分析无法继续推进后,开始主动使用完整的本地开发环境。

整个过程是这样的:

```text
阅读工程
→ 修改代码
→ 编译
→ 根据编译错误修正
→ 检测已连接设备
→ 构建签名真机包
→ 安装并启动 App
→ 实时读取控制台
→ 等待我执行复现操作
→ 找到第一个状态分歧
→ 修改底层原因
→ 再次构建并真机验证
```

以前我更多把 AI 编程工具当成代码补全和问答工具。这次更像是旁边坐着一个能操作终端、Xcode 和真机调试链路的人。

当然,它仍然需要我在手机上点击按钮、确认锁屏到底显示了什么。AI 没法替代最后那一步人工观察。但编译、安装、抓日志和比对状态这些重复工作,它确实接过去了。

2.2.0 还在等待审核,可能还要继续等。2.2.1 已经快做完了。

至少这个从 1.x.x 开始就存在的老问题,不用再往后拖了。

---

原文链接:[点击查看](https://www.v2ex.com/t/1229207)

评论

暂无评论。