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

1 天前
 kfj92

静听 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 排查过很多次,也改过几轮。

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

每一种改法看起来都有理由。有些确实修掉了旁边的问题,但这个 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 。

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

xcrun devicectl list devices

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

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

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

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

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

先把整条状态链路串起来

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

日志覆盖了这些位置:

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

记录的内容包括:

appPlaying
enginePlaying
nodePlaying
engineRunning
playbackRate
playbackState
currentTime
isNowPlayingUpdatesSuspended

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

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

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

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

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

第二次构建通过。

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

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

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

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

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

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

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

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

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

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

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

暂停时,日志显示:

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

这几行说明:

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

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

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

AVAudioPlayerNode.isPlaying=false
AVAudioEngine.isRunning=true

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

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

原来的暂停逻辑大致是:

playerNode.pause()
_isPlaying = false

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

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

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

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

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

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

最终修改只有几行

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

playerNode.pause()

if avEngine.isRunning {
    avEngine.stop()
}

_isPlaying = false

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

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

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

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

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

我又做了一轮相同操作。

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

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

锁屏按钮同步了。

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

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

为什么以前一直没有解决

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

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

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

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

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

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

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

差别就在这里。

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

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

avEngine.stop()

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

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

整个过程是这样的:

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

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

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

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

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

1022 次点击
所在节点    程序员
4 条回复
elboble
23 小时 21 分钟前
经常使用,点个赞
mouyase
20 小时 13 分钟前
其实简单点说就是调试和真相链路没有打通。

如果能尽可能的让 AI 拿到日志,拿到真相,那就是很容可以排查到问题。
kfj92
5 小时 1 分钟前
@elboble 感谢
kfj92
5 小时 0 分钟前
@mouyase 是的,也算是经验总结

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1229207

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX