用 Group Activities 构建自定义体验
Build custom experiences with Group Activities
2021年6月9日
一句话判断
SharePlay 不只能同步视频播放——这场 Session 展示了如何用它做协作白板、共享画布、多人游戏等自定义场景,消息通道的灵活性远超你的想象。
这场 Session 讲了什么
Session 是 10183(Meet Group Activities)的工程实践篇,聚焦于使用 Group Activities 框架构建非媒体播放的自定义共享体验。展示了如何利用 GroupSessionMessenger 和 GroupSession 的状态同步能力来实现协作类应用。
媒体播放的同步由 AVPlayer 自动处理,但自定义场景需要你完全自己管理状态同步。Session 用一个协作画板(Collaborative Canvas)的例子贯穿全场:多个参与者在同一块画布上画画,每个人的笔迹实时同步给其他人。关键技术挑战包括:如何高效传输增量数据(只传新画的笔画,不传整张画布)、如何处理冲突(两个人同时画在同一个位置)、以及如何处理参与者加入和离开时的状态同步。
值得深挖的点
增量状态同步 vs 全量状态同步
全量同步每次都发送完整的状态(比如整张画布的图片),简单但带宽消耗大。增量同步只发送变化的部分(比如新画的线条坐标),高效但实现复杂。Session 建议混合使用:正常操作时用增量同步,新参与者加入时用全量同步(让他快速获得当前状态)。GroupSessionMessenger 的 send(_:to:) 方法可以指定接收者——向新参与者发送全量状态,向老参与者发送增量更新。
参与者离开时的状态清理
当参与者离开 GroupSession 时,他之前发送的数据仍然存在。Session 建议为每个参与者的数据添加 participantID 标签,当参与者离开时可以选择保留或清理他的贡献。对于协作画板场景,保留离开者的笔迹通常更合理(就像 Google Docs 保留离开者的输入一样)。但对于”临时指示”类的数据(比如光标位置),应该在离开时立即清除。
代码片段
// 自定义 GroupActivity:协作画板
struct CollaborativeCanvas: GroupActivity {
var metadata: GroupActivityMetadata {
GroupActivityMetadata()
.$title.set("协作画板")
.$type.set(.generic)
}
let canvasID: UUID
}
// 画笔数据的消息类型
struct StrokeMessage: Codable {
let participantID: UUID
let strokeID: UUID
let points: [CGPoint]
let color: CodableColor
let width: CGFloat
let isComplete: Bool
}
struct CodableColor: Codable {
let red, green, blue, alpha: Double
}
// 画板 ViewModel:管理状态同步
class CanvasViewModel: ObservableObject {
@Published var strokes: [UUID: Stroke] = [:]
private var session: GroupSession<CollaborativeCanvas>?
private var messenger: GroupSessionMessenger?
func configureSession() async {
for await newSession in CollaborativeCanvas.sessions() {
self.session = newSession
self.messenger = GroupSessionMessenger(session: newSession)
// 监听新参与者加入
Task {
for await participants in newSession.$activeParticipants.values {
handleParticipantsChanged(participants)
}
}
// 监听画笔消息
Task {
for await (message, participant) in newSession.messages(of: StrokeMessage.self) {
handleStrokeMessage(message, from: participant)
}
}
newSession.join()
}
}
// 发送新画的笔画
func sendStroke(_ stroke: Stroke) async {
let message = StrokeMessage(
participantID: session?.localParticipantID ?? UUID(),
strokeID: stroke.id,
points: stroke.points,
color: CodableColor(from: stroke.color),
width: stroke.width,
isComplete: stroke.isComplete
)
try? await messenger?.send(message)
}
// 处理收到的笔画
func handleStrokeMessage(_ message: StrokeMessage,
from participant: GroupSessionParticipant) {
DispatchQueue.main.async {
if let existing = self.strokes[message.strokeID] {
// 更新已有笔画(增量添加点)
existing.points.append(contentsOf: message.points)
} else {
// 新笔画
self.strokes[message.strokeID] = Stroke(
id: message.strokeID,
points: message.points,
color: UIColor(
red: message.color.red,
green: message.color.green,
blue: message.color.blue,
alpha: message.color.alpha
),
width: message.width
)
}
}
}
}
// 新参与者加入时发送全量状态
func handleParticipantsChanged(_ participants: Set<GroupSessionParticipant>) {
guard let session = session,
let messenger = messenger else { return }
let newParticipants = participants.subtracting(session.activeParticipants)
for newParticipant in newParticipants {
// 向新参与者发送完整的画布状态
Task {
let fullState = CanvasStateMessage(
canvasID: session.activity.canvasID,
strokes: Array(strokes.values),
participantCount: session.activeParticipants.count
)
try? await messenger.send(fullState, to: [newParticipant])
}
}
}
// 全量状态消息
struct CanvasStateMessage: Codable {
let canvasID: UUID
let strokes: [Stroke]
let participantCount: Int
}
最佳实践
- 消息体大小控制在 50KB 以内——Group Activities 的消息通道不是为传输大数据设计的。如果一笔画太复杂,拆分为多条消息分批发送。
- 为每条消息添加单调递增的序列号,接收端用序列号做消息排序和去重。网络传输不保证顺序。
- 画布数据同时持久化到本地——FaceTime 通话结束后,用户应该能继续编辑之前的内容,不丢失。
还有什么值得关注
GroupSession的$state属性可以监听会话状态变化(joined、invalidated、suspended),在网络不稳定时做本地缓存和重连。- 自定义体验可以配合
AVAudioSession的.playbackcategory 使用,确保 App 的音频不会被 FaceTime 的通话音频中断。 - Group Activities 支持最多 32 个参与者的群组活动,但超过 8 人时消息延迟会明显增加。