Highlight
Apple 推出 NowPlaying 框架,用声明式协议和
@Observable替代了MPNowPlayingInfoCenter的字典 API,让 App 的媒体播放自动同步到锁屏、控制中心、灵动岛和 CarPlay;同时通过 Remote Media Sessions 和 Media Sharing Extensions,把第三方音箱等外部设备的播放控制也纳入了系统原生界面。
核心内容
做过音视频播放的开发者都经历过这个场景:用户按下耳机上的暂停键,或者从锁屏点击下一首,你的 App 需要在多个地方响应这些事件。以前的做法是往 MPNowPlayingInfoCenter 里塞一个字典,键名是字符串常量,类型全靠运行时检查,状态更新也是手动触发。代码写起来像打地鼠,锁屏上的进度条和暂停按钮经常跟 App 内部状态对不上。
NowPlaying 框架把这个过程彻底改写了。
(01:00)它的核心思路是:让播放模型遵守一个协议,系统通过 Swift 的 @Observable 自动追踪状态变化,然后自动更新所有系统界面。你不需要手动告诉锁屏”现在播到第 3 秒了”,只需要修改模型里的 elapsedTime 属性,系统自己会刷新。
这个设计直接解决了一个长期痛点:状态同步。以前字典 API 是命令式的,每次改一个字段都要重新 set,系统不知道你到底改了什么,只能无脑全量刷新。现在 @Observable 让系统能精确 diff,只更新变化的部分,锁屏和灵动岛的动画也更流畅。
(05:04)另一个大变化是对远程播放设备的支持。如果你的 App 能控制智能音箱,以前用户切回 iPhone 后,控制中心里看不到这个设备在播什么。现在通过 Remote Media Sessions,音箱的状态可以通过 APNs 推送到 iPhone,系统会在控制中心显示同样的播放界面,体验跟本地播放完全一致。
(10:31)最后还有 Media Sharing Extensions。以前支持投屏协议意味着要在 App 里嵌入对应的 SDK,包体积膨胀。现在协议实现由系统管理,App 只需要调用系统设备选择器,用户选完设备后,播放路由自动完成。
详细内容
本地播放接入系统 Now Playing
(01:56)假设你有一个播放模型,里面引用了音频引擎,还有一个属性记录当前在播什么:
import Observation
@Observable
final class PlayerModel {
let player: SoundPlayer
var sound: Sound { player.currentSound }
init(player: SoundPlayer) {
self.player = player
}
}
要让这个模型接入系统 Now Playing,只需要让它遵守 MediaSessionRepresentable 协议:
import NowPlaying
extension PlayerModel: MediaSessionRepresentable {
var id: String { "ambient-sound-session" }
var content: (any MediaContentRepresentable)? {
return GenericContent(
id: sound.id,
title: sound.name,
subtitle: sound.description,
type: .audio,
duration: .live,
artwork: Artwork(id: sound.id) { size in
let data = try await self.artworkData(size: size)
return try ArtworkRepresentation(data: data)
}
)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: player.isPlaying ? .playing() : .paused
)
}
var commands: [MediaCommand] {[
.play { self.player.play() },
.pause { self.player.pause() },
.previous { self.player.previous() },
.next { self.player.next() }
]}
}
关键点:
id是会话标识,不是歌曲标识。切歌时不要变,否则系统会认为是两个不同会话,导致灵动岛动画断裂。content描述正在播放的内容。NowPlaying 提供了MusicContent、PodcastContent、MovieContent等专用类型,也可以用GenericContent覆盖其他场景。duration可以是.live(直播)、.continuous(无限循环)或一个具体时长。系统会根据这个值自动调整进度条样式。artwork接收一个异步闭包,系统在需要特定尺寸封面时调用。不要在闭包里做同步阻塞操作。playbackSnapshot返回当前播放状态。对于有时长的内容,需要加上elapsedTime参数。commands定义 App 支持的操作,每个命令对应一个闭包。用户从锁屏或控制中心触发时,系统调用对应的闭包。
(04:31)协议遵守后,再用 MediaSession 把模型和系统连接起来:
import NowPlaying
struct PlayerController {
let player: SoundPlayer
let model: PlayerModel
let session: MediaSession<PlayerModel>
init() {
self.player = SoundPlayer()
self.model = PlayerModel(player: player)
self.session = MediaSession(model)
}
}
关键点:
MediaSession在初始化时开始观察模型,状态变化自动同步到系统界面。- 初始化位置建议和音频引擎放在一起,生命周期保持一致。引擎销毁时 Session 也要释放。
远程播放设备接入系统
(05:04)假设你的 App 能控制一个智能音箱,音箱通过你自己的服务器通信。要让这个远程播放状态显示在 iPhone 控制中心,需要三个东西:APNs 推送、一个 App Extension、以及遵守 RemoteMediaSessionRepresentable 的模型。
数据流是这样的:音箱状态变化 -> 你的服务器 -> APNs 推送到 iPhone -> 系统唤醒 App Extension -> Extension 提供更新后的会话表示 -> 系统刷新控制中心 UI。
(06:42)先创建 App Extension:
import ExtensionFoundation
import NowPlaying
@main
final class SampleAppExtension: @MainActor RemoteMediaSessionExtension {
var configuration: some AppExtensionConfiguration {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
var extensionPoint: AppExtensionPoint {
AppExtensionPoint.Identifier(host: "com.apple.nowplaying", name: "remote-media")
}
func session(_ state: RemotePlayerState) async throws -> RemotePlayerModel {
RemotePlayerModel(state: state)
}
}
关键点:
RemoteMediaSessionExtension是 Extension 的入口协议。extensionPoint必须使用"com.apple.nowplaying"作为 host、"remote-media"作为 name。session(_:)在系统需要更新 UI 或处理交互时被调用,传入的state来自 APNs 推送 payload。- Extension 是按需唤醒的,不要在里面维持长连接或缓存大量数据。
(07:23)远程播放模型跟本地模型结构类似,但多出一些远程特有的属性:
import Observation
@Observable
@MainActor
final class RemotePlayerModel {
let client: ServerClient
var state: RemotePlayerState
init(state: RemotePlayerState) {
self.client = ServerClient(sessionID: state.sessionID)
self.state = state
}
}
(07:40)让它遵守 RemoteMediaSessionRepresentable:
import NowPlaying
extension RemotePlayerModel: @MainActor RemoteMediaSessionRepresentable {
var id: String { state.sessionID }
var content: (any MediaContentRepresentable)? {
GenericContent(
id: state.sound.id,
title: state.sound.name,
subtitle: state.sound.description,
type: .audio,
duration: .live,
artwork: Artwork(id: state.sound.id) { size in
let data = try await self.artworkData(size: size)
return try ArtworkRepresentation(data: data)
}
)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: state.isPlaying ? .playing() : .paused
)
}
var commands: [MediaCommand] {[
.play { try await self.client.send(.play) },
.pause { try await self.client.send(.pause) },
.previous { try await self.client.send(.previous) },
.next { try await self.client.send(.next) }
]}
var devices: [MediaDevice] {
state.devices.map { device in
MediaDevice(
id: device.id,
name: device.name,
type: .speaker,
capabilities: [
.absoluteVolume(device.volume) { volume in
// 发送音量变更到服务器
}
]
)
}
}
func update(_ state: RemotePlayerState) {
self.state = state
}
}
关键点:
commands里的闭包不再直接操作本地播放器,而是向服务器发送指令。devices告诉系统有哪些设备在参与这个会话。每个设备需要稳定的 ID、名称、类型和一组能力(如音量控制)。update(_:)在收到新的 APNs 推送时被调用,更新模型状态。因为模型是@Observable,NowPlaying 会自动检测到变化并刷新系统 UI。
Media Sharing Extensions
(10:31)Media Sharing Extensions 是一组让 iPhone 把媒体投放到其他音箱和电视的 API,全部通过统一的系统设备选择器完成。
传统做法是在 App 里嵌入每个投屏协议的 SDK。Media Sharing Extensions 把协议实现移到了系统层面,App 不再需要关心具体的播放技术。随着新协议加入,已经接入的 App 可以直接使用,不需要再集成额外的 SDK。
核心启发
-
做一个专注白噪音或冥想的 App:用
GenericContent+duration: .continuous描述无限循环的环境音,用户可以直接从锁屏暂停或切换声音,不需要打开 App。入口 API:MediaSessionRepresentable+MediaSession。 -
给现有音乐 App 替换旧的 Now Playing 实现:把
MPNowPlayingInfoCenter的字典调用迁移到MediaSessionRepresentable,利用@Observable消除状态不同步的 bug。先封装一个 Adapter 桥接旧逻辑,逐步替换。入口 API:MediaSession。 -
为智能音箱品牌做 iOS 配套 App:通过
RemoteMediaSessionExtension+ APNs,让用户在 iPhone 控制中心直接控制音箱,体验跟 Apple Music 一样原生。需要搭建一个推送服务器,把音箱状态实时同步到 APNs。入口 API:RemoteMediaSessionRepresentable+RemoteMediaSessionExtension。 -
做一个播客 App 并支持多设备切换:本地播放用
MediaSession,远程音箱用RemoteMediaSession,再通过 Media Sharing Extensions 让用户用系统设备选择器切换播放位置。入口 API:MediaSessionRepresentable+RemoteMediaSessionRepresentable+ Media Sharing Extensions。 -
在 CarPlay 上展示第三方音频内容:NowPlaying 框架的集成会自动覆盖 CarPlay 界面,不需要额外的 CarPlay 特定代码。只要做好
MediaSessionRepresentable,CarPlay 的 Now Playing 卡片会自动显示你的内容。入口 API:MediaSession。
关联 Session
- Meet the Now Playing framework — 用声明式协议和 App Extension 将本地与远程媒体播放接入系统 Now Playing 界面
- What’s new in Swift —
@Observable宏的底层机制和最佳实践,理解 NowPlaying 框架依赖的状态观察模型 - What’s new in SwiftUI — SwiftUI 与系统框架的集成方式,NowPlaying 的声明式设计与 SwiftUI 理念一致
- What’s new in UIKit — UIKit App 如何配合 NowPlaying 框架,以及现代 UIKit 中的状态管理建议
- What’s new in MusicKit — 音乐内容相关的 API,与 NowPlaying 框架的内容类型(
MusicContent等)直接相关
评论
GitHub Issues · utterances