关于 axiom-now-playing
面向 iOS 开发者的 Now Playing 实现与诊断技能,覆盖音频会话资格、远程命令、封面和播放状态更新,并介绍 MPNowPlayingSession 的用法。
主要功能
- 01AVAudioSession 配置与激活排查
- 02远程命令注册与启用
- 03专辑封面配置与竞态处理
- 04播放状态和进度同步
- 05MPNowPlayingSession 使用模式
适用场景
- 01排查锁屏未显示播放信息
- 02修复控制中心按钮无响应
- 03诊断封面缺失或闪烁
- 04同步暂停、播放和进度状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-now-playing安装后在 Claude Code 中输入/axiom-now-playing确认已加载。
详细介绍
正在播放集成指南
目的:避免 iOS 18+ 上最常见的 4 个“正在播放”问题:信息不显示、命令不起作用、封面问题和状态同步问题
Swift 版本:Swift 6.0+ iOS 版本:iOS 18+ Xcode:Xcode 16+
核心理念
“要符合‘正在播放’显示条件,必须同时满足三项:激活 AVAudioSession、处理远程命令以及发布元数据。其中任何一项缺失,都会导致整个系统悄无声息地失效。90% 的‘正在播放’问题源于激活顺序错误或缺少命令处理程序,而非 API 缺陷。”
WWDC 2022/110338 的关键见解:应用必须满足两项系统启发式要求:
- 为至少一个远程命令注册处理程序
- 将 AVAudioSession 配置为不可混音类别
何时使用此技能
✅ 在以下情况下使用此技能:
- “正在播放”信息未显示在锁定屏幕或控制中心
- 播放/暂停/跳过按钮呈灰色或没有响应
- 专辑封面缺失、错误,或在不同图片间闪烁
- 应用已暂停时,控制中心却显示“正在播放”,反之亦然
- Apple Music 或其他应用“抢占”了“正在播放”状态
- 首次实现“正在播放”功能
- 调试现有实现中的“正在播放”问题
- 集成 CarPlay“正在播放”功能(详见模式 6)
- 使用 MusicKit/Apple Music 内容(详见模式 7)
iOS 26 说明
iOS 26 为锁定屏幕和控制中心的“正在播放”小组件引入了Liquid Glass 视觉设计。这是系统自动行为,无需更改代码。此技能中的模式仍适用于 iOS 26。
❌ 请勿在以下情况下使用此技能:
- 配置后台音频的详细信息(请参阅 AVFoundation 技能)
相关技能
- swift-concurrency - 用于 @MainActor 模式、闭包中的 weak self、异步加载封面
- memory-debugging - 用于命令处理程序中的循环引用
- avfoundation-ref - 用于 AVAudioSession 配置的详细信息
危险信号 / 反模式
如果发现以下任意情况,应怀疑“正在播放”配置不正确:
- 信息短暂出现后消失(AVAudioSession 已停用)
- 命令在模拟器上有效,但在设备上无效(模拟器使用不同的音频栈)
- 封面先显示占位图,然后更新(竞态条件,不一定是错误)
- 封面始终不显示(格式/尺寸问题,或 MPMediaItemArtwork 块返回 nil)
- 进入后台后播放/暂停状态不正确(播放速率变化时未更新)
- 其他应用“抢占”了“正在播放”(未满足资格要求)
playbackState属性不更新(iOS 没有playbackState,仅 macOS 有!)
禁止的假设:
- “只要设置 nowPlayingInfo 就能正常工作”——还必须配置 AVAudioSession 并添加命令处理程序
- “playbackState 控制控制中心”——iOS 会忽略 playbackState,而是使用 playbackRate
- “有图片就能用作封面”——必须使用带尺寸处理程序的 MPMediaItemArtwork
- “命令会自动启用”——必须添加目标并设置 isEnabled = true
- “每秒更新一次已播放时间”——系统会根据速率推断,频繁更新会造成抖动
必须先执行的步骤(诊断前)
运行以下代码,先了解当前状态再进行调试:
// 1. Verify AVAudioSession configuration
let session = AVAudioSession.sharedInstance()
print("Category: \(session.category.rawValue)")
print("Mode: \(session.mode.rawValue)")
print("Options: \(session.categoryOptions)")
print("Is active: \(try? session.setActive(true))")
// Must be: .playback category, NOT .mixWithOthers option
// 2. Verify background mode
// Info.plist must have: UIBackgroundModes = ["audio"]
// 3. Check command handlers are registered
let commandCenter = MPRemoteCommandCenter.shared()
print("Play enabled: \(commandCenter.playCommand.isEnabled)")
print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)")
// Must have at least one command with target AND isEnabled = true
// 4. Check nowPlayingInfo dictionary
if let info = MPNowPlayingInfoCenter.default().nowPlayingInfo {
print("Title: \(info[MPMediaItemPropertyTitle] ?? "nil")")
print("Artwork: \(info[MPMediaItemPropertyArtwork] != nil)")
print("Duration: \(info[MPMediaItemPropertyPlaybackDuration] ?? "nil")")
print("Elapsed: \(info[MPNowPlayingInfoPropertyElapsedPlaybackTime] ?? "nil")")
print("Rate: \(info[MPNowPlayingInfoPropertyPlaybackRate] ?? "nil")")
} else {
print("No nowPlayingInfo set!")
}
此代码可提供以下信息:
| 观察结果 | 诊断 | 模式 |
|---|---|---|
| 类别为 .ambient 或包含 .mixWithOthers | 无法成为“正在播放”应用 | 模式 1 |
| 没有命令设置目标 | 系统会忽略该应用 | 模式 2 |
| 命令已设置目标,但 isEnabled = false | 界面控件呈灰色 | 模式 2 |
| 封面为 nil | MPMediaItemArtwork 块返回 nil | 模式 3 |
| 播放时 playbackRate 为 0.0 | 控制中心会显示暂停 | 模式 4 |
| Info.plist 中没有“audio”后台模式 | 锁定屏幕后信息会消失 | 模式 1 |
决策树
Now Playing not working?
├─ Info never appears at all?
│ ├─ AVAudioSession category .ambient or .mixWithOthers?
│ │ └─ Pattern 1a (Wrong Category)
│ ├─ No remote command handlers registered?
│ │ └─ Pattern 2a (Missing Handlers)
│ ├─ Background mode "audio" not in Info.plist?
│ │ └─ Pattern 1b (Background Mode)
│ └─ AVAudioSession.setActive(true) never called?
│ └─ Pattern 1c (Not Activated)
│
├─ Info appears briefly, then disappears?
│ ├─ On lock screen specifically?
│ │ ├─ AVAudioSession deactivated too early?
│ │ │ └─ Pattern 1d (Early Deactivation)
│ │ └─ App suspended (no background mode)?
│ │ └─ Pattern 1b (Background Mode)
│ └─ When switching apps?
│ └─ Another app claiming Now Playing → Pattern 5
│
├─ Commands not responding?
│ ├─ Buttons grayed out (disabled)?
│ │ └─ command.isEnabled = false → Pattern 2b
│ ├─ Buttons visible but no response?
│ │ ├─ Handler not returning .success?
│ │ │ └─ Pattern 2c (Handler Return)
│ │ └─ Using wrong command center (session vs shared)?
│ │ └─ Pattern 2d (Command Center)
│ └─ Skip forward/backward not showing?
│ └─ preferredIntervals not set → Pattern 2e
│
├─ Artwork problems?
│ ├─ Never appears?
│ │ ├─ MPMediaItemArtwork block returning nil?
│ │ │ └─ Pattern 3a (Artwork Block)
│ │ └─ Image format/size invalid?
│ │ └─ Pattern 3b (Image Format)
│ ├─ Wrong artwork showing?
│ │ └─ Race condition between sources → Pattern 3c
│ └─ Artwork flickering?
│ └─ Multiple updates in rapid succession → Pattern 3d
│
├─ State sync issues?
│ ├─ Shows "Playing" when paused?
│ │ └─ playbackRate not updated → Pattern 4a
│ ├─ Progress bar stuck or jumping?
│ │ └─ elapsedTime not updated at right moments → Pattern 4b
│ └─ Duration wrong?
│ └─ Not setting playbackDuration → Pattern 4c
│
├─ CarPlay specific issues?
│ ├─ App doesn't appear in CarPlay at all?
│ │ └─ Missing entitlement → Pattern 6 (Add com.apple.developer.carplay-audio)
│ ├─ Now Playing blank in CarPlay but works on iOS?
│ │ └─ Same root cause as iOS → Check Patterns 1-4
│ ├─ Custom buttons don't appear in CarPlay?
│ │ └─ Wrong configuration timing → Pattern 6 (Configure at templateApplicationScene)
│ └─ Works on device but not CarPlay simulator?
│ └─ Debugger interference → Pattern 6 (Run without debugger)
│
└─ Using MusicKit (ApplicationMusicPlayer)?
├─ Now Playing shows wrong info?
│ └─ Overwriting automatic data → Pattern 7 (Don't set nowPlayingInfo manually)
└─ Mixing MusicKit + own content?
└─ Hybrid approach needed → Pattern 7 (Switch between players)
模式 1:AVAudioSession 配置(信息未显示)
耗时:10-15 分钟
症状
- 锁定屏幕上始终不显示「正在播放」信息
- 锁定屏幕时信息短暂出现,然后消失
- 在前台运行正常,在后台运行时消失
错误代码
// ❌ WRONG — Category allows mixing, won't become Now Playing app
class PlayerService {
func setupAudioSession() throws {
try AVAudioSession.sharedInstance().setCategory(
.playback,
options: .mixWithOthers // ❌ Mixable = not eligible for Now Playing
)
// Never called setActive() // ❌ Session not activated
}
func play() {
player.play()
updateNowPlaying() // ❌ Won't appear - session not active
}
}
正确代码
// ✅ CORRECT — Non-mixable category, activated before playback
class PlayerService {
func setupAudioSession() throws {
try AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .default,
options: [] // ✅ No .mixWithOthers = eligible for Now Playing
)
}
func play() async throws {
// ✅ Activate BEFORE starting playback
try AVAudioSession.sharedInstance().setActive(true)
player.play()
updateNowPlaying() // ✅ Now appears correctly
}
func stop() async throws {
player.pause()
// ✅ Deactivate AFTER stopping, with notify option
try AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
}
}
Info.plist 要求
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
验证
- 锁定屏幕上显示「正在播放」控制项
- 应用进入后台后,信息仍会保留
- 切换应用后仍会保留(除非其他应用正在播放)
模式 2:远程命令注册(命令无法使用)
耗时:15-20 分钟
症状
- 播放/暂停按钮显示为灰色
- 按钮可见,但轻点后没有任何反应
- 未显示跳过按钮
- 命令生效一次后就失效
错误代码
// ❌ WRONG — Missing targets and isEnabled
class PlayerService {
func setupCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// ❌ Added target but forgot isEnabled
commandCenter.playCommand.addTarget { _ in
self.player.play()
return .success
}
// playCommand.isEnabled defaults to false!
// ❌ Never added pause handler
// ❌ skipForward without preferredIntervals
commandCenter.skipForwardCommand.addTarget { _ in
return .success
}
}
}
正确代码
// ✅ CORRECT — Targets registered, enabled, with proper configuration
@MainActor
class PlayerService {
private var commandTargets: [Any] = [] // Keep strong references
func setupCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// ✅ Play command - add target AND enable
let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
self?.player.play()
self?.updateNowPlayingPlaybackState(isPlaying: true)
return .success
}
commandCenter.playCommand.isEnabled = true
commandTargets.append(playTarget)
// ✅ Pause command
let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.player.pause()
self?.updateNowPlayingPlaybackState(isPlaying: false)
return .success
}
commandCenter.pauseCommand.isEnabled = true
commandTargets.append(pauseTarget)
// ✅ Skip forward - set preferredIntervals BEFORE adding target
commandCenter.skipForwardCommand.preferredIntervals = [15.0]
let skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
self?.skip(by: skipEvent.interval)
return .success
}
commandCenter.skipForwardCommand.isEnabled = true
commandTargets.append(skipForwardTarget)
// ✅ Skip backward
commandCenter.skipBackwardCommand.preferredIntervals = [15.0]
let skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
self?.skip(by: -skipEvent.interval)
return .success
}
commandCenter.skipBackwardCommand.isEnabled = true
commandTargets.append(skipBackwardTarget)
}
func teardownCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.removeTarget(nil)
commandTargets.removeAll()
}
deinit {
teardownCommands()
}
}
验证
- 控制中心中的按钮未显示为灰色
- 轻点播放/暂停按钮后,确实会播放/暂停
- 显示跳过按钮,且间隔正确(15 秒)
模式 3:封面配置(封面问题)
耗时:15-25 分钟
症状
- 始终不显示封面(显示通用占位图)
- 当前曲目显示了错误的封面
- 封面在不同图片之间闪烁
- 封面出现后消失
错误代码
// ❌ WRONG — MPMediaItemArtwork block can return nil, no size handling
func updateNowPlaying() {
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
// ❌ Storing UIImage directly (doesn't work)
nowPlayingInfo[MPMediaItemPropertyArtwork] = image
// ❌ Or: Block that ignores requested size
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in
return self.cachedImage // ❌ May be nil, ignores requested size
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ❌ WRONG — Multiple rapid updates cause flickering
func loadArtwork(from url: URL) {
// Request 1
loadImage(url) { image in
self.updateNowPlayingArtwork(image) // Update 1
}
// Request 2 (cached) returns faster
loadCachedImage(url) { image in
self.updateNowPlayingArtwork(image) // Update 2 - flicker!
}
}
正确代码
// ✅ CORRECT — Proper MPMediaItemArtwork with value capture (Swift 6 compliant)
@MainActor
class NowPlayingService {
private var currentArtworkURL: URL?
func updateNowPlayingArtwork(_ image: UIImage, for trackURL: URL) {
// ✅ Prevent race conditions - only update if still current track
guard trackURL == currentArtworkURL else { return }
// ✅ Create MPMediaItemArtwork with VALUE CAPTURE (not stored property)
// This is Swift 6 strict concurrency compliant — UIImage is immutable
// and safe to capture across isolation domains
let artwork = MPMediaItemArtwork(boundsSize: image.size) { [image] requestedSize in
// ✅ System calls this block from any thread
// Captured value avoids "Main actor-isolated property" error
return image
}
// ✅ Update only artwork key, preserve other values
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Single entry point with priority: embedded > cached > remote
func loadArtwork(for track: Track) async {
currentArtworkURL = track.artworkURL
// Priority 1: Embedded in file (immediate, no flicker)
if let embedded = await extractEmbeddedArtwork(track.fileURL) {
updateNowPlayingArtwork(embedded, for: track.artworkURL)
return
}
// Priority 2: Already cached (fast)
if let cached = await loadFromCache(track.artworkURL) {
updateNowPlayingArtwork(cached, for: track.artworkURL)
return
}
// Priority 3: Remote (slow, but don't flicker)
// ✅ Set placeholder first, then update once with real image
if let remote = await downloadImage(track.artworkURL) {
updateNowPlayingArtwork(remote, for: track.artworkURL)
}
}
}
为什么要捕获值,而不是使用 nonisolated(unsafe):传递给 MPMediaItemArtwork 的闭包可能会由系统在任意线程上调用。在 Swift 6 严格并发模式下,从此闭包访问隔离于 @MainActor 的存储属性会导致编译错误。直接捕获图像值比使用 nonisolated(unsafe) 更简洁,因为 UIImage 不可变,且可安全地进行读取。
封面尺寸指南
- 锁定屏幕:300x300 点(600x600 @2x,900x900 @3x)
- 控制中心:尺寸不一
- 最佳实践:提供至少 600x600 像素的图片
验证
- 锁定屏幕上显示封面
- 当前曲目显示正确的封面
- 曲目切换时不闪烁
- 应用进入后台后,封面仍会保留
模式 4:播放状态同步(状态同步问题)
耗时:10-20 分钟
症状
- 实际已暂停,但控制中心显示「正在播放」
- 进度条不移动或意外跳动
- 时长显示错误
- 拖动进度条无法正常工作
错误代码
// ❌ WRONG — Using playbackState (macOS only, ignored on iOS)
func updatePlaybackState(isPlaying: Bool) {
MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
// ❌ iOS ignores this property! Only macOS uses it.
}
// ❌ WRONG — Updating elapsed time on a timer (causes drift)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentTime().seconds
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// ❌ Every second creates jitter, system already infers from timestamp
}
// ❌ WRONG — Partial dictionary updates cause race conditions
func updateTitle() {
var info = [String: Any]()
info[MPMediaItemPropertyTitle] = track.title
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// ❌ Cleared all other values (artwork, duration, etc.)!
}
正确代码
// ✅ CORRECT — Use playbackRate for iOS, update at key moments only
@MainActor
class NowPlayingService {
// ✅ Update when playback STARTS
func playbackStarted(track: Track, player: AVPlayer) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
// ✅ Core metadata
nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.album
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
// ✅ Playback state via RATE (not playbackState property)
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 // Playing
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Update when playback PAUSES
func playbackPaused(player: AVPlayer) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
// ✅ Update elapsed time AND rate together
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 // Paused
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Update when user SEEKS
func userSeeked(to time: CMTime, player: AVPlayer) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time.seconds
// ✅ Keep current rate (don't change playing/paused state)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Update when track CHANGES
func trackChanged(to newTrack: Track, player: AVPlayer) {
// ✅ Full refresh of all metadata
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = newTrack.title
nowPlayingInfo[MPMediaItemPropertyArtist] = newTrack.artist
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = newTrack.album
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
// Then load artwork asynchronously
Task {
await loadArtwork(for: newTrack)
}
}
}
何时更新「正在播放」信息
| 事件 | 更新内容 |
|---|---|
| 开始播放 | 所有元数据 + elapsed=current + rate=1.0 |
| 暂停播放 | elapsed=current + rate=0.0 |
| 用户拖动进度条 | elapsed=newPosition(保持 rate 不变) |
| 曲目切换 | 所有元数据(新曲目) |
| 播放速率变化(2x、0.5x) | rate=newRate |
不要更新
- 在计时器触发时更新(系统会根据 elapsed、rate 和 timestamp 推算)
- 持续更新经过时间(会导致抖动)
- 部分字典(会丢失其他值)
模式 5:MPNowPlayingSession(iOS 16+ 推荐方案)
耗时:20-30 分钟
何时使用 MPNowPlayingSession
- 使用 iOS 16+(自 iOS 16 起提供,此前仅限 tvOS)
- 使用 AVPlayer 播放
- 希望自动发布播放状态
- 使用多个播放器(画中画场景)
错误代码(手动方案——更容易出错)
// ❌ Manual updates are error-prone, easy to miss state changes
class OldStylePlayer {
func play() {
player.play()
// Must remember to:
updateNowPlayingElapsed()
updateNowPlayingRate()
// Easy to forget one...
}
}
正确代码(MPNowPlayingSession)
// ✅ CORRECT — MPNowPlayingSession handles automatic publishing
@MainActor
class ModernPlayerService {
private var player: AVPlayer
private var session: MPNowPlayingSession?
init() {
player = AVPlayer()
setupSession()
}
func setupSession() {
// ✅ Create session with player
session = MPNowPlayingSession(players: [player])
// ✅ Enable automatic publishing of:
// - Duration
// - Elapsed time
// - Playback state (rate)
// - Playback progress
session?.automaticallyPublishNowPlayingInfo = true
// ✅ Register commands on SESSION's command center (not shared)
session?.remoteCommandCenter.playCommand.addTarget { [weak self] _ in
self?.player.play()
return .success
}
session?.remoteCommandCenter.playCommand.isEnabled = true
session?.remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in
self?.player.pause()
return .success
}
session?.remoteCommandCenter.pauseCommand.isEnabled = true
// ✅ Try to become active Now Playing session
session?.becomeActiveIfPossible { success in
print("Became active Now Playing: \(success)")
}
}
func play(track: Track) async {
let item = AVPlayerItem(url: track.url)
// ✅ Set static metadata on player item (title, artwork)
item.nowPlayingInfo = [
MPMediaItemPropertyTitle: track.title,
MPMediaItemPropertyArtist: track.artist,
MPMediaItemPropertyArtwork: await createArtwork(for: track)
]
player.replaceCurrentItem(with: item)
player.play()
// ✅ No need to manually update elapsed time, rate, duration
// MPNowPlayingSession publishes automatically!
}
}
多个会话(画中画)
class MultiPlayerService {
var mainSession: MPNowPlayingSession
var pipSession: MPNowPlayingSession
func pipDidExpand() {
// ✅ Promote PiP session when it expands to full screen
pipSession.becomeActiveIfPossible { success in
// PiP now controls Lock Screen, Control Center
}
}
func pipDidMinimize() {
// ✅ Demote back to main session
mainSession.becomeActiveIfPossible { success in
// Main player now controls Lock Screen, Control Center
}
}
}
关键注意事项
使用 MPNowPlayingSession 时:使用 session.remoteCommandCenter,不要使用 MPRemoteCommandCenter.shared()
// ❌ WRONG
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }
// ✅ CORRECT
session.remoteCommandCenter.playCommand.addTarget { _ in }
模式 6:CarPlay 集成
如需了解 CarPlay 专属集成模式,请调用 /skill axiom-now-playing-carplay。
关键要点:CarPlay 使用的 MPNowPlayingInfoCenter 和 MPRemoteCommandCenter 与 iOS 完全相同。如果「正在播放」功能在 iOS 上正常工作,那么在 CarPlay 中也能正常工作,无需添加任何代码。
模式 7:MusicKit 集成(Apple Music)
如需了解 MusicKit 专属集成模式和混合应用示例,请调用 /skill axiom-now-playing-musickit。
关键要点:MusicKit 的 ApplicationMusicPlayer 会自动发布到 MPNowPlayingInfoCenter。播放 Apple Music 内容时,无需手动更新“正在播放”信息。
压力场景
场景 1:Apple Music 不断抢占控制权(距离上线还有 24 小时)
情况
- 应用明天上线
- QA 报告:“‘正在播放’功能正常,但用户打开 Apple Music 再返回我们的应用时,控制项就消失了”
- 产品经理:“这是个阻碍,上线后用户会觉得我们的应用坏了”
- 距离代码冻结还有 2 小时
合理化陷阱(请勿这样做)
- “就告诉用户别用 Apple Music”——用户体验不可接受,会收到 1 星评价
- “强制我们的应用始终显示为‘正在播放’”——不可能,系统会决定哪些应用符合控制资格
- “向 Apple 提交 Bug”——赶不上上线前解决
根本原因
你的应用失去了资格,原因如下:
- 使用了
.mixWithOthers选项(允许其他应用同时播放) - 返回前台时未调用
becomeActiveIfPossible() - 应用在后台时,AVAudioSession 已停用
系统性修复(30 分钟)
// 1. Remove mixWithOthers
try AVAudioSession.sharedInstance().setCategory(.playback, options: [])
// 2. Reactivate when returning to foreground
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard self?.isPlaying == true else { return }
do {
try AVAudioSession.sharedInstance().setActive(true)
self?.session?.becomeActiveIfPossible { _ in }
} catch {
print("Failed to reactivate audio session: \(error)")
}
}
// 3. Handle interruptions (phone call, Siri)
NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
if type == .ended {
// ✅ Reactivate after interruption
try? AVAudioSession.sharedInstance().setActive(true)
self?.session?.becomeActiveIfPossible { _ in }
}
}
沟通模板
To PM: Found root cause - our audio session config allowed Apple Music to take over.
Fix implemented: 3 changes to audio session handling.
Testing: Verified fix with Apple Music, Spotify, phone calls.
ETA: 20 more minutes for full regression test.
To QA: Please test this flow:
1. Play audio in our app
2. Open Apple Music, play a song
3. Return to our app, tap play
4. Lock screen should show OUR controls
节省时间
- 减少 2-3 小时的猜测性调试
- 避免延迟上线
- 恢复 QA 的信心
场景 2:每次切换曲目时专辑封面闪烁
情况
- 用户反馈:“歌曲切换时专辑封面一直闪”
- 分析数据显示,每次切换曲目都会更新专辑封面 3-4 次
- 设计师:“看起来很不专业”
根本原因
多个专辑封面来源同时争抢:
- 缓存检查(异步)
- 远程 URL 获取(异步)
- 内嵌专辑封面提取(异步)
三者完成时间各不相同,都会更新“正在播放”信息
修复(20 分钟)
// ✅ Single-source-of-truth with cancellation
private var artworkTask: Task<Void, Never>?
func loadArtwork(for track: Track) {
// Cancel previous artwork load
artworkTask?.cancel()
artworkTask = Task { @MainActor in
// Clear previous artwork immediately (optional)
// updateNowPlayingArtwork(nil)
// Wait for best available artwork
let artwork = await loadBestArtwork(for: track)
// Check if still current track
guard !Task.isCancelled else { return }
// Single update
updateNowPlayingArtwork(artwork, for: track.artworkURL)
}
}
private func loadBestArtwork(for track: Track) async -> UIImage? {
// Priority order: embedded > cached > remote
if let embedded = await extractEmbeddedArtwork(track) {
return embedded
}
if let cached = await loadFromCache(track.artworkURL) {
return cached
}
return await downloadImage(track.artworkURL)
}
沟通模板
To Designer: Fixed artwork flicker - reduced from 3-4 updates to 1 per track.
Root cause: Multiple async sources racing to update artwork.
Solution: Task cancellation + priority order (embedded > cached > remote).
Testing: Verified with 10 track changes, zero flicker.
节省时间
- 减少 1-2 小时的图片缓存调查
- 不再阻碍设计师审批
- 恢复专业的用户体验
常见问题
| 症状 | 原因 | 解决方案 | 修复时间 |
|---|---|---|---|
| 信息始终不显示 | 缺少后台模式 | 将 audio 添加到 Info.plist 中的 UIBackgroundModes | 2 min |
| 信息始终不显示 | AVAudioSession 未激活 | 在播放前调用 setActive(true) | 5 min |
| 信息始终不显示 | 没有命令处理程序 | 至少为一个命令添加目标 | 10 min |
| 信息始终不显示 | 使用了 .mixWithOthers | 移除 .mixWithOthers 选项 | 5 min |
| 命令呈灰色 | isEnabled = false | 添加目标后设置 command.isEnabled = true | 5 min |
| 命令没有响应 | 处理程序返回了错误状态 | 从处理程序返回 .success | 5 min |
| 命令没有响应 | 使用了与 MPNowPlayingSession 共用的命令中心 | 改用 session.remoteCommandCenter | 10 min |
| 缺少跳转按钮 | 未设置 preferredIntervals | 设置 skipCommand.preferredIntervals = [15.0] | 5 min |
| 封面始终不显示 | MPMediaItemArtwork 块返回 nil | 确保在创建封面前已加载图像 | 15 min |
| 封面闪烁 | 多次快速更新 | 使用单一事实来源并进行取消 | 20 min |
| 播放/暂停状态错误 | 使用了 playbackState 属性 | 使用 playbackRate(1.0 = 正在播放,0.0 = 已暂停) | 10 min |
| 进度条卡住 | 跳转时未更新 | 跳转完成后更新 elapsedPlaybackTime | 10 min |
| 进度条跳动 | 在计时器上更新 elapsed | 不要在计时器上更新;系统会根据速率推算 | 10 min |
| 当前播放信息被其他应用抢占 | 前台时未重新激活会话 | 前台时调用 becomeActiveIfPossible() | 15 min |
playbackState 不起作用 | 仅适用于 iOS 的应用 | playbackState 仅适用于 macOS;在 iOS 上使用 playbackRate | 10 min |
| Siri 跳转忽略 preferredIntervals | 处理程序中使用了硬编码间隔 | 使用 MPSkipIntervalCommandEvent 中的 event.interval | 5 min |
| CarPlay:应用未显示 | 缺少权利 | 将 com.apple.developer.carplay-audio 添加到 entitlements | 5 min |
| CarPlay:自定义按钮未显示 | 配置时间不正确 | 在 templateApplicationScene(_:didConnect:) 中进行配置 | 5 min |
| CarPlay:设备上正常,模拟器上不正常 | 调试器已连接 | 为确保测试可靠,请在不连接调试器的情况下运行 | 1 min |
| MusicKit:当前播放信息错误 | 覆盖了自动数据 | 使用 ApplicationMusicPlayer 时,不要设置 nowPlayingInfo | 5 min |
专家检查清单
实现当前播放功能之前
- 已将
audio添加到 Info.plist 中的 UIBackgroundModes - AVAudioSession 类别为
.playback,且未使用.mixWithOthers - 已决定使用手动方式(MPNowPlayingInfoCenter)还是自动方式(MPNowPlayingSession)?
AVAudioSession 设置
- 已在应用启动时调用
setCategory(.playback) - 已在开始播放前调用
setActive(true) - 停止时调用
setActive(false, options: .notifyOthersOnDeactivation) - 已处理中断通知(电话结束后重新激活)
- 已处理前台通知(从后台返回后重新激活)
远程命令
- 至少有一个命令已注册目标
- 所有已注册的命令都设置了
isEnabled = true - 已为跳转命令设置
preferredIntervals - 处理程序成功时会返回
.success - 使用了正确的命令中心(会话的命令中心还是共用的命令中心)
- 命令目标已存储,以防止被释放
- 已在 deinit 中移除命令
当前播放信息
- 已设置标题(
MPMediaItemPropertyTitle) - 已设置时长(
MPMediaItemPropertyPlaybackDuration) - 已在播放/暂停/跳转时设置经过时间(
MPNowPlayingInfoPropertyElapsedPlaybackTime) - 已设置播放速率(
MPNowPlayingInfoPropertyPlaybackRate:1.0 = 正在播放,0.0 = 已暂停) - 已使用
MPMediaItemArtwork(boundsSize:requestHandler:)创建封面 - 未使用
playbackState属性(仅适用于 macOS) - 未在计时器上更新经过时间
封面图片
- 图片至少为 600x600 像素
- MPMediaItemArtwork 代码块永不返回 nil(必要时返回占位图片)
- 使用唯一事实来源以避免闪烁
- 切换曲目时取消之前的封面图片加载
测试
- 锁定屏幕显示正确的信息
- 控制中心显示正确的信息
- 播放/暂停按钮可正常响应
- 跳过按钮正常显示并响应
- 进度条正确移动
- 应用在后台/前台切换后仍能正常工作
- 接听电话造成中断后仍能正常工作
- 其他应用播放音频后仍能正常工作
- 已测试与 Apple Music 的冲突
- 已测试与 Spotify 的冲突
CarPlay(如适用)
- 已添加
com.apple.developer.carplay-audio权限 - 已在
templateApplicationScene(_:didConnect:)中配置 CPNowPlayingTemplate - 已使用 CPNowPlayingButton 配置自定义按钮(如有)
- 已在 CarPlay 模拟器中测试(I/O → External Displays → CarPlay)
- 已在真实车辆中测试(如有)
- 已分别在连接和未连接调试器的情况下测试
资源
WWDC:2022-110338、2017-251、2019-501
文档:/mediaplayer/mpnowplayinginfocenter、/mediaplayer/mpremotecommandcenter、/mediaplayer/mpnowplayingsession
技能:axiom-avfoundation-ref、axiom-now-playing-carplay、axiom-now-playing-musickit
最后更新:2026-01-04 状态:涵盖 Now Playing、CarPlay 和 MusicKit 集成的 iOS 18+ 规范技能 测试依据:基于 WWDC 2019-501、WWDC 2022-110338 中的模式