Build custom experiences with Group Activities
Audio & Video 高级 26m

用 Group Activities 构建自定义体验

Build custom experiences with Group Activities

2021年6月9日

在 Apple 官方观看视频

一句话判断

SharePlay 不只能同步视频播放——这场 Session 展示了如何用它做协作白板、共享画布、多人游戏等自定义场景,消息通道的灵活性远超你的想象。

这场 Session 讲了什么

Session 是 10183(Meet Group Activities)的工程实践篇,聚焦于使用 Group Activities 框架构建非媒体播放的自定义共享体验。展示了如何利用 GroupSessionMessengerGroupSession 的状态同步能力来实现协作类应用。

媒体播放的同步由 AVPlayer 自动处理,但自定义场景需要你完全自己管理状态同步。Session 用一个协作画板(Collaborative Canvas)的例子贯穿全场:多个参与者在同一块画布上画画,每个人的笔迹实时同步给其他人。关键技术挑战包括:如何高效传输增量数据(只传新画的笔画,不传整张画布)、如何处理冲突(两个人同时画在同一个位置)、以及如何处理参与者加入和离开时的状态同步。

值得深挖的点

增量状态同步 vs 全量状态同步

全量同步每次都发送完整的状态(比如整张画布的图片),简单但带宽消耗大。增量同步只发送变化的部分(比如新画的线条坐标),高效但实现复杂。Session 建议混合使用:正常操作时用增量同步,新参与者加入时用全量同步(让他快速获得当前状态)。GroupSessionMessengersend(_: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.playback category 使用,确保 App 的音频不会被 FaceTime 的通话音频中断。
  • Group Activities 支持最多 32 个参与者的群组活动,但超过 8 人时消息延迟会明显增加。
WWDC 2021