静听 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 。
第一步是检查当前有哪些设备:
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
接下来我只需要在手机上操作:
- 播放一首歌;
- 在 App 内点击暂停;
- 锁屏查看按钮;
- 返回 App 恢复播放;
- 再次查看锁屏。
Codex 在另一边持续读取真机输出。
这和以前把日志复制出来再发给 AI 分析不太一样。它自己构建、安装、启动、等待操作,然后继续读同一个进程的日志。发现问题后还能直接改代码,再走一遍相同流程。
第一轮真机日志排除了大部分怀疑对象
暂停时,日志显示:
appPlaying=false
enginePlaying=false
playbackRate=0
playbackState=paused
suspended=false
这几行说明:
- App 的业务状态已经是暂停;
- 播放器状态已经是暂停;
MPNowPlayingInfoPropertyPlaybackRate 成功写成了 0;
- 从
MPNowPlayingInfoCenter 立即读回来仍然是 paused;
- Now Playing 更新没有被中断恢复逻辑拦截。
也就是说,前面一直怀疑的几个地方其实都没错。
不是 App 按钮没更新,也不是通知没发送。远程命令已经注册,Now Playing 字典也写成功了,没有旧状态在后面覆盖新状态。
继续看底层日志,终于出现了第一个真正的分歧:
AVAudioPlayerNode.isPlaying=false
AVAudioEngine.isRunning=true
播放节点暂停了,但承载它的 AVAudioEngine 还在运行。
根因不在状态,而在音频图
原来的暂停逻辑大致是:
playerNode.pause()
_isPlaying = false
这段代码在 App 内看不出问题。声音停了,按钮变了,进度也不再前进。
但真机上的锁屏媒体状态不只参考 App 写入的 playbackRate。系统还会结合实际音频会话和播放图判断当前媒体是否活跃。
暂停之后,iOS 实际上收到了一组互相矛盾的信号:
App:已经暂停
Now Playing:已经暂停
PlayerNode:已经暂停
AVAudioEngine:仍在运行
于是锁屏界面继续保留旧的播放状态。
这也解释了为什么以前反复更新 playbackRate、playbackState 和 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 开始就存在的老问题,不用再往后拖了。