Efiwe 是一套面向网络条件差、设备性能弱的场景构建的编程学习平台,目标是让学生仅凭手机浏览器就能学代码。它的设计要点可以概括为三条:平台侧不要假定学习者拥有笔记本、宽带、英语阅读能力和付费能力;真正难啃的骨头是存储效率、配额管理、冷启动优化与失败路径容错,而非模型本身;离线优先与本地执行可以显著压低流量消耗和对持续连线的依赖。本文面向前端架构师、教育科技工程师以及针对新兴市场做产品的技术负责人。

从80人报名、6人毕业说起:四个被默认为成立的假设

作者曾经经营一家编程培训机构,用一年时间观察学生流失。学生进入直播课堂、参与互动,随后在课程中途掉线。逐一回访后他发现,几乎没有人是主动退课的,问题在于他们的网络连接根本支撑不了一整节课。一批 80 名报名者中,最终完成学业的只有 6 人。

这段经历让作者看清了一件事:绝大多数编程教育产品都站在四个隐性预设之上——学习者手里有笔记本电脑、家里有宽带、读英文不费劲、负担得起订阅费用(或者其所在机构代为支付)。这些预设太过常见,以至于很多人已经把它们当成客观事实,而忘记了它们只是假设。Efiwe 的出发点就是把这四条逐一推翻。

具体做法是:平台能直接在智能手机浏览器中运行,首次配置完成后即可离线使用;学生提交的代码同样在本地执行,首屏资源加载之后不再产生移动数据流量。截至目前,Efiwe 已被 160 个国家的 22,000 多名学习者使用,并在尼日利亚拉各斯州、奥贡州以及印度北阿坎德邦的学校组织了正式试点。

作者强调,文章讨论的不是教学方法,而是工程实现,而且聚焦那些真正踩过坑的地方。在这些产品中,卡住开发者的往往不是模型质量,而是存储、配额、冷启动和失败路径的设计。这些难题也不只存在于教育产品里:任何面向设备性能有限、网络频繁中断的用户的产品,都可能遇到同样的挑战。

为何放弃原生APK而选择PWA:安装门槛、更新频率与一套Web运行时

第一个工程决策是产品以什么形式分发。开发原生 Android 应用看起来是最顺理成章的选择,团队最终没有走这条路,理由有三条。

其一是安装门槛。哪怕一门课程都还没下载,从应用商店安装一个 APK 也可能消耗几十 MB 流量,而目标学生往往对自己的流量套餐精打细算。渐进式 Web 应用(PWA)让学生点开即学,缓存可以在后台逐步补齐,流量开销被摊薄到日常学习过程中。

其二是更新频率。团队会持续发布新版本,借助 Service Worker,新版本可以在用户下次访问时自动生效;应用商店分发做不到这一点。在那些用户为省流量而一再推迟更新应用的市场里,很可能有相当比例的用户仍在运行几个月前就修复过的旧代码。

其三是代码执行。产品需要学生编写 Python、SQL、JavaScript 和 HTML 并立即看到结果,且全程不依赖服务器。浏览器中的 WebAssembly 让团队可以只实现一次,就获得跨平台运行能力。

团队也评估过用原生外壳包一个 WebView:这样同样能用上 Web 运行时,远程加载内容也能解决更新问题,但安装门槛依旧——学生必须先下载 APK 才能开始学习,而这份流量恰恰是他们不愿意花的。对于一个以降低门槛为设计初衷的产品,这一条就足以拍板。

这条路线也有代价:必须接受浏览器存储机制及其配额限制。后来在生产环境中遇到的大部分麻烦,正是从这里来的。

把CPython和SQLite编进WebAssembly:运行时不进主线程、不用不加载

常规的编程题判分流程是:把学生代码发往服务器,在沙箱中执行,再把判定结果传回。这意味着每一次尝试都要经历一次网络往返。在尼日利亚伊杰布奥德的教室里,网络连接本就时好时坏,而学生平均要尝试十到十二次才能解出一道题,这种模式根本无法运转。

团队因此改让代码在设备本地运行:SQL 走 sql.js,即编译为 WebAssembly 的 SQLite;Python 走 Pyodide,即编译为 WebAssembly 的 CPython。两者体积都不小,初始化都很慢,处理不慎就会阻塞主线程造成页面卡死。

最终确定的规则是:任何运行时都不得在主线程中初始化,也不得在真正需要之前初始化。学生选定课程后,才按需启动对应 Worker,而且挑战页面的首次渲染绝不能等待运行时初始化完成。实现中,主线程通过 getRuntime(kind) 创建 Worker,设置了 'runtime-init-timeout' 超时保护,并在收到就绪消息(onReady)后清除定时器。Worker 内部同样做了防护,第二次收到初始化消息时不会重复初始化;模块文件优先从缓存加载,因此即便设备已经离线一周,学生再次打开应用时运行时仍能启动。

代码注释里写得很直白:『学生代码出错是正常数据,不是系统异常,绝不能因此终止 Worker。』这一区别值得展开。在普通应用里,执行代码时抛异常通常意味着有 Bug;但在 Efiwe 中,这大多是预期行为——使用者是初学者,犯错本就是学习过程的一部分。所以代码执行链路中的每一层都必须把失败当作正常情况处理。

SQL 判题也不采用直接比对输出字符串的做法,而是对比结果的结构和内容。这样在题目允许的范围内,学生使用了不同的格式、别名或列顺序,只要查询逻辑正确仍可通过。判分函数 gradeSql(actual, expected, opts) 会返回带上 reason 字段的结果,这个字段并非装饰——它会作为后续反馈系统的输入,让平台能告诉学生『你选对了列,但筛选条件有问题』,而不是只丢一句『答案错了』。

从localStorage迁移到IndexedDB:5MB上限与主线程阻塞的双重教训

Efiwe 提供 246 种语言的内容。翻译后的题目文本缓存在设备上,选约鲁巴语或印地语的学生不必每次学习都重新下载同样内容,离线状态下翻译也照样可用。

这套缓存最初用 localStorage 实现,事后证明是个错误决定,而且一次暴露了两个问题。

问题一在容量。localStorage 对每个源的限制通常在 5MB 左右,因浏览器和平台而异。一个包含数百道题的课程,哪怕只缓存少数几种语言的翻译,也很容易顶到这个上限;一旦越界,写入就会抛出 QuotaExceededError。如果每次写入都没有做好异常处理,错误就会在意想不到的地方冒出来。

问题二更隐蔽:localStorage 是同步接口,每次读写都会阻塞主线程。在一台中端笔记本上,这或许只值几毫秒,没人会在意;但在一部入门级 Android 手机上,闪存控制器慢、CPU 本就满负荷,启动期间一次较大的同步读取就可能造成肉眼可见的卡顿,而这恰恰发生在最经不起卡顿的机器上。团队花了不少力气优化 JavaScript 体积,却在存储层静悄悄埋下一处主线程阻塞点。

于是他们迁移到 IndexedDB:异步接口,配额通常按可用磁盘空间比例分配而非固定几 MB,而且可以直接存结构化数据,省去 JSON 序列化与反序列化的往返。

选型容易,难的是让既有用户无损迁移。已经上线的用户数据还留在旧存储中,其中不少会连续离线好几天,因此他们何时执行到新代码完全无法预测。若直接切换,缓存的翻译和未完成的学习进度可能无声消失。团队因此设置了双重读取过渡期:先读新存储,找不到再读一次旧存储;命中后写入新存储,再删掉旧键。函数 readTranslation(key) 从 'translations' 读取,旧的同步存储只在迁移时读取一次。

这段实现里有两处细节,几乎浓缩了这次迁移的全部经验。其一,旧数据读取被包在异常处理里——如果上一次会话中断导致 localStorage 只写入了一部分,JSON.parse 就会抛异常,绝不能因为一次缓存解析失败就整页崩溃;此时按数据不存在处理。其二,迁移写入被单独包在另一个异常处理中——设备存储已满时,读取可能成功而写入失败,这种情况下正确的做法依然是先把内容交到学生手上,迁移留到下次再试。

一次白屏事故:启动链路上的await把渲染卡死了

如果这篇文章只让同行记住一件事,作者最希望是这个线上故障。

尼日利亚试点期间,有老师反映部分学生在手机上注册完账户后,学习页面始终出不来。账户创建成功,登录也正常,但课程加载不出来,屏幕一片空白。

团队最初怀疑是低端 Android 设备的硬件兼容性问题。这个猜测看上去很合理,实际是错的。由于问题集中在更便宜的机型上,团队为此浪费了不少排查时间。便宜的设备往往存储容量更小,而容量越小越容易被占满——这只是一种相关关系,并非真正的因果。

真正的原因是课程启动流程中的一个 await。启动时平台会先写入初始课程清单到缓存,再继续后面的步骤。可当设备已无剩余空间时,写入会失败;由于该错误没有被捕获,Promise 链在调用渲染之前就断了。学生这边看不到任何报错,因为程序并没有以 UI 能检测到的方式崩溃,只是课程渲染函数根本没有机会被调用。

修改前的逻辑是:写入失败会被拒绝,后面的渲染代码永远执行不到。修改后,持久化存储降级为一项可选优化而非前置条件——写不进去时抛出 'manifest-cache-failed' 事件,课程照常启动。离线能力会受影响,平台也会明确告知学生,但绝不因此挡住学习。修复后,团队在多种存储条件下验证,包括一台被故意塞满存储的设备,确认问题消失才关闭故障记录。从收到反馈到修复并验证完成,全部在当天做完。

由此总结出一条可推广的规则:启动流程中的持久化写入必须以发起即走的方式处理、显式捕获异常,绝不能用 await 等它落盘。只要页面渲染依赖写入成功,用户把手机构储空间一填满就可能撞上白屏。

团队还补了前置检查,让系统主动降级,而不是等到写失败才发现问题。函数 storageBudget() 与 canCacheCourse(estimatedBytes) 配合工作:无法确定容量时不应成为拒绝缓存的理由,同时要为额外开销预留空间。出于隐私保护的考虑,navigator.storage.estimate() 返回的数值经过填充处理、本身只是近似值,因此只能拿来规划、不能当作保证。但用它判断该预缓存整门课程还是按需加载已经够用,也足以提前提醒学生:若想在离线模式下学习,请先清理一点存储空间。

云端超时降级两级反馈:试点评分4.3分背后的取舍

Efiwe 会针对每个学生的错误答案给出个性化反馈。在尼日利亚试点中,这是现场调研评分最高的一项功能,平均 4.3 分(满分 5 分),10 位老师中有 9 位给了 4 分或 5 分。

但这项功能与离线优先理念的冲突也最大:效果最好的反馈来自经由 API 调用的云端模型,而这个产品最基本的设计前提恰恰是网络可能不存在。

团队最后落地的方案分两级,并对切换机制做了明确规定。第一级是云端模型,仅在网络可用时调用,并配有严格的超时;第二级是能力较弱的端侧方案,把确定性分析与本地模型组合在一起。第二级始终可用,而且对事实的判断始终准确,因为事实判定由确定性分析部分兜底。

实现上,getFeedback(submission, challenge) 会先跑开销极低但必然执行的原生分析,再通过 withTimeout(promise, ms) 给云端调用套上时限。

有三个设计细节,团队花的时间比预想更长。

第一,本地分析前置且无条件执行,而不是等云端失败后才顶上。它成本低、结果确定,还会作为上下文注入云端请求:云端模型拿到的是一份已完成初筛诊断的代码,不用从零分析,输出更稳、提示词更短。

第二,参考 navigator.onLine 但绝不轻信。该属性只能说明设备有没有网络接口,证明不了网络真能用。手机连着一个需要登录认证的 Wi-Fi,或者移动网络已经卡死,它照样可能返回 true。所以它只能被当成快速判断离线的信号,真正让学生免于长时间干等的是超时机制。

第三,响应中会记录本次反馈出自哪一级,平台不隐藏答案由哪条路径生成。这不仅服务于遥测数据,也因为第二级反馈的表达风格不同,需要如实标注。

早期的反馈极其粗糙,基本只有对错。学生写的是 consolee.log("hi"),还是压根没理解函数是什么,收到的都是类似『这里错了,应该改成这样』的格式化提示。试点中一位老师准确指出了这一问题:学生拿到纠错意见,但如果老师不在旁边,学生根本不知道从何改起——这与规模化充当编程导师的目标背道而驰。

解决办法是先估算学生的答案距离正确结果有多近,再决定给哪一类反馈。差一点点时,指出具体哪个字符出了问题;属于结构层面的错误时,则转向解释背后概念。proximityHint(submitted, expected) 先判断是否细微错误,不是就转概念性反馈;是则定位 diff,把角色转换为字符串后给出类似『快答对了!检查一下第 X 个字符』的提示(X 为 diff.index + 1)。

作者在团队内部常打一个比方:两位老师批改同一份作业。经验少的老师打一个红叉,再写上正确答案;经验更丰富的老师会准确圈出错误所在,并写上针对这处需修改的提示。这一次调整让平台的反馈从前者变成了后者,而且几乎完全由确定性逻辑完成,因此即使离线,反馈体验也完全一致。

10位教师中8位说会遇到的障碍:一台设备支持20个档案并存

尼日利亚试点中,10 位老师有 8 位认为最大的障碍是学生没有属于自己的设备。有的手机是兄弟姐妹共用的,有的是向老师借的,有的则是一组学生在一节课里轮流使用。

而平台当时的身份模型默认一台设备对应一名学生。两名学生共用一部手机,就得反复退出和新登录;在网速不好的环境里,每次切换都可能耗掉课堂上一分钟。更糟的是,尚未同步到服务器的本地学习进度也可能随之丢失。

于是,账号切换从可有可无的便利变成了真实场景下的硬需求。现在一台设备最多可保存 20 个用户配置,轻点一下即可切换,不必彻底退出。架构上,团队必须为每个本地存储命名空间按用户档案隔离,让学习进度、翻译缓存和运行时状态互不串味,避免全局共享。命名空间以 `${store}::${profileId}` 的形式派生,读取进度时同样带 profileId 参数。

作者的建议是:产品若面向多人共用设备的市场,这套机制应尽早引入。上线后再来拆解一个全局共享的本地存储体系,代价远高于从第一天就按命名空间隔离开。(想先手动清理某个档位下的缓存,可以进入 设置 → 存储管理 → 按用户档案清除课程缓存。)

两场对照试点:离线可靠性4.0分与静默缺席的语言切换按钮

文中的大部分经验来自两次实地部署。在印度北阿坎德邦,有四家机构在统一管理的设备上运行平台;在尼日利亚拉各斯州与奥贡州,来自十所学校的十位老师开展了为期八周的试点,学生几乎全靠自己的手机。

把两次试点放在一起比较,团队得以区分哪些问题出在平台本身、哪些属于环境使然。试点对工程路线更新是一大价值就在这儿。尼日利亚的离线可靠性,平视来自自身经历得分 4.0(满分 5 分),其中全部低分都指向同一种情形:首次缓存还没完成,网络就断了。这不是离线能力不可靠,而是首次运行的任务调度顺序出了问题。把离线运行所需的最小内容优先缓存下来、再处理其余可选内容,就能从代码层面消除。相比之下,学生有没有设备可用根本不是代码能解的,硬要用技术手段冒充解法只会白白消耗工期。

最让作者受挫的发现与语言有关。支持 246 种语言本是平台开发的重要动机之一,但在试点里,10 位老师中有 4 位从未尝试过本地语言界面,给出还会答一次怎么用。这是实际难点,四、细节里的技术点数】且所有老师都在入门培训时观看过该功能的演示。

问题就出在语言切换按钮被放在了页面最底部。一个埋头一道道完成编程挑战的学生,完全没有理由特意滚到页面最底。功能确实存在、有文档、演示过,可在实际使用中近乎隐形。

可发现性不等于好。如果用户事先不知道去哪找才能看见某个功能,它就等于还不存在——大部分用户都属于不会预先知道的那部分。作者正在把语言切换控件搬到学生自学习流里必然经过的位置,但比这次改善更重要更具普遍需求的恐怕带来个洞察,是会后问用户:“如果告诉他们位置能否找到”,而是模拟用户体验得出“接理演过程学产品的时间会不会自然遇到它”,也大概率就要考虑调整。

还没解决的三件件是王问题:冷启动、端侧模型上限与不透明的存储

作者特意没有写入故作圆满的收尾来写文章,列出三件尚在未竟之列。

一,在性能最弱的没备上,WebAssembly 运行时的冷启动提升还不进去?,