· engineering· 约 24 分钟精读
端侧离线教育沙盒的概念问答交互契约:两套作答面、单次提交的边界与只记正确的记录缺口
同一套题库在 QubitLab 里长出了两张面孔:沙盒实验室的整卷测验与奥德赛关卡的前置概念题。本文逐个函数量出这两条作答链路的判定、锁卷、揭晓与记录,给出十道题与九十六道题的正确项位置分布,并说明为什么“答错”在这套系统里几乎不留痕迹。
一、一个学生按下第二个选项之后
点开任意一关的导读页,十六比九的课程卡播完,往下是一道概念题,四个选项,没有提交按钮。手指落在第二个选项上,那一行变成青色描边,右侧出现一枚空心对勾,页面底部原本灰着的按钮亮起。整个过程里,学生不知道自己这一下是对是错。
另一处,沙盒实验室的双缝实验做完,点开“概念测验”,同样是四选一,选好之后还要按一次“确认并核对答案”;按下去的那一帧,正确项转绿、错选项转红,下面是物理解析和一条趣味拓展。
两处界面看起来是同一个功能,代码里却是两套不同的契约:判定时机不同、揭晓策略不同、写盘字段不同、重测语义不同。本文把这两条链路逐个函数翻一遍,把能量化的部分量化,最后说明这套设计在教学上留下了什么缺口,以及缺口该怎么补。
二、两套作答面:入口、题量与结算
QubitLab 的概念题分成两组。一组挂在沙盒实验室下,由 QuizSet.allQuizzes 提供,六个实验室共十个 QuizQuestion;一组挂在奥德赛关卡的导读页,九十六个关卡各带一道,字段直接写在 PuzzleLevel 上(quizQuestion、quizOptions、quizCorrectIndex、quizExplanation,四个都是可选类型)。
这两组的差别不止在入口,而在整条契约上。
| 维度 | 沙盒实验室测验 | 奥德赛关卡概念题 |
|---|---|---|
| 入口 | 实验室详情页的测验卡 | 关卡导读页的课程卡下方 |
| 题量 | 六实验室共十题,一次走完整卷 | 九十六关各一题,一次只做一题 |
| 选项 | 固定四项,顺序不洗牌 | 固定四项,顺序不洗牌 |
| 提交 | 选好再按“确认并核对答案” | 点选即提交,没有确认动作 |
| 锁卷 | 本卷内提交后不可改选 | 当次视图内点过即不可改选 |
| 揭晓 | 正确项转绿、错选转红,解析展开 | 只给选中项描边与空心对勾,答案不出现 |
| 记录 | quizScore 写一个 0 到 100 的整数 | levelQuizMastered 插入关号,再重算星级 |
| 失败留痕 | 只在结算时体现为较低百分比 | 无,答错不写任何字段 |
| 重测 | 可无限重测,每次覆盖上一次 | 每次重新进入关卡可再答,直到答对 |
| 后续影响 | 证书逐实验室显示百分比 | 决定该关第三颗星,进而影响章节解锁 |
前五行是界面差异,后五行是契约差异。真正决定这套题教学效果的,是后五行。
三、沙盒测验的四段链路
图左是沙盒测验的四段,图右是奥德赛关卡对应的四段。先看左边。
3.1 状态机只有五个变量
LabQuizView 的作答状态全部是本视图的 @State:currentIndex、selectedAnswer、hasSubmittedAnswer、correctAnswersCount、isCompleted。选项按钮的第一行守卫是 guard !hasSubmittedAnswer else return,也就是提交之后本卷内不能再改选;submitAnswer() 只做三件事——置位、按对错给分并触发对应触觉、把选中项留在状态里。推进逻辑在 advanceQuestion(),最后一题走结算分支。
这套状态简短、好读,代价是所有东西都在视图里,进程一退就没了。
3.2 得分行的分母取错了量
题头那一行是这么写的:得分取 correctAnswersCount,分母取 currentIndex。而 currentIndex 是当前题号(从零起),提交后、推进前不变。于是真机上会看到这样一串:
- 进入第一题:0 / 0(审计截图里这一帧就是 0 / 0);
- 答对第一题那一下:1 / 0;
- 推进到第二题:1 / 1;
- 答对第二题:2 / 1(一直显示到按下“查看测试总结”);
- 随后切到结算页。
分母始终是“已经翻过去的题数”,与总题数无关。两题卷面上“二比一”这样的分数会真实出现在屏幕上。
结算页另算一次 Int(Double(correctAnswersCount) / Double(questions.count) * 100.0),这一处是对的。所以写进本机的分数没有问题,错的只是学生在答题途中读到的那一行自我评估。这类错误的麻烦之处在于它不崩、不报错,只是安静地给出一个错误的分母。
3.3 进度条把“位置”当成了“进度”
ProgressView(value: Double(currentIndex), total: Double(questions.count)) 的分子是题号,不是已答数。两题卷停在第 1 题时是百分之五十,答完立刻切页,进度条永远到不了一百。进度条在这里表达的是“翻到第几页”,而学生读到的是“做了多少”。
3.4 中途退出等于没做
写盘只有一处:advanceQuestion() 的最后分支 progress.quizScore[lab.rawValue] = scorePercent。此前所有答案都在 @State 里。右上角“关闭”直接 dismiss(),sheet 也没有禁用下滑关闭,两条退出路径都不弹确认。答完第一题就退出,回到实验室详情页,卡片上仍然是“开始测验”,刚才那次作答不存在。
这件事实在不必如此:离线应用本可以把这份状态写进本机存档,不需要网络、不涉及同步、也没有隐私成本。这里不是不能,是没做。
3.5 覆盖写与“只留最好”的两种态度
同一个 LabProgress 里,星级用 max 保留历史最好(completeLevel 里 if masteryStars > currentBest),沙盒测验分则是直接赋值。于是重测一次答错三题,六十分会盖掉此前的满分。两种策略并存不算矛盾,但界面上“重测”这个按钮在两种语义下含义不同,用户无从预判。
顺带一处:模型层写了 recordQuizScore(labId:score:),全工程零调用,视图绕过它直接改字典。方法里以后想加的校验、留痕、审计,也就都没有落点。
3.6 六个实验室的题量分布
| 实验室 | 题数 | 正确项位置 | 是否免费 |
|---|---|---|---|
| 波粒二象性 | 2 | 第二项、第二项 | 免费 |
| 叠加与比特 | 2 | 第三项、第三项 | 免费 |
| 量子隧穿 | 2 | 第二项、第一项 | 付费 |
| 幽灵纠缠 | 1 | 第二项 | 付费 |
| 电路设计 | 2 | 第二项、第二项 | 付费 |
| 密码通信 | 1 | 第二项 | 付费 |
十道题里有六道落在四个付费实验室,免费侧只有四道;题量与实验室的概念密度也没有对应关系——纠缠是整个沙盒里概念最重的一块,只配了一道题。
四、奥德赛关卡:单题、不揭晓、只记正确
4.1 点选即提交
quizOptionButton 里没有确认动作:先 guard selectedQuizOption == nil else return,然后一次触觉、一次赋值,紧接着 if idx == correct 就写掌握。误触即一次作答,没有撤销。
4.2 对错都不说
选项的视觉状态只取决于“是否被选中”:背景转青、描边转青、右侧一枚未填充的对勾。正确索引只在判定时用一次,从不参与渲染。学生拿到的第一条反馈是结算页的星数:三颗表示答对,两颗表示答错;两颗那一页还会写“物理装置已全部点亮!返回导读页答对概念题可获得第 3 颗金星!”——用一句话事后否定了刚才那次作答。
另外,进入实验的闸门只检查“有没有答过”,不检查“答得对不对”:只要选中过一项,底部按钮就亮。题目在这里的作用是强制一次思考,不是筛选。
4.3 答错不留任何痕迹
recordLevelQuizMastery 的第一行是 guard isCorrect else return。答错:不插入、不计数、不落盘。承载它的字段是 levelQuizMastered: Set<Int>?,一个可选集合,只能表达“这一关已掌握”。它无法表达“答过、但错了”“错了几次”“上次选了哪一项”。
所以 refreshStateForCurrentLevel() 只有两态:已掌握就把选中项预置成正确项,未掌握就清空。想让界面说一句“上次你选了第三项”,现有模型做不到。
4.4 星数由模型层说了算
triggerWin 先算“答对给三星、答错给两星”,再调 completeLevel。而 completeLevel 里会把请求值截到零到二之间,再按掌握集合决定是否补到三:
其中 s 是调用方请求的星数,Q 是已掌握的关号集合。任何调用方传一百颗星,最多也只拿到两颗——这是正确的防御位置,规则不该由视图决定。代价是同一套规则在两处各写了一遍,读代码的人得先确认哪一份有约束力。
4.5 “只能选择一次”的三条失效路径
界面标题写着“科学概念问答(只能选择一次)”,选项中也有注释说只能选一次。实际有三条路径可以再答:
- 结算页的“返回导读页补答题目”按钮,里面显式把选中项置空,注释原文就是重置以便再选;
- 退出关卡再进,
onAppear触发refreshStateForCurrentLevel(),未掌握时同样置空; - 同一关可以反复进出,没有次数上限,也没有冷却。
所以真实语义是“每次进入只能提交一次”,而不是“这一关只能答一次”。前者的作用是防止在一屏之内反复试错,后者才是门槛。两者在教学上相差很远:一个允许想想再来,一个要求一次想对。
4.6 前置题允许后补
题目的定位写在模型注释里:实验前的概念检查。但 recordLevelQuizMastery 的后半段是,若该关已有两颗星,直接把星级提到三颗。也就是说实验做完再回来补答概念题,第三颗星照样到手,不用重做实验。
设计上这不算错,它避免惩罚先动手的学生;但它让“先想清楚再做”的顺序约束失效——学生可以先把装置点亮,再对着四个选项慢慢试。
五、题库的统计面
把两组题的正确项位置数一遍,得到一张很集中的分布:
| 选项位置 | 沙盒十题 | 奥德赛九十六题 | 合计 | 占比 |
|---|---|---|---|---|
| 第一项 | 1 | 8 | 9 | 8.5% |
| 第二项 | 7 | 78 | 85 | 80.2% |
| 第三项 | 2 | 10 | 12 | 11.3% |
| 第四项 | 0 | 0 | 0 | 0% |
三个可以当场算出来的结论。
第一,固定选第二项的盲选策略,沙盒卷面拿七十分,奥德赛有百分之八十一点二五的关卡直接得到第三颗星。若四个位置等概率,盲选期望是四分之一;当前位置分布把期望抬到约八成,等于把四选一压成了一点二五选一。
第二,一百零六道题的正确答案没有一道在第四项。按一二三四顺序轮换,任何一题最多三次必然命中;沙盒两题卷和奥德赛单题关都成立。
第三,选项顺序不洗牌。同一道题每次呈现完全相同,“上次第三项是对的”这种位置记忆长期有效,与第一条叠加。
三条叠起来,第三颗星在当前题库上无法区分“想过”与“试过”。这不是安全漏洞,是一道测量学问题:区分度被位置分布吃掉了。
5.1 干扰项的语义距离
| 题目要点 | 正确项 | 典型干扰项 | 排除所需知识 |
|---|---|---|---|
| 哪种日常技术依赖隧穿 | 闪存与固态硬盘 | 木质铅笔、机械钟表发条、自行车链条齿轮 | 常识即可排除 |
| CNOT 的作用 | 控制位为一则翻转目标位 | 关闭量子计算机、强制清零、加快时钟频率 | 知道门操作即可排除 |
| 在缝上观测光子 | 波函数坍缩成两条粒子条纹 | 干涉纹更亮、光子超光速、探测器起火 | 基本物理常识即可排除 |
干扰项与正确项的距离过大,排除法可以完全绕过概念本身。这对启蒙阶段不算错,荒谬干扰项能降低挫败感;但它决定了这套题测的是“有没有听说过”,而不是“有没有想明白”。若目标是后者,干扰项应当来自同一概念族内的常见误解,例如把“观测导致坍缩”与“观测导致相位退相干”并列,逼学生分辨两者。
六、写了却不显示的解析
九十六道关卡题每题都写了物理解析,并且通过 currentQuizExplanation 把七种语言的译文接了出来:日语、法语、西班牙语、德语、简体中文、繁体中文各九十六条,韩语六十四条(其余三十二关回落英语原文)。文本本身涉及 656.3 纳米的 Hα 跃迁、位置与动量的不确定性乘积下限、CHSH 参数的二倍根号二这类具体结论,不是随手写的安慰话。
然后视图层对解析字段的引用数是零。
也就是说,为九十六道题准备了解析,翻成了七种语言,最后没有任何界面把它显示出来。它现在只是包体里一段死数据:占体积、需要维护、会随题库改动同步过期,却不产生任何教学作用。
两种收尾都合理:要么答完之后显示解析,与沙盒侧对齐;要么明确“本题不提供解析,解析在导读视频里”,并把字段删掉,免得后来的人以为有界面。现状是第三种——字段在、译文在、界面没有。
同一屏上还有一处语言覆盖的不对称。题干与选项的译文覆盖简繁日韩四种,而这一屏的界面框架文字只分简繁两档,其余语言一律英文。结果是日语用户读到日文题干、配英文的“得分”“确认并核对答案”,德语用户整屏英文。这不是翻译质量问题,是两套字符串表各自覆盖了不同的语言集合。
七、记录缺口的后果
三处消费方读到的口径各不相同:
| 消费方 | 读什么 | 显示什么 |
|---|---|---|
| 实验室详情页 | 单实验室分数 | “N% 得分”,并决定按钮是开始还是重测 |
| 设置页 | 已测实验室个数 | “已测 / 六” |
| 证书页 | 逐实验室分数 | “测验 N%” |
| 家长报告 | 关卡星级与总星数 | 完全没有测验成绩 |
沙盒侧只存一个整数百分比:两题卷的五十分,可能是第一题错,也可能是第二题错。奥德赛侧只存一个集合:只有掌握与未掌握。想在端侧回答“这个学生在观测与坍缩这一族概念上是否反复出错”,现有字段一个都不够。
这一点要说清楚:缺口不是隐私设计换来的。百分之百离线的取舍是“数据不出设备”,不是“数据不存在”。本机完全可以存一份逐题作答记录——题号、选项、时间序、连续次数——不上传、不同步、不进任何遥测,仍然满足数据不出设备这条底线。缺的是记录面,不是隐私面。
八、如果要改:五条工程线
- 选项洗牌并映射回去。呈现层打乱顺序,正确索引随置换映射回原位置;记录里存选项的稳定标识而不是位置,否则洗牌之后历史数据无法解释。
- 逐题作答落盘。给进度模型增加作答记录(题标识、选项标识、时间序、连续次数),答错也写。判定仍可以只看掌握集合,但诊断从此有据。
- 分数取最大值。测验分与星级对齐,重测不降级;若坚持覆盖,就在重测按钮上明示“本次将覆盖上次成绩”。
- 断点续答。把当前题号与已答结果写进进度,关闭面板前给一次确认,进度条分子改成已答数。
- 语义统一。把“只能选择一次”改成“本次进入只能提交一次”,或者真的做成一次;实验前与实验后二选一,在文案和模型里同时落地。
这五条都不需要联网,也不改变数据不出设备的边界。
九、能力边界
- 四选一的一次命中不等于理解,位置偏差让它更弱。
- 三次内必中,意味着第三颗星在这个题库上无法区分想过与试过。
- 答错零留痕,等于把最有价值的教学信号丢在本机之外——不是丢在云端,是根本没记。
- 解析不显示本身不构成缺陷;写了不显示才会让后续维护重复劳动。
- 全部判定在本机完成,没有行为数据回流,题库参数只能靠人工评审调整。这是离线架构的固有代价,不是疏漏。
十、结语
把作答当成一个状态机来看,两套面的代码都很短:一个守卫、一次置位、一个分支、一次写盘。短到容易被当成同一个功能。
真正决定教学效果的,不是四选一这个形式,而是四件事的组合:什么时候锁、锁了告不告诉对错、答错记不记、重测算不算数。QubitLab 在沙盒侧选择了锁后揭晓、整卷记录、重测覆盖,在关卡侧选择了点选即锁、全程不揭晓、只记正确、可以重来。两种选择各有理由,但它们不该由两份独立的代码各自决定——一旦要回答“这个学生到底学会了没有”,两份记录必须能对上同一道题。
参考文献
[1] Apple Inc. ProgressView — Apple Developer Documentation. https://developer.apple.com/documentation/swiftui/progressview
[2] Apple Inc. Managing model data in your app — Apple Developer Documentation. https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
[3] Apple Inc. Human Interface Guidelines — Providing feedback. https://developer.apple.com/design/human-interface-guidelines/providing-feedback
[4] Next Generation Science Standards. https://www.nextgenscience.org/
[5] W3C. Web Content Accessibility Guidelines (WCAG) 2.2. https://www.w3.org/TR/WCAG22/
[6] ISO/IEC 25010:2023 Systems and software Quality Requirements and Evaluation — Product quality model. https://www.iso.org/standard/78176.html