mcpField
S
shareplay-activities

shareplay-activities

github.com/dpearson2699开发1.2k3.6k 次安装13 周前更新
代码仓库

关于 shareplay-activities

shareplay-activities 是面向 Apple 应用开发者的 GroupActivities 与 SharePlay 技能,用于构建实时共享体验。它介绍会话生命周期、参与者间状态同步、协同媒体播放及附件传输。

主要功能

  1. 01定义并启动 GroupActivity
  2. 02管理 SharePlay 会话生命周期
  3. 03通过消息同步参与者状态
  4. 04使用 AVPlaybackCoordinator 协同播放
  5. 05通过 GroupSessionJournal 传输附件

适用场景

  1. 01构建 FaceTime 中的共同观看体验
  2. 02同步协作绘图或编辑状态
  3. 03向新加入者提供当前活动状态
  4. 04在会话中共享非实时附件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill shareplay-activities

安装后在 Claude Code 中输入/shareplay-activities确认已加载。

附带文件evals/evals.jsonreferences/shareplay-patterns.md

详细介绍

GroupActivities / SharePlay

使用 GroupActivities 框架构建共享实时体验。SharePlay 可通过 FaceTime、Messages、AirDrop 和附近的 visionOS 共享功能连接用户,并同步媒体播放、应用状态或自定义数据。

目录

设置

功能

在 Xcode 中为应用目标添加 Group Activities 功能。Xcode 会添加所需的授权,并更新配置文件:

<key>com.apple.developer.group-session</key>
<true/>

仅为应用目标配置此功能。小组件、扩展和 App Clips 不支持 Group Activities。

检查是否符合条件
import GroupActivities

let observer = GroupStateObserver()

// Check if a FaceTime call or Messages conversation is active
if observer.isEligibleForGroupSession {
    showSharePlayButton()
}

以响应式方式观察变化:

for await isEligible in observer.$isEligibleForGroupSession.values {
    showSharePlayButton(isEligible)
}

定义 GroupActivity

遵循 GroupActivity 并提供元数据:

import GroupActivities

struct WatchTogetherActivity: GroupActivity {
    let movieID: String
    let movieTitle: String

    var metadata: GroupActivityMetadata {
        var meta = GroupActivityMetadata()
        meta.title = movieTitle
        meta.type = .watchTogether
        meta.fallbackURL = URL(string: "https://example.com/movie/\(movieID)")
        return meta
    }
}
活动类型
类型用例
.generic自定义活动的默认类型
.watchTogether视频播放
.listenTogether音频播放
.createTogether协作创作(绘图、编辑)
.exploreTogether共享浏览、规划或探索
.learnTogether共享学习或研习
.readTogether共享阅读
.shopTogether共享购物
.workoutTogether共享健身课程

GroupActivity 遵循 Codable;存储的活动数据必须可编码。仅在 SwiftUI ShareLink、通过 AirDrop 使用 SharePlay,或使用 AppKit/UIKit 共享表单时,才添加 Transferable。尽量精简载荷:使用标识符或 URL,而不是大型数据。

会话生命周期

监听会话

设置一个长期运行的任务,以便在其他参与者启动活动时接收会话:

@Observable
@MainActor
final class SharePlayManager {
    private var session: GroupSession<WatchTogetherActivity>?
    private var messenger: GroupSessionMessenger?
    private var sessionTasks: [Task<Void, Never>] = []

    func observeSessions() {
        Task {
            for await session in WatchTogetherActivity.sessions() {
                self.configureSession(session)
            }
        }
    }

    private func configureSession(
        _ session: GroupSession<WatchTogetherActivity>
    ) {
        self.session = session
        self.messenger = GroupSessionMessenger(session: session)

        // Observe session state changes
        let stateTask = Task {
            for await state in session.$state.values {
                handleState(state)
            }
        }
        sessionTasks.append(stateTask)

        // Observe participant changes
        let participantTask = Task {
            for await participants in session.$activeParticipants.values {
                handleParticipants(participants)
            }
        }
        sessionTasks.append(participantTask)

        // Join the session
        session.join()
    }

    private func cleanUp() {
        sessionTasks.forEach { $0.cancel() }
        sessionTasks.removeAll()
        session = nil
        messenger = nil
    }
}
会话状态
状态说明
.waiting会话已存在,但本地参与者尚未加入
.joined本地参与者正在会话中
.invalidated(reason:)会话已结束(查看原因了解详情)
处理状态变化
private func handleState(_ state: GroupSession<WatchTogetherActivity>.State) {
    switch state {
    case .waiting:
        print("Waiting to join")
    case .joined:
        print("Joined session")
        loadActivity(session?.activity)
    case .invalidated(let reason):
        print("Session ended: \(reason)")
        cleanUp()
    @unknown default:
        break
    }
}

private func handleParticipants(_ participants: Set<Participant>) {
    print("Active participants: \(participants.count)")
}
离开和结束
// Leave the session (other participants continue)
session?.leave()

// End the session for all participants
session?.end()

发送和接收消息

使用 GroupSessionMessenger 在参与者之间同步小型、对时间敏感的应用状态。

定义消息

消息必须遵循 Codable;每条消息应小于 256 KB。

struct SyncMessage: Codable {
    let action: String
    let timestamp: Date
    let data: [String: String]
}
发送
func sendSync(_ message: SyncMessage) async throws {
    guard let messenger else { return }

    try await messenger.send(message, to: .all)
}

// Send to specific participants
try await messenger.send(message, to: .only(participant))
接收
func observeMessages() {
    guard let messenger else { return }

    Task {
        for await (message, context) in messenger.messages(of: SyncMessage.self) {
            let sender = context.source
            handleReceivedMessage(message, from: sender)
        }
    }
}
传递模式
// Reliable (default) -- checked and retried for crucial state
let reliableMessenger = GroupSessionMessenger(
    session: session,
    deliveryMode: .reliable
)

// Unreliable -- lower latency, no delivery guarantee
let unreliableMessenger = GroupSessionMessenger(
    session: session,
    deliveryMode: .unreliable
)

对于选择或轮次等会改变状态的操作,使用 .reliable。对于光标位置、绘图笔画和反应等高频临时数据,使用 .unreliable。

协调媒体播放

对于视频或音频,使用 AVPlayer 配合 AVPlaybackCoordinator:

import AVFoundation
import GroupActivities

func configurePlayback(
    session: GroupSession<WatchTogetherActivity>,
    player: AVPlayer
) {
    // Connect the player's coordinator to the session
    let coordinator = player.playbackCoordinator
    coordinator.coordinateWithSession(session)
}

连接后,AVFoundation 会同步播放/暂停、搜索、速率、播放速度和时间。不要将 AVPlayer 的传输字段放入 messenger 消息或快照中,包括供后加入者使用的快照;仅使用自定义消息传递播放状态以外的信息。

从应用启动 SharePlay

使用 GroupActivitySharingController(UIKit)
import GroupActivities
import UIKit

func startSharePlay() async throws {
    let activity = WatchTogetherActivity(
        movieID: "123",
        movieTitle: "Great Movie"
    )

    switch await activity.prepareForActivation() {
    case .activationPreferred:
        // A conversation is active and the user chose to share.
        _ = try await activity.activate()

    case .activationDisabled:
        // The user chose local playback, or sharing is unavailable.
        startLocalExperience()

    case .cancelled:
        break

    @unknown default:
        break
    }
}

没有活跃对话时(即 isEligibleForGroupSession 为 false), 使用 GroupActivitySharingController 让用户先选择联系人:

let controller = try GroupActivitySharingController(activity)
present(controller, animated: true)

自定义控件请使用 shareplay SF Symbol。将 GroupActivityMetadata 视为发现文案:标题、副标题、图片和类型应简洁,并与入口相符。避免涉及相邻领域:GameKit 负责身份验证、匹配、排行榜、成就和语音/聊天;TabletopKit 负责席位、棋盘设备、空间布局、回合、规则和权威桌面状态;AVKit 负责播放界面。SharePlay 负责邀请、生命周期、参与者和协调交接。SwiftUI ShareLink、AirDrop 和直接激活模式请参阅 references/shareplay-patterns.md。

GroupSessionJournal:文件传输

对于较大的非紧急附件,请使用 GroupSessionJournal,而不是 GroupSessionMessenger。日志项目必须遵循 Transferable,新加入的参与者也能获取,大小上限为 100 MB。它要求 iOS/iPadOS/tvOS 17+、macOS 14+ 或 visionOS 1+。对于更大或受保护的资源,请分享指针或清单,并使用服务器存储或应用管理的文件传输。

import GroupActivities

let journal = GroupSessionJournal(session: session)

// Upload a Transferable file or data item
let attachment = try await journal.add(sharedImageItem)

// Observe incoming attachments
Task {
    for await attachments in journal.attachments {
        for attachment in attachments {
            let data = try await attachment.load(Data.self)
            handleReceivedFile(data)
        }
    }
}

常见错误

不要:忘记调用 session.join()

配置存储的会话、信使和观察器,然后调用 join()。会话生命周期中的规范长生命周期管理器展示了所需顺序。

不要:忘记离开或结束会话
// WRONG -- session stays alive after the user navigates away
func viewDidDisappear() {
    // Nothing -- session leaks
}

// CORRECT -- leave when the view is dismissed
func viewDidDisappear() {
    session?.leave()
    session = nil
    messenger = nil
}
不要:假设所有参与者的状态都相同
// WRONG -- broadcasting state without handling late joiners
func onJoin() {
    // New participant has no idea what the current state is
}

// CORRECT -- send full state to new participants
func handleParticipants(_ participants: Set<Participant>) {
    let newParticipants = participants.subtracting(knownParticipants)
    for participant in newParticipants {
        Task {
            try await messenger?.send(currentState, to: .only(participant))
        }
    }
    knownParticipants = participants
}
不要:将 SharePlay 传输用于大型或受保护的资源
// WRONG -- messenger is small/time-sensitive; journal is Transferable and <=100 MB
let imageData = try Data(contentsOf: imageURL)     // 300 KB
try await messenger.send(imageData, to: .all)      // Too large
// CORRECT -- journal attachments up to 100 MB; otherwise share a pointer/manifest
let journal = GroupSessionJournal(session: session)
try await journal.add(sharedImageItem)
// Larger/protected assets: server storage or app-managed file transfer
不要:为媒体播放发送冗余消息
// WRONG -- manually syncing play/pause when using AVPlayer
func play() {
    player.play()
    try await messenger.send(PlayMessage(), to: .all)
}

// CORRECT -- let AVPlaybackCoordinator handle it
player.playbackCoordinator.coordinateWithSession(session)
player.play()  // Automatically synced to all participants
不要:在会被重新创建的视图中观察会话

应在长生命周期管理器中拥有 sessions() 监听器,而不是在可重新创建的视图中。使用上文展示的管理器生命周期,并在失效时取消其子任务。

审查清单

  • 仅向应用目标添加 Group Activities 功能
  • GroupActivity 结构体遵循 Codable,且包含有意义的元数据
  • 使用 ShareLink、AirDrop 或共享表单时添加 Transferable 一致性
  • 在长生命周期对象(而非 SwiftUI 视图主体)中观察 sessions()
  • 接收并配置会话后调用 session.join()
  • 用户离开页面或关闭界面时调用 session.leave()
  • GroupSessionMessenger 消息保持在 256 KB 以内,并使用恰当的 deliveryMode
  • 新加入的参与者在连接时收到当前状态
  • 观察 $state 和 $activeParticipants 发布者以处理生命周期变化
  • 使用 GroupSessionJournal 传输非紧急的 Transferable 附件
  • 使用 AVPlaybackCoordinator 同步媒体(而非手动发送消息)
  • 显示 SharePlay 界面前检查 GroupStateObserver.isEligibleForGroupSession
  • 没有活跃对话时使用 GroupActivitySharingController
  • 会话失效时清理信使、日志和任务

参考资料

评论

支持 Markdown