
公开课上出的问题印象很深。老师点下一页演示实验步骤教室大屏切过去了坐在后排的几台平板上还停在上一页。老师以为没点到又点了一次这次前排的一半跟着翻了页后排才慢吞吞跳过来。课后看日志才发现事情比看上去复杂。第一次的切换指令发出去后有两台设备因为无线漫游延迟了 3 秒才收到老师第二次点击产生的指令版本更高先到了部分设备。等第一次的指令姗姗来迟它在那些已经翻页的设备上又执行了一遍把页面拉回了上一页。老师第二次点的那一下效果被抵消了。问题的根子在指令没有先后关系。网络不保证顺序靠谁先到谁先生效必然出乱子。指令要能自我排序课堂同步的每一条指令都带上单调递增的版本号exportinterfaceSlideCommand{commandId:string// 全局唯一用于幂等判定lessonId:string// 属于哪一堂课slideIndex:number// 要切到第几页version:number// 由教师端统一分配单调递增issuedAt:number// 教师端本地时间仅用于诊断}exportinterfaceStudentView{studentId:stringlessonId:stringslideIndex:numberappliedVersion:number// 已经生效的最大版本receivedAt:number}version由教师端的一个计数器分配不用时间戳。两台设备的时间戳可能只差几毫秒而同一个毫秒里老师确实可能点两次计数器不会有这个问题。commandId用来防重复。网络重传是常事同一条指令到两次第二次不能再执行一遍。issuedAt这个字段不参与任何判定只留在日志里。排查“谁先谁后”的问题时教师端本地时间和指令版本对照着看能分清是版本分配乱了还是网络真的乱序——这两种情况的修法完全不同前者改计数器后者改信道。学生端只认更大的版本收到指令后的判定写成四种结果每一种在界面上都有不同的处理exporttypeApplyResultapplied|stale|duplicate|mismatchexportfunctionapplyCommand(view:StudentView,command:SlideCommand,appliedIds:Setstring):ApplyResult{if(command.lessonId!view.lessonId){returnmismatch}if(appliedIds.has(command.commandId)){returnduplicate}// 版本不比当前新说明是一条迟到的旧指令if(command.versionview.appliedVersion){returnstale}returnapplied}stale这个分支就是公开课上翻车的地方。旧指令到达时不是没用了就丢掉这么简单——它需要被丢弃并且不产生任何副作用包括不刷新最后更新时间否则教师端的回执统计会以为设备刚刚响应过。appliedVersion记的是最大值而不是最后一次。设备可能先收到版本 8、再收到版本 6如果记录的是“最后一次应用”版本 6 会把记录拉回去然后版本 7 到达时又会被误判为可用。这个错误在一次压测里暴露过表现为少数设备的翻页顺序错乱。四种结果在界面上的处理各不相同整理成一张表结果触发条件界面处理刷新回执时间applied课程匹配、版本更新、commandId 没见过翻页并记录 appliedVersion是stale版本不大于当前生效版本静默丢弃否duplicatecommandId 已在 appliedIds 中静默丢弃否mismatchlessonId 与当前课程不一致丢弃并记一条警告日志否两张表合起来看才发现stale那行的“否”是关键。教师端的回执统计以回执时间为准旧指令一旦刷新了它统计面板就会显示一台刚刚还在响应的设备突然落后而这种“落后”是假的。迟到的学生要拿快照不要重放一个容易被漏掉的场景学生中途才打开应用进课堂。如果让他从版本 1 开始重放所有指令会出现两种糟糕的情况——重放期间不断翻页以及中途某条指令丢失导致最终停在不正确的页面上。正确做法是拉一份当前快照exportinterfaceLessonSnapshot{lessonId:stringslideIndex:numberversion:numbercapturedAt:number}exportfunctionjoinLesson(view:StudentView,snapshot:LessonSnapshot):StudentView{if(snapshot.lessonId!view.lessonId){returnview}return{...view,slideIndex:snapshot.slideIndex,appliedVersion:snapshot.version,receivedAt:snapshot.capturedAt}}快照的version直接写进appliedVersion。这样在快照之前产生的所有指令都自动变成stale不需要单独维护一份黑名单。晚到一两秒的旧指令会被安静地丢掉。快照里只有三个字段这是有意的。课件材料本身走另一条通道同步信道只搬状态。把整门课的页面数据塞进快照的话几十台设备同时加入时信道会被堵死翻页指令反而排在后面。加入课堂因此分成两步先拉快照对齐状态再按需拉课件两步之间界面显示“正在加入课堂”。学生端先看到正确的页码再看到这一页的内容比反过来体验好。教师端要知道谁没跟上光把指令发出去不够。老师需要知道当前有多少台设备真正跟上了否则像公开课上那样人站在讲台上根本判断不出是网络问题还是自己没点中。学生端应用指令后回推一条回执教师端按版本号统计exportinterfaceReceiptRecord{studentId:stringappliedVersion:numberonline:boolean}exportinterfaceProgressSummary{total:numberinSync:numberlagging:numberoffline:number}exportfunctionsummarize(records:ReceiptRecord[],currentVersion:number):ProgressSummary{letinSync:number0letlagging:number0letoffline:number0records.forEach((record:ReceiptRecord){if(!record.online){offline1return}if(record.appliedVersioncurrentVersion){inSync1}else{lagging1}})return{total:records.length,inSync,lagging,offline}}lagging是关键指标。它大于零就说明还有设备没跟上教师端可以给出有 5 台设备正在同步的提示而不是让老师盲目地反复点击。统计用的比较是而不是。学生端有可能通过快照拿到一个比当前广播版本更高的版本——比如教师的广播还在路上同时另一个学生端已经汇报过更新的状态。用等号会导致这些设备被误算成落后。四秒还收不到回执怎么办同步超时也要处理。指令发出去 4 秒还没有回执设备很可能已经掉线constRECEIPT_TIMEOUT_MS:number4000exportfunctionmarkTimeout(records:ReceiptRecord[],currentVersion:number,now:number,lastPushedAt:number):ReceiptRecord[]{if(now-lastPushedAtRECEIPT_TIMEOUT_MS){returnrecords}returnrecords.map((record:ReceiptRecord){if(record.appliedVersioncurrentVersion){return{...record,online:false}}returnrecord})}判定掉线用的是“超过超时时间仍未跟上”而不是“没收到回执”。学生端可能回了回执但版本没跟上比如它收到了指令却因为别的原因没渲染这两者要分开处理前者是网络问题后者是客户端问题。统计相关的参数集中列一下改任何一个之前先看这张表参数取值说明RECEIPT_TIMEOUT_MS4000超过这个时间未跟上就标记离线按教室网络取的 4 秒版本比较运算快照版本可能高于当前广播版本用等号会误判统计基准currentVersion教师端计数器的当前值所有回执都跟它比那条是压测逼出来的。有学生端通过快照拿到了比当前广播版本还高的版本用等号比较时它被算进 lagging教师端提示“有 1 台设备正在同步”可那台设备的画面明明是对的。这种错的提示比不提示更伤老师会反复点击。评审问答定稿之前过了三轮评审记下三个被反复追问的点。问版本号为什么用计数器不用时间戳两台设备的时间戳可能只差几毫秒而同一个毫秒里老师确实可能点两次时间戳会撞车计数器不会。另外学生端中途加入时直接继承快照的 version不需要额外维护一份“已见指令”的黑名单——所有版本低于快照的指令自动变成 stale规则本身完成了去重。问stale 指令为什么不刷新“最后更新时间”刷新了教师端的回执统计会以为这台设备刚刚响应过。试讲课上踩过这个坑面板显示还有 3 台没跟上点开每台的回执时间都是新的老师完全没法判断到底是网络慢还是统计在说谎。不回刷新面板上的数字和教室里真实的情况才一致。问快照的 version 直接写进 appliedVersion万一是旧快照呢快照由教师端按当前状态生成lessonId、version、capturedAt 一起发出来学生端先比对课程再整体覆盖。真正要防的是快照还在路上、新指令又到了的情况——后到的指令只要版本更高照样 applied因为applyCommand只比版本大小快照写入的版本只是成了新的基准线。joinLesson因此不做版本比较它负责的是初始状态填充不是状态更新。目录结构ClassLink/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 教师控制台与座位矩阵 │ ├─ features/ │ │ ├─ CommandModel.ets # 指令与视图状态结构 │ │ ├─ CommandApplier.ets # 版本判定与幂等 │ │ └─ ProgressAggregator.ets # 回执统计与超时标记 │ ├─ entryability/EntryAbility.ets │ └─ adapters/ │ └─ LessonChannelAdapter.ets # 课堂内网信道 ├─ entry/src/main/resources/ └─ build-profile.json5CommandApplier在学生端和教师端的本地预览里复用同一份代码。老师在讲台上看到的预览页和学生端用的是同一套版本判定避免了老师看到的是第 8 页、学生看到的是第 6 页而界面都没提示这种情况。课堂场景里的网络条件通常比办公环境差一个教室几十台设备共享一个接入点。真机部署时还需要给信道加一层本地优先级把翻页指令排在答题数据前面。这部分属于传输层不影响上面的版本判定逻辑。