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 很小,适合传
fileID、range、commentID这类结构化数据。 - 怎么开始:把当前文件标识放在
GroupActivity的稳定信息里;把光标移动和批注创建定义成 Codable 消息;用context区分是谁发送的操作。
3. 共同购物清单
- 做什么:通话时多人一起整理购物清单,添加、勾选、删除项目实时同步。
- 为什么值得做:每次变更都能表示成小消息,符合 Messenger 对小 payload 的定位。
- 怎么开始:定义
UpsertItemMessage和DeleteItemMessage;每条消息带 item id 和版本号;晚加入者通过完整清单消息追上当前状态。
4. 桌游记分器
- 做什么:做一个通话中的桌游计分器,所有玩家设备显示同一轮次和分数。
- 为什么值得做:分数变化是低频结构化数据,适合用
GroupSessionMessenger保持一致。 - 怎么开始:用
GroupActivity表示一局游戏;用ScoreUpdateMessage发送玩家 id、分数和回合数;监听activeParticipants给新玩家发送当前计分板。
5. 共享复盘看板
- 做什么:多人通话时一起整理待办、问题和结论,卡片移动实时同步。
- 为什么值得做:卡片创建、移动、改标题都可以拆成 Codable 消息,系统负责参与者间的消息投递。
- 怎么开始:定义
CardMessage、MoveCardMessage和BoardSnapshotMessage;用pointCount类似的版本计数避免旧快照覆盖新看板。
关联 Session
- Coordinate media experiences with Group Activities — 先讲媒体类 SharePlay 的 session 接收、播放准备和加入流程,是本 session 的前置基础。
- Meet Group Activities — 介绍 Group Activities 的整体能力和 SharePlay 体验类型。
- Design for Group Activities — 补充 SharePlay 体验的入口、状态和协作设计。
- Coordinate media playback in Safari with Group Activities — 讲解在网页和 companion app 中使用 Group Activities 协调媒体播放。
评论
GitHub Issues · utterances