3.2 举手连麦:用消息做信令
互动直播课堂和看录播最大的区别,就是学生能上台。编程课上到一半,老师点学生上台讲自己的解题思路,或者上台一起改代码,这种环节全靠连麦。从技术上看,连麦本质上是把学生从“拉流观众”切换成“推流角色”,而驱动这个切换的指令,我们是拿 IM 自定义消息做的。
这一节讲两件事:举手连麦的信令怎么设计,以及端到端时序怎么不乱。
为什么不用专门的信令通道
最早我想给连麦单独对接一套信令服务,评估了一周放弃了。原因是课堂里的控制指令总共不到十种,自建一套可靠的投递、重试、去重机制成本太高,而 IM 群消息已经把投递这件事解决了——消息发到群,订阅了群的人就能收到,离线的人还能收到推送。权衡过程里参考了《ADP 智能体开发指南》中关于长连接网关的讨论,结论是这种规模的信令完全没必要自建,滥用基础设施反而增加故障面。
于是我们把课堂里所有控制指令都搬到自定义消息上,额外只加两样东西:序号和确认。
多麦位与排队
真实课堂不会只有一个学生举手。我们设了两个学生麦位,意味着第三个举手的人要排队。处理方式是在举手信令里带一个期望麦位字段,老师同意时指定几号麦位;没有空位时,老师端可以把举手请求标记为“排队”,学生端状态机进入排队分支:界面显示前面还有几人,且在前面有人下麦时自动提醒老师“下一位可以上场了”。
排队提醒是一条系统通知,不占举手列表,也不计入未读——刻意保持轻量,排队信息是服务性的,不该用红点催学生。
这里还有个公平性问题:举手早的学生应该优先,但老师经常想点特定的人(比如这节课还没发过言的学生)。我们的做法是举手列表按时间排序,但给老师一个“优先点名”标记的功能:老师可以备注哪个学生今天还没发言,列表里高亮提示,点不点还是老师决定。工具提供信息,不替老师做决定。
信令设计表
每条信令都是一条自定义消息,用类型字段区分动作,载荷字段承载参数:
| 动作 | 消息类型 | 载荷字段 | 谁来处理 |
|---|---|---|---|
| 举手申请 | 自定义消息 | 学生编号、课节号、问题简述 | 老师、助教 |
| 同意连麦 | 自定义消息 | 学生编号、麦位编号 | 该学生 |
| 拒绝连麦 | 自定义消息 | 学生编号、拒绝原因 | 该学生 |
| 上麦成功 | 自定义消息 | 学生编号、切换后的角色标识 | 全体客户端 |
| 下麦申请 | 自定义消息 | 学生编号、下麦原因 | 老师、助教 |
| 强制下麦 | 自定义消息 | 学生编号、操作者编号 | 该学生、全体客户端 |
注意“上麦成功”和“强制下麦”都是广播给全体的。这是踩坑之后才加的——如果不广播,其他学生端的连麦列表和老师端对不上:画面里明明有人在讲话,举手列表里却空空如也,学生以为系统坏了,纷纷在群里问“怎么没人”。
载荷设计有一条原则:字段越少越好,但要把“谁、在哪个课节、要干嘛”说全。尤其课节号必须有,学生同时挂着上午下午两个课堂的群时,没有课节号信令会串到另一节课去。
举手连麦时序表
整条链路六个步骤,每一步谁触发、消息往哪走、界面怎么变,全部定死:
| 步骤 | 触发者 | 消息流向 | 界面变化 |
|---|---|---|---|
| 1 举手 | 学生 | 学生发往老师端 | 老师端举手列表加一 |
| 2 查看举手 | 老师 | 无,本地处理 | 列表展示学生的问题简述 |
| 3 同意 | 老师 | 老师发往该学生 | 学生端弹出上麦确认 |
| 4 上麦 | 学生 | 学生广播给全体 | 角色切换,开始推流 |
| 5 讲完下麦 | 学生 | 学生发往老师端 | 老师端确认麦位释放 |
| 6 确认下麦 | 老师 | 老师广播给全体 | 列表清除,连麦结束 |
实际跑起来,第 3 步到第 4 步最容易出问题:老师点了同意,同意信令却在路上丢了,学生端毫无反应,老师以为学生磨蹭,又点一次。所以每条信令都带确认回执,超时就重发,详见下面状态机。
麦位有限的课堂(我们定 2 个学生麦位)还要处理排队:老师同意上限到达后,举手列表里后面的学生自动转为排队状态,并在界面里标出前面还有几人。排队状态也走同一个状态机,只是多了个序号。老师端还可以一键“清空麦位”,把两个麦位同时释放——习题课连麦频繁时这个操作很常用。
状态机说明
举手这件事在学生端是一个小状态机,四个状态:
- 等待:举手信号已发出,等老师响应。界面显示“已举手”,可随时取消。
- 同意:收到同意信令,进入准备上麦,本地开始申请麦克风权限。
- 拒绝:收到拒绝信令,界面回显原因,状态复位。
- 超时:60 秒内无响应,自动取消举手。这个状态必须有,否则老师忘了处理,学生端会一直转圈。
“超时”这个状态是上线第二天被学生投诉后补上的。自己测试时很难发现——因为测试时总会顺手点同意。拒绝状态要回显原因也是被投诉逼出来的:学生只看到一个“请求被拒绝”,不知道为什么,家长还以为老师针对自家孩子。
踩坑:信令丢失或重复
信令的丢和重复是两类问题,解法都是加序号:
- 防丢失:每条信令带自增序号,接收方收到后回执确认;发送方超时未收到回执就重发一次。
- 防重复:接收方维护最近已处理的序号集合,重复序号直接丢弃,不重复弹窗、不重复举手。
- 防乱序:拒绝信令比同意信令晚到时,以序号大的那条为准,避免状态回退。
还有一条铁律:上麦后 TRTC 侧的角色切换,一律以“上麦成功”这条广播信令为准,不要在客户端本地自行切换。两端状态不一致是课堂事故的常见来源——有一次学生端自己切了角色开始推流,但广播信令没到,其他端看到的是黑麦,老师不得不中断讲课排查。那次调试到半夜的经历不想再来一次。后来我们把这类排错步骤整理成了《DSH 运维指南》式的排查清单,值班时照着走能省一半时间。
协议演进:从文本拼指令到自定义消息
值得分享的一段弯路:第一版连麦功能我是拿文本消息做的。约定学生发“举手”两个字、老师回复“同意加学生名”,客户端解析字符串。上线第一周就崩了:一个学生在群里聊“我也想举手”,直接被解析成举手信令;另一名学生家长用手机代发消息,多带了一个空格,解析失败但界面显示已举手。两次事故之后我们全部改成自定义消息:动作靠类型字段区分,参数靠结构化载荷承载,解析逻辑只认结构不认自然语言。改完后这类事故一次都没再出。
这个教训我后来归纳成一条设计原则:协议层永远不要用自然语言承载机器指令,无论看起来多省事。自然语言的歧义性迟早会咬你一口。
压测与演练
信令系统跑顺之后,我们做了一次课堂压力演练:拉 30 个测试账号同时在一个模拟课堂里,程序自动执行“举手、同意、上麦、下麦”的完整循环,持续两小时共约 3000 轮。重点观察三项:
- 消息延迟:从举手到老师端列表加一,正常应在 1 秒内,压测中偶发 3 秒也能接受,超过 5 秒就要查。
- 状态一致性:演练结束后,所有客户端的举手列表和麦位状态必须完全一致,任何不一致都说明渲染或广播有漏洞。
- 重复与丢失:对账每轮的发出序号集合与收到序号集合,差额就是丢了或重了的信令。
这次演练查出两个隐蔽 bug:一是序号集合只存了最近 50 条,高频轮转时老序号被挤出去,重放旧信令时发生了重复弹窗;二是确认回执只发了不校验,老师端网络抖动时同意信令会被重发到学生端两次。都在上线前修掉了,没等到真实课堂事发。压力演练投入不大,但性价比极高,建议任何要做互动课堂的团队都跑一遍。
弱网下的降级处理
举手连麦发生在课堂中段,往往是家里宽带最紧张的时候(晚高峰家长也在看视频)。弱网下信令的取舍要事先想清楚:
- 举手信令允许重发:举手是低频操作,学生点了没反应会再点一次,重发无害,靠去重挡掉即可。
- 同意信令重发要显眼:老师点同意后学生没上麦,老师端要明确提示“未收到确认,可再点一次”,而不是默默失败。
- 极端情况降级到老师报学号:网络差到信令都发不出时,老师直接口头报学号让学生开麦,视频流的优先级永远高于互动花活。互动是锦上添花,课讲不下去才是事故。
这套降级逻辑的关键是:承认基建会失败,并准备好失败时怎么上课。我们的课堂容灾预案包括完全断网时的口头点名方案,助教人手一份。
客户端实现要点
服务端设计清楚之后,客户端的实现也有几个必须守住的点:
- 信令和普通消息分通道渲染:举手、禁言等控制消息绝不能混进聊天列表里滚动,否则学生讲课时一串控制指令把提问淹没。我们在客户端做了两层过滤,渲染层只认聊天类消息。
- 界面状态只跟随信令变化:举手列表、麦位状态全部由收到的信令驱动刷新,不做任何本地推测。这一条和前面的铁律是同一个逻辑的客户端版本。
- 断线重连后的状态对齐:网络恢复后第一件事是拉一次课堂完整状态(当前麦位、举手队列),再开始接收新信令。否则重连瞬间收到的信令和本地旧状态叠加,会出现“幽灵举手”。
断线重连这块还有个多端同时在线的特殊场景:学生用手机举了手,又切到平板上课。两端同时在线时以最后操作为准,服务端按序号裁定,客户端收到序号比自己新的状态信令就无条件覆盖本地。家长兴起时会觉得这是 bug:“我明明举手了怎么列表里没有”,其实就是另一端取消了。
