shareplay-activities

shareplay-activities

熱門

使用 GroupActivities 和 SharePlay 打造共享即時體驗。適用於實作共享媒體播放、協作應用功能、同步遊戲狀態,或任何在 iOS、macOS、tvOS 或 visionOS 上的 FaceTime、訊息、AirDrop 或鄰近 visionOS 群組活動。

940星標
47分支
更新於 2026/7/15
SKILL.md
唯讀
名稱
shareplay-activities
描述

使用 GroupActivities 和 SharePlay 打造共享即時體驗。適用於實作共享媒體播放、協作應用功能、同步遊戲狀態,或任何在 iOS、macOS、tvOS 或 visionOS 上的 FaceTime、訊息、AirDrop 或鄰近 visionOS 群組活動。

GroupActivities / SharePlay

使用 GroupActivities 框架打造共享即時體驗。SharePlay
透過 FaceTime、訊息、AirDrop 以及鄰近 visionOS 分享連結使用者,
同步媒體播放、應用程式狀態或自訂資料。

目錄

設定

功能

在 Xcode 中為 App target 加入 Group Activities 功能。Xcode 會加入
所需的授權並更新 provisioning profile:

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

僅為 App target 設定此功能。Group Activities 不適用於
widget、extension 或 App Clip。

檢查資格

import GroupActivities

let observer = GroupStateObserver()

// 檢查是否有進行中的 FaceTime 通話或訊息對話
if observer.isEligibleForGroupSession {
    showSharePlayButton()
}

以響應式方式觀察變化:

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

定義 GroupActivity

遵循 GroupActivity 協定並提供 metadata:

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 共享健身

GroupActivityCodable;儲存的活動資料必須可編碼。僅在
使用 SwiftUI ShareLink、透過 AirDrop 的 SharePlay 或
AppKit/UIKit 分享選單時才加入 Transferable。保持 payload 最小化:使用識別碼或 URL
而非大型資料。

Session 生命週期

監聽 Session

建立一個長期存在的 task 來接收其他參與者啟動活動時的 session:

@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)

        // 觀察 session 狀態變化
        let stateTask = Task {
            for await state in session.$state.values {
                handleState(state)
            }
        }
        sessionTasks.append(stateTask)

        // 觀察參與者變化
        let participantTask = Task {
            for await participants in session.$activeParticipants.values {
                handleParticipants(participants)
            }
        }
        sessionTasks.append(participantTask)

        // 加入 session
        session.join()
    }

    private func cleanUp() {
        sessionTasks.forEach { $0.cancel() }
        sessionTasks.removeAll()
        session = nil
        messenger = nil
    }
}

Session 狀態

狀態 說明
.waiting Session 存在但本地參與者尚未加入
.joined 本地參與者已活躍於 session 中
.invalidated(reason:) Session 已結束(檢查 reason 取得詳細資訊)

處理狀態變化

private func handleState(_ state: GroupSession<WatchTogetherActivity>.State) {
    switch state {
    case .waiting:
        print("等待加入")
    case .joined:
        print("已加入 session")
        loadActivity(session?.activity)
    case .invalidated(let reason):
        print("Session 已結束:\(reason)")
        cleanUp()
    @unknown default:
        break
    }
}

private func handleParticipants(_ participants: Set<Participant>) {
    print("活躍參與者:\(participants.count)")
}

離開與結束

// 離開 session(其他參與者繼續)
session?.leave()

// 為所有參與者結束 session
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)
}

// 傳送給特定參與者
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)
        }
    }
}

傳送模式

// 可靠(預設)——針對關鍵狀態進行檢查與重試
let reliableMessenger = GroupSessionMessenger(
    session: session,
    deliveryMode: .reliable
)

// 不可靠——較低延遲,不保證送達
let unreliableMessenger = GroupSessionMessenger(
    session: session,
    deliveryMode: .unreliable
)

對於改變狀態的動作(如選取、回合)使用 .reliable
對於高頻率、短暫的資料(如游標位置、繪圖筆觸、反應)使用 .unreliable

協調媒體播放

對於影片/音訊,使用 AVPlaybackCoordinator 搭配 AVPlayer

import AVFoundation
import GroupActivities

func configurePlayback(
    session: GroupSession<WatchTogetherActivity>,
    player: AVPlayer
) {
    // 將播放器的協調器連接到 session
    let coordinator = player.playbackCoordinator
    coordinator.coordinateWithSession(session)
}

連接後,AVFoundation 會同步播放/暫停、搜尋、播放速率、播放速度
和時間。請勿將 AVPlayer 的傳輸控制欄位放入 messenger 訊息或快照中,
包含後加入者的快照;僅針對播放以外的狀態使用自訂訊息。

從你的 App 啟動 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:
        // 有進行中的對話,且使用者選擇分享。
        _ = try await activity.activate()

    case .activationDisabled:
        // 使用者選擇本地播放,或分享不可用。
        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 擁有播放 UI。SharePlay 擁有邀請、生命週期、參與者和協調交接。
參見 references/shareplay-patterns.md 了解 SwiftUI ShareLink、AirDrop 和直接啟動模式。

GroupSessionJournal:檔案傳輸

對於較大、非時間敏感的附件,使用 GroupSessionJournal 而非
GroupSessionMessenger。Journal 項目必須遵循 Transferable
可供後加入者使用,且限制為 100 MB。需要 iOS/iPadOS/tvOS
17+、macOS 14+ 或 visionOS 1+。對於更大/受保護的資產,分享指標或清單,
並使用伺服器儲存或應用程式管理的檔案傳輸。

import GroupActivities

let journal = GroupSessionJournal(session: session)

// 上傳一個 Transferable 檔案或資料項目
let attachment = try await journal.add(sharedImageItem)

// 觀察傳入的附件
Task {
    for await attachments in journal.attachments {
        for attachment in attachments {
            let data = try await attachment.load(Data.self)
            handleReceivedFile(data)
        }
    }
}

常見錯誤

不要:忘記呼叫 session.join()

設定好儲存的 session、messenger 和觀察者後,再呼叫 join()
Session 生命週期中的典型長期管理員顯示了必要的順序。

不要:忘記離開或結束 session

// 錯誤——使用者離開頁面後 session 仍然存活
func viewDidDisappear() {
    // 什麼都不做——session 洩漏
}

// 正確——當檢視被關閉時離開
func viewDidDisappear() {
    session?.leave()
    session = nil
    messenger = nil
}

不要:假設所有參與者狀態相同

// 錯誤——廣播狀態但未處理後加入者
func onJoin() {
    // 新參與者不知道當前狀態
}

// 正確——將完整狀態傳送給新參與者
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 傳輸機制處理大型/受保護的資產

// 錯誤——messenger 適用於小型/時間敏感資料;journal 是 Transferable 且 <=100 MB
let imageData = try Data(contentsOf: imageURL)     // 300 KB
try await messenger.send(imageData, to: .all)      // 太大
// 正確——journal 附件最多 100 MB;否則分享指標/清單
let journal = GroupSessionJournal(session: session)
try await journal.add(sharedImageItem)
// 更大/受保護的資產:伺服器儲存或應用程式管理的檔案傳輸

不要:為媒體播放傳送冗餘訊息

// 錯誤——使用 AVPlayer 時手動同步播放/暫停
func play() {
    player.play()
    try await messenger.send(PlayMessage(), to: .all)
}

// 正確——讓 AVPlaybackCoordinator 處理
player.playbackCoordinator.coordinateWithSession(session)
player.play()  // 自動同步給所有參與者

不要:在會被重建的檢視中觀察 session

sessions() 監聽器放在長期存在的管理員中,而不是可重建的檢視。
使用上述的管理員生命週期,並在失效時取消其子 task。

審查清單

  • [ ] 僅為 App target 加入 Group Activities 功能
  • [ ] GroupActivity 結構體是 Codable 且包含有意義的 metadata
  • [ ] 使用 ShareLink、AirDrop 或分享選單時加入 Transferable 遵循
  • [ ] sessions() 在長期存在的物件中觀察(而非 SwiftUI 檢視 body)
  • [ ] 接收並設定 session 後呼叫 session.join()
  • [ ] 使用者離開或關閉時呼叫 session.leave()
  • [ ] GroupSessionMessenger 訊息保持在 256 KB 以下,並使用適當的 deliveryMode
  • [ ] 後加入的參與者在連線時收到當前狀態
  • [ ] 觀察 $state$activeParticipants 發布者以處理生命週期變化
  • [ ] 對於非時間敏感的 Transferable 附件使用 GroupSessionJournal
  • [ ] 使用 AVPlaybackCoordinator 進行媒體同步(而非手動訊息)
  • [ ] 在顯示 SharePlay UI 前檢查 GroupStateObserver.isEligibleForGroupSession
  • [ ] 當沒有進行中的對話時使用 GroupActivitySharingController
  • [ ] 處理 session 失效,清理 messenger、journal 和 task

參考資料