WWDC Quick Look 💓 By SwiftGGTeam
Coordinate media experiences with Group Activities

Coordinate media experiences with Group Activities

观看原视频

Highlight

Apple 在 Group Activities 框架中加入 SharePlay 媒体同步能力,并通过 AVPlaybackCoordinator 让开发者把 AVPlayer 播放、暂停和跳转状态同步到 FaceTime 通话中的所有设备。

核心内容

远程一起看视频,最难的部分从来不是播放视频本身。

麻烦来自同步。一个人按下播放,另一个人还在缓冲。一个人暂停说话,另一个人的进度继续往前走。有人拖动进度条,群组里每台设备都要追上同一个位置。

过去,开发者需要自己处理这些状态。App 要知道用户是否在通话中,要把播放命令发给远端设备,还要处理网络延迟、缓冲、加入中途会话、离开会话这些细节。媒体 App 最终会写出一套小型实时协作系统。

Apple 在 WWDC21 给出的方案是 Group Activities(群组活动)和 SharePlay。App 只需要定义“大家正在一起做什么”,系统负责把活动发送给 FaceTime 通话中的其他参与者,并把 GroupSession(群组会话)交给每台设备。

媒体播放还有一层特殊处理。AVFoundation 新增 AVPlaybackCoordinator(播放协调器)。开发者把 AVPlayerplaybackCoordinator 连接到 GroupSession,播放、暂停、seek 这类传输控制命令就会被协调到同一个群组状态。

从本地播放切换到 SharePlay

用户点击播放按钮时,App 不应该直接假设用户想分享。

prepareForActivation() 会让系统判断当前环境和用户意图。如果用户没有在 FaceTime 通话中,App 继续本地播放。如果用户正在通话并选择共享,App 调用 activate(),系统创建会话并把它送到本机和远端设备。

这一步解决的是入口问题。播放按钮仍然是播放按钮,用户无需学习新的操作路径。

从 GroupSession 接管播放状态

会话创建后,App 通过 MovieWatchingActivity.sessions() 监听系统送来的 GroupSession

本机发起的活动会收到会话。远端用户接受活动后也会收到会话。App 拿到会话后,加载对应内容,把 AVPlayer 连接到这个会话,最后调用 join() 加入实时连接。

这一步解决的是分发问题。App 不直接创建 GroupSession,也不自己管理远端设备列表。

让 AVPlayer 负责同步细节

AVPlaybackCoordinator 会拦截改变速率或当前时间的 AVPlayer API。

当一个参与者按下播放,协调器先让本机等待,再把命令发送给其他设备。所有设备准备好后一起开始播放。seek 也是同样流程:各设备完成跳转后,再一起恢复。

这一步解决的是同步问题。开发者仍然调用熟悉的 AVPlayer API,协调器负责把这些命令变成群组行为。

处理每个人的临时中断

群组播放不代表每次暂停都要影响所有人。

一个人的闹钟响了,系统音频会中断本机播放。网络卡顿也可能只发生在一台设备上。AVPlayerPlaybackCoordinator 会为这类情况创建 suspension(暂停协调),把这台设备临时从群组时序里分离出来。

中断结束后,本机会跳到群组当前时间并重新同步。其他人不用等待这次本地中断。

详细内容

定义 GroupActivity

03:06)GroupActivity 是用户一起体验的内容。视频 App 可以把它理解成“一部正在一起看的电影”。框架要把活动数据通过网络发送给其他设备,所以协议继承自 Codable

protocol GroupActivity: Codable {

    /// An identifier so the system knows how to reference this activity
    static var activityIdentifier: String { get }

    /// Information that the system uses to show this activity, such as title and a preview image
    var metadata: GroupActivityMetadata { get async }
}

关键点:

  • GroupActivity 表示共享体验中的单个内容,例如一部电影或一首歌。
  • Codable 让框架可以把活动里的属性发到网络另一端。
  • activityIdentifier 是系统引用这个活动类型的唯一标识。
  • metadata 提供标题、预览图等系统 UI 需要展示的信息。
  • get async 使用 Swift Concurrency(Swift 并发)的异步只读属性形式。

在播放按钮里判断是否启动共享

04:42)用户选择内容后,App 先构造活动,再调用 prepareForActivation()。系统会根据 FaceTime 通话状态和用户选择返回结果。

func playButtonTapped() {
    let activity = MovieWatchingActivity(movie: movie)

    Task {
        switch await activity.prepareForActivation() {
        case .activationDisabled:
            // Playback coordination isn't active. Queue movie
            // for local playback.
            self.enqueuedMovie = movie
        case .activationPreferred:
            // Activate the activity. The system enqueues the movie
            // when the activity starts.
            activity.activate()
        case .cancelled:
            // The user cancelled the operation. Nothing to perform.
            break
        default:
            break
        }
    }
}

关键点:

  • MovieWatchingActivity(movie: movie) 把当前电影包装成群组活动。
  • Task 用来执行异步调用。
  • await activity.prepareForActivation() 让系统判断是否应该共享到 FaceTime 通话。
  • .activationDisabled 表示播放协调未启用,或者用户选择本地观看;代码直接设置 enqueuedMovie
  • .activationPreferred 表示系统建议启动共享;代码调用 activity.activate()
  • .cancelled 表示用户取消分享流程,代码不做额外操作。

接收 GroupSession

08:31)App 不直接实例化 GroupSession。系统通过 GroupActivity.sessions() 返回的 AsyncSequence(异步序列)交付会话。

// Receiving a GroupSession from the GroupSession AsyncSequence

func listenForGroupSession() {
    Task {
        for await session in MovieWatchingActivity.sessions() {
            ...
        }
    }
}

关键点:

  • listenForGroupSession() 应该在 App 启动后尽早开始监听。
  • Task 启动异步循环。
  • for await 持续等待系统交付新的会话。
  • MovieWatchingActivity.sessions() 是接收 GroupSession 的入口。
  • session 代表当前活动在多台设备之间的实时会话。

把 AVPlayer 连接到 GroupSession

09:03)拿到 GroupSession 后,媒体 App 的关键步骤是把 AVPlayerplaybackCoordinator 连接到这个会话。

let player = AVPlayer()

...

func listenForGroupSession() {
    Task {
        for await groupSession in MovieWatchingActivity.sessions() {

            // Verify content is available, prepare for playback to begin

            player.playbackCoordinator.coordinateWithSession(groupSession)

            ...
        }
    }
}

关键点:

  • AVPlayer() 是实际播放媒体内容的播放器。
  • for await groupSession 接收系统送来的群组会话。
  • 注释提醒开发者先确认内容可用,并完成播放前准备。
  • player.playbackCoordinator 访问 AVPlayer 绑定的播放协调器。
  • coordinateWithSession(groupSession) 把播放器接入群组会话。
  • 连接完成后,播放、暂停、seek 这类改变速率或时间的命令会进入协调流程。

09:24)会话最初处于 waiting 状态。App 还需要调用 join(),让实时连接开始工作。演讲说明,连接成功后,播放同步才会开始。

groupSession.join()

关键点:

  • join() 让当前设备加入群组实时连接。
  • 加入后,设备之间可以发送和接收会话消息。
  • 对媒体播放来说,加入会话后播放协调才开始生效。

理解 AVPlaybackCoordinator 如何拦截播放命令

16:17)AVPlaybackCoordinator 会拦截 transport control API(传输控制 API),也就是改变 ratecurrentTime 的调用。

player.play()
player.pause()
player.seek(to: time)

关键点:

  • player.play() 会改变播放器速率,协调器会把这个意图发送给其他参与者。
  • player.pause() 同样属于速率变化,会影响群组中播放同一内容的设备。
  • player.seek(to: time) 改变当前时间,协调器会让其他设备跳到一致的位置。
  • 演讲强调,协调器只会在参与者播放相同内容时应用这些状态。

18:03)默认情况下,协调器用 AVPlayerItem 的 URL 判断两台设备是否在播放同一内容。URL 相同,状态才会互相影响。URL 不同,命令不会套用到远端播放器。

let item = AVPlayerItem(url: movieURL)
player.replaceCurrentItem(with: item)

关键点:

  • AVPlayerItem(url:) 使用 URL 创建播放项目。
  • 协调器默认把相同 URL 视为相同内容。
  • replaceCurrentItem(with:) 让项目成为当前播放内容。
  • 项目成为 current item 后,协调器才会开始应用群组状态。

为本地缓存和合成内容提供自定义标识

23:20)如果一个用户播放本地缓存文件,另一个用户播放云端 URL,默认 URL 判断会失效。AVFoundation 提供 delegate 入口,让 App 为 AVPlayerItem 返回自定义字符串标识。

func playbackCoordinator(_ playbackCoordinator: AVPlayerPlaybackCoordinator,
                         identifierFor playerItem: AVPlayerItem) -> String? {
    return movieIdentifier
}

关键点:

  • playbackCoordinator(_:identifierFor:) 来自 AVPlayerPlaybackCoordinator delegate。
  • playerItem 是协调器正在询问身份的播放项目。
  • 返回的字符串会取代 URL,用来判断两台设备是否播放同一内容。
  • movieIdentifier 应该表示内容身份,不能表示本地文件路径。
  • 演讲提醒,两个项目被视为相同时,同一时间点必须对应同一段内容。

24:40)个性化广告和插播内容不要混进主内容时间轴。演讲建议把广告和其他 interstitials(插播内容)放到单独播放器中,让主资产的时间保持一致。HLS 内容可以结合 AVPlayerInterstitialEvent API。

let event: AVPlayerInterstitialEvent

关键点:

  • AVPlayerInterstitialEvent 是演讲提到的 HLS 插播相关 API。
  • 插播内容有自己的播放时段,不应该改变主内容的时间对应关系。
  • 主内容时间保持一致后,协调器才能把不同设备重新拉回同一进度。

使用 suspension 做个人临时脱离

31:26)有些操作只应该影响本机。演讲里的例子是“我错过了刚才几秒”:本机后退几秒,二倍速追上群组,其他设备继续播放。

class AVPlaybackCoordinator {
    func beginSuspension(for reason: AVCoordinatedPlaybackSuspension.Reason) -> AVCoordinatedPlaybackSuspension
}

class AVCoordinatedPlaybackSuspension {
    func end()
    func end(proposingNewTime newTime: CMTime)
}

关键点:

  • beginSuspension(for:) 让当前参与者的协调器临时脱离群组。
  • reason 记录这次脱离的原因,App 可以用自定义字符串常量扩展。
  • 返回的 AVCoordinatedPlaybackSuspension 代表这段脱离状态。
  • suspension 存在期间,本机 seek 和 rate 改变不会影响其他人。
  • end() 结束脱离,本机会重新加入群组当前时间和速率。
  • end(proposingNewTime:) 结束脱离时可以向群组提议新的时间,适合拖动进度条这类交互。

32:40)AVPlaybackCoordinator 还有几个辅助入口。它可以让某些 suspension 原因触发其他人等待,也可以暴露其他参与者状态,并通过 AVPlayer 通知标记远端参与者造成的速率变化或时间跳转。

coordinator.suspensionReasonsThatTriggerWaiting
coordinator.otherParticipants
player.reasonForWaitingToPlay
player.playImmediately(atRate: rate)

关键点:

  • suspensionReasonsThatTriggerWaiting 用来配置哪些暂停原因会让其他参与者等待。
  • otherParticipants 可以查看其他参与者状态,包括 suspension reason。
  • reasonForWaitingToPlay 会出现新的 coordinated playback 等待原因。
  • playImmediately(atRate:) 可以绕过等待立即播放,演讲提醒这可能让其他参与者错过内容。

自定义播放器使用 AVDelegatingPlaybackCoordinator

34:46)如果 App 没有使用 AVPlayer,可以使用 AVDelegatingPlaybackCoordinator。它不会替你观察播放器状态,开发者要自己把播放、暂停、seek、缓冲命令接到 delegate。

let coordinator = AVDelegatingPlaybackCoordinator()

关键点:

  • AVDelegatingPlaybackCoordinator 面向非 AVPlayer 的播放实现。
  • UI 不应该直接命令自定义播放器,应该先把意图交给协调器。
  • 协调器决定是否需要等待其他参与者后,再让播放对象改变时间或速率。
  • 当前播放项目需要由开发者用字符串标识。
  • 自动 suspension 不会由 delegating coordinator 添加,系统事件需要开发者自己开始和结束 suspension。

核心启发

  1. 做什么:给视频 App 增加“一起看”按钮。

    • 为什么值得做prepareForActivation() 能保留原有播放入口,同时在 FaceTime 通话中把播放升级为 SharePlay。
    • 怎么开始:定义 MovieWatchingActivity,在播放按钮中调用 prepareForActivation(),根据结果选择本地 enqueue 或 activate()
  2. 做什么:做一个在线课程的同步观看功能。

    • 为什么值得做AVPlaybackCoordinator 会同步播放、暂停和 seek,适合讲师和学员一起看同一段课程视频。
    • 怎么开始:让课程视频 URL 成为 GroupActivity 的内容数据,监听 CourseWatchingActivity.sessions(),再调用 player.playbackCoordinator.coordinateWithSession(groupSession)
  3. 做什么:给体育回放加入“我错过了”个人追赶按钮。

    • 为什么值得做AVCoordinatedPlaybackSuspension 允许一名参与者短暂后退和加速,不打断其他人观看。
    • 怎么开始:点击按钮时调用 beginSuspension(for:),本机 seek 到几秒前并设置更高速率,追上后调用 suspension.end()
  4. 做什么:支持本地缓存视频的 SharePlay 播放。

    • 为什么值得做:缓存文件和云端文件 URL 不同,默认 URL 身份无法同步;自定义 identifier 可以把它们映射到同一内容。
    • 怎么开始:实现 AVPlayerPlaybackCoordinator delegate 的 identifierFor playerItem,返回服务端内容 ID。
  5. 做什么:为带广告的视频做稳定的群组同步。

    • 为什么值得做:个性化插播会破坏主内容时间轴,导致设备在不同片段上同步失败。
    • 怎么开始:把广告放进单独播放器,主内容保持一致;HLS 场景继续研究 AVPlayerInterstitialEvent

关联 Session

评论

GitHub Issues · utterances