WWDC Quick Look 💓 By SwiftGGTeam
Meet the Now Playing framework

Meet the Now Playing framework

观看原视频

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 提供了 MusicContentPodcastContentMovieContent 等专用类型,也可以用 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