WWDC Quick Look 💓 By SwiftGGTeam
Build custom experiences with Group Activities

Build custom experiences with Group Activities

观看原视频

Highlight

Apple 在 Group Activities 中提供了自定义活动和 GroupSessionMessenger,开发者可以在 FaceTime 中同步 Codable 消息,把画板、协作编辑等非媒体 App 做成实时 SharePlay 体验。

核心内容

从一个画板 App 开始

一个画板 App 本来很简单。屏幕中间是一块画布,每个人有一种颜色,手指移动时把点追加到当前笔画里。

麻烦出现在 FaceTime 通话里。三个人想在同一块画布上画画时,每台设备都要收到其他人的笔画。新加入的人还要拿到已经画好的内容。有人清空画布时,其他设备也要切到同一个新状态。

媒体 App 可以依赖播放同步。画板没有播放器,也没有时间轴。开发者需要自己定义“要共享什么数据”,再把这些数据发给会话里的其他参与者。

Apple 在这场 session 里用 DrawTogether 演示了完整流程:先定义一个 GroupActivity,把它标记为自定义活动;再用 GroupSessionMessenger 在参与者之间发送结构化消息;最后处理晚加入者、切换活动和入口按钮这些边界情况。

自定义活动只保存稳定信息

GroupActivity 描述的是一次共享体验本身。演讲里强调,activity 应该包含整个体验中保持不变的信息。

对 DrawTogether 来说,稳定信息就是“大家正在一起画画”。当前画了哪些线、每条线有哪些点,这些会随用户操作变化,应该通过消息同步。

因此,配置自定义活动时只需要给 metadata 设置标题,并把 metadata.type 设为 .generic。这一步把它从媒体活动变成自定义活动。

消息是共享画布的核心

画布同步不需要传整张图片。每次用户画出一个新点,App 发送一条消息。消息里包含笔画 id、颜色和坐标点。

GroupSessionMessenger 负责把 Codable 消息发给会话里的参与者,也提供异步序列接收指定类型的消息。演讲明确提醒,它适合较小 payload,不适合传文件、图片或视频。快速循环发送大量消息也可能让 send 抛错。

体验还要处理边界情况

共享体验会遇到晚加入者。新设备加入时,它没有之前的画布状态。如果直接开始画,画面就会和其他设备不一致。

演讲给出的方案是监听 GroupSession.activeParticipants。当参与者集合变化时,已有设备计算新加入者,并把当前画布作为 catch-up 消息发给他们。

切换到一张新画布时,演讲推荐创建新的 Group Session。这个方式给旧状态和新状态之间加了清晰边界,也让系统知道用户开始了一个主要变化。

详细内容

配置自定义 Group Activity(03:50

Apple 提供的入口是 GroupActivity 协议。DrawTogether 定义一个同名结构体,实现 metadata,并把类型设为 .generic

import GroupActivities

struct DrawTogether: GroupActivity {

    var metadata: GroupActivityMetadata {
        var metadata = GroupActivityMetadata()
        metadata.title = NSLocalizedString("Draw Together",
                                           comment: "Title of group activity")
        metadata.type = .generic
        return metadata
    }

}

关键点:

  • import GroupActivities 引入 SharePlay 相关框架。
  • struct DrawTogether: GroupActivity 定义一个可被系统共享的活动类型。
  • var metadata: GroupActivityMetadata 返回活动在系统 UI 中展示所需的信息。
  • GroupActivityMetadata() 创建 metadata 对象。
  • metadata.title 设置活动标题,FaceTime 共享界面会使用它。
  • metadata.type = .generic 把活动标记为自定义体验。
  • return metadata 把配置交给系统。

演讲还展示了激活流程。用户点击控制栏按钮时,App 创建 DrawTogether 实例并调用 activate()。在加入会话前,项目还需要添加 Group Activities entitlement。

import GroupActivities
import SwiftUI

struct ShareCanvasButton: View {
    var body: some View {
        Button {
            Task {
                _ = try? await DrawTogether().activate()
            }
        } label: {
            Image(systemName: "shareplay")
        }
    }
}

关键点:

  • Button 是 DrawTogether 演示中放在 ControlBar 里的入口。
  • Task 让按钮动作可以调用异步 API。
  • DrawTogether() 创建刚才定义的自定义活动。
  • activate() 请求系统启动这次 Group Activity。
  • try? await 表明激活会异步完成,并且可能失败。

接收并加入 Group Session(08:03

活动被激活后,App 需要接收 GroupSession。演讲把这一步放在 ContentView 的异步 task 中,然后把 session 交给 Canvas 对象配置,最后调用 join()

import GroupActivities
import SwiftUI

struct ContentView: View {
    @StateObject private var canvas = Canvas()

    var body: some View {
        CanvasView(canvas: canvas)
            .task {
                for await groupSession in DrawTogether.sessions() {
                    canvas.configureGroupSession(groupSession)
                    groupSession.join()
                }
            }
    }
}

关键点:

  • .task 在 SwiftUI view 出现时启动异步监听。
  • DrawTogether.sessions()GroupActivity 提供的会话异步序列。
  • for await 持续接收新的 GroupSession
  • canvas.configureGroupSession(groupSession) 把会话保存到 App 自己的模型层。
  • groupSession.join() 让当前设备加入共享体验。

演讲里的 Canvas 在配置会话时会先重置本地画布,再保存 groupSession。这样新会话不会混入旧画布的本地状态。

用 GroupSessionMessenger 同步笔画(10:06

GroupSessionMessenger 是这场演讲的核心 API。它可以发送 raw data,也可以发送结构化消息。DrawTogether 选择结构化消息,因为笔画点天然可以表示成 Codable 类型。

import GroupActivities
import SwiftUI

let messenger = GroupSessionMessenger(session: groupSession)

// 1. Define
struct UpsertStrokeMessage: Codable {
    let id: UUID
    let color: Color
    let point: CGPoint
}

// 2. Receive
for await (message, context) in messenger.messages(of: UpsertStrokeMessage.self) {
    // Handle message
}

// 3. Send
do {
    try await messenger.send(UpsertStrokeMessage(id: stroke.id, color: .red, point: point))
} catch {
    // Handle error
}

关键点:

  • GroupSessionMessenger(session: groupSession) 绑定当前 Group Session。
  • UpsertStrokeMessage 是画布同步协议中的一条消息。
  • id 标识同一条笔画,接收端可以据此追加点。
  • color 保存这条笔画的颜色。
  • point 保存本次新增的坐标点。
  • Codable 让 messenger 自动处理序列化和反序列化。
  • messenger.messages(of:) 返回异步序列,元素包含消息和上下文。
  • context 包含发送者等消息上下文。
  • messenger.send 是异步 throwing 方法,App 必须处理失败。

接收端的处理逻辑也很直接:如果本地已有相同 id 的 stroke,就把点追加进去;如果没有,就创建新 stroke,再追加到画布数组。

import SwiftUI

struct Stroke: Identifiable {
    let id: UUID
    let color: Color
    var points: [CGPoint]
}

func handle(_ message: UpsertStrokeMessage, strokes: inout [Stroke]) {
    if let index = strokes.firstIndex(where: { $0.id == message.id }) {
        strokes[index].points.append(message.point)
    } else {
        let stroke = Stroke(id: message.id,
                            color: message.color,
                            points: [message.point])
        strokes.append(stroke)
    }
}

关键点:

  • Stroke 是画布中实际保存的笔画模型。
  • id 对应 UpsertStrokeMessage.id,用于定位同一条笔画。
  • points 保存这条笔画已经收到的所有点。
  • firstIndex(where:) 查找本地是否已经存在这条笔画。
  • 找到时只追加 message.point
  • 没找到时创建新 Stroke,再加入 strokes

演讲补充了三个限制。Messenger 提供可靠、FIFO 顺序的消息投递。消息太大时,send 会抛错。短时间突发发送大量消息时,send 也可能抛错。应用协议还应该考虑版本字段,让新旧 App 可以互通。

让晚加入者拿到当前画布(16:07

晚加入者加入 Group Session 后,画布一开始是空的。演讲的处理方式是定义 CanvasMessage,把当前所有 strokes 和一个 pointCount 启发式计数一起发给新设备。

import SwiftUI

struct CanvasMessage: Codable {
    let strokes: [Stroke]
    let pointCount: Int
}

func handle(_ message: CanvasMessage,
            strokes: inout [Stroke],
            currentPointCount: Int) {
    guard message.pointCount > currentPointCount else {
        return
    }

    strokes = message.strokes
}

关键点:

  • CanvasMessage 表示一次 catch-up 数据。
  • strokes 携带当前画布的完整笔画。
  • pointCount 用作判断消息新旧的启发式指标。
  • guard message.pointCount > currentPointCount 避免旧消息覆盖新画布。
  • strokes = message.strokes 用较新的 catch-up 数据替换本地画布。

发送 catch-up 数据的触发点是 activeParticipants。已有设备观察参与者集合变化,计算新出现的参与者,只把当前画布发给这些新加入者。

import GroupActivities

func sendCatchUpMessage(
    to newParticipants: Set<GroupSession<DrawTogether>.Participant>,
    messenger: GroupSessionMessenger,
    strokes: [Stroke],
    pointCount: Int
) async {
    let message = CanvasMessage(strokes: strokes, pointCount: pointCount)

    do {
        try await messenger.send(message, to: .only(newParticipants))
    } catch {
        // Handle error
    }
}

关键点:

  • newParticipants 是从 activeParticipants 变化中计算出的新增参与者。
  • CanvasMessage(strokes:pointCount:) 创建当前画布快照。
  • messenger.send 负责发送结构化消息。
  • .only(newParticipants) 把消息限制给晚加入者。
  • catch 处理发送失败,例如消息过大或发送节流。

只在合适时显示 SharePlay 按钮(22:58

共享按钮不应该一直显示。用户没有处在可开启 Group Session 的环境时,按钮会造成误导。

GroupStateObserver 提供 eligibility 信息。演讲用它监听设备是否有资格开始 Group Session,然后动态显示或隐藏按钮。

import GroupActivities
import SwiftUI

struct ControlBar: View {
    @StateObject private var groupStateObserver = GroupStateObserver()
    let isInGroupSession: Bool

    var body: some View {
        HStack {
            if groupStateObserver.isEligibleForGroupSession && !isInGroupSession {
                ShareCanvasButton()
            }

            Button("Reset") {
                // Reset canvas
            }
        }
    }
}

关键点:

  • GroupStateObserver 观察当前设备是否适合开启 Group Session。
  • isEligibleForGroupSession 为 true 时,用户可以开始共享体验。
  • !isInGroupSession 避免用户已经在会话中时重复显示启动按钮。
  • ShareCanvasButton() 复用前面调用 activate() 的入口。
  • Reset 对应演讲中清空画布并切换到新 session 的控制项。

核心启发

1. 协作白板

  • 做什么:做一个 FaceTime 中可共同绘制的白板,支持笔、橡皮和清空画布。
  • 为什么值得做GroupSessionMessenger 可以同步每个笔画点,晚加入者可以通过 CanvasMessage 获得当前画布。
  • 怎么开始:先定义 WhiteboardActivity: GroupActivity,设置 metadata.type = .generic;再定义 UpsertStrokeMessage: Codable,用 messenger.messages(of:) 接收笔画,用 messenger.send 发送本地输入。

2. 远程结对代码批注

  • 做什么:两个人在 FaceTime 里查看同一段代码,并同步光标、选区和批注。
  • 为什么值得做:消息 payload 很小,适合传 fileIDrangecommentID 这类结构化数据。
  • 怎么开始:把当前文件标识放在 GroupActivity 的稳定信息里;把光标移动和批注创建定义成 Codable 消息;用 context 区分是谁发送的操作。

3. 共同购物清单

  • 做什么:通话时多人一起整理购物清单,添加、勾选、删除项目实时同步。
  • 为什么值得做:每次变更都能表示成小消息,符合 Messenger 对小 payload 的定位。
  • 怎么开始:定义 UpsertItemMessageDeleteItemMessage;每条消息带 item id 和版本号;晚加入者通过完整清单消息追上当前状态。

4. 桌游记分器

  • 做什么:做一个通话中的桌游计分器,所有玩家设备显示同一轮次和分数。
  • 为什么值得做:分数变化是低频结构化数据,适合用 GroupSessionMessenger 保持一致。
  • 怎么开始:用 GroupActivity 表示一局游戏;用 ScoreUpdateMessage 发送玩家 id、分数和回合数;监听 activeParticipants 给新玩家发送当前计分板。

5. 共享复盘看板

  • 做什么:多人通话时一起整理待办、问题和结论,卡片移动实时同步。
  • 为什么值得做:卡片创建、移动、改标题都可以拆成 Codable 消息,系统负责参与者间的消息投递。
  • 怎么开始:定义 CardMessageMoveCardMessageBoardSnapshotMessage;用 pointCount 类似的版本计数避免旧快照覆盖新看板。

关联 Session

评论

GitHub Issues · utterances