开发者生态
morning
AI 编程导师挑战低端 Android 手机!不用服务器,Python 和 SQL 直接在浏览器里运行
摘要
我花了一年时间,看着学生一个接一个地消失。当时我经营着一家编程培训机构,学生们会进入直播课堂,积极参与,然后突然在上课途中掉线。后来我逐个跟进才发现,几乎没有人是主动退出的。他们的网络连接根本撑不到一节课结束。在一批报名的 80 名学生中,最终只有 6 人毕业。 这段经历让我意识到,大多数编程教育平台都建立在四个默认前提之上。这些前提太过普遍,以至于人们几乎忘了它们只是一些假设:学习者有笔记本电脑...
Web
Worker
WebAssembly
runtime
workerPromise
new
Efiwe
PWA
APK
Python
2026-11-01
1 阅读
约10分钟阅读
作者:Chidi Nwaogu
字号:
我花了一年时间,看着学生一个接一个地消失。当时我经营着一家编程培训机构,学生们会进入直播课堂,积极参与,然后突然在上课途中掉线。后来我逐个跟进才发现,几乎没有人是主动退出的。他们的网络连接根本撑不到一节课结束。在一批报名的 80 名学生中,最终只有 6 人毕业。 这段经历让我意识到,大多数编程教育平台都建立在四个默认前提之上。这些前提太过普遍,以至于人们几乎忘了它们只是一些假设:学习者有笔记本电脑、有宽带网络、能够轻松阅读英语,而且付得起订阅费,或者所在机构已经替他们付了费。 我开发的 Efiwe " 平台,就是要打破所有这些假设。它可以直接在智能手机浏览器中运行,只需完成一次初始设置,之后就能离线使用。学生提交的代码也在本地执行,首次加载完成后,不再需要消耗移动数据流量。目前,Efiwe 已被 160 个国家的 22,000 多名学习者使用,并在尼日利亚的拉各斯州、奥贡州,以及印度北阿坎德邦的学校开展了有组织的试点。 这篇文章不讨论教学方法,而是讨论工程实践,尤其是那些曾经出过问题的地方。在这个领域,真正棘手的问题并不在于模型质量,而在于存储、配额、冷启动和失败路径的设计。这些问题也远不止存在于教育产品中。只要产品面向的是设备性能有限、网络连接时断时续的用户,就很可能遇到同样的挑战。 为什么选择 PWA,而不是原生应用 我们首先要决定的是如何分发产品。开发一款原生 Android 应用似乎是最自然的选择,但我们最终没有这么做,原因有三个。 首先是安装门槛。即使还没有下载任何课程内容,从应用商店安装一个 APK,也往往需要消耗几十 MB 的流量,而学生可能正精打细算地使用自己的流量套餐。渐进式 Web 应用(PWA)则允许学生点开就开始学习,缓存可以在后台逐步完成,流量开销也随之分摊到学习过程中。 其次是更新频率。我们会持续发布更新。使用 Service Worker,新版本可以在用户下次访问时生效。应用商店发布新版本却做不到这一点。在那些用户为了节省流量而推迟更新应用的市场中,可能有相当一部分用户仍在运行几个月前就已经修复过问题的旧代码。 第三,Web 平台让我们可以用一套运行时解决代码执行问题。我们需要学生能够编写 Python、SQL、JavaScript 和 HTML,并立即看到运行结果,而且整个过程不能依赖服务器。浏览器中的 WebAssembly 让我们可以一次实现,在各个平台上运行。 我们也考虑过用原生应用外壳包装一个 WebView。这样同样可以使用 Web 运行时,如果加载的是远程内容,也能解决更新问题。但它解决不了安装门槛:学生仍然需要先下载 APK,才能开始学习,而这笔流量可能正是他们舍不得花的。对于一个从根本上就是为了降低学习门槛而设计的产品来说,这一点已经足以让我们作出决定。 这个选择也有代价:我们必须接受浏览器的存储机制,包括它的配额限制。而我们在生产环境中遇到的大部分麻烦,恰恰都来自这里。 不依赖服务器执行代码 通常,评判一道编程题的答案,需要把学生提交的代码发送到服务器,在沙箱中执行,再把判定结果返回给学生。这意味着每尝试一次,都要经历一次网络往返。在尼日利亚伊杰布奥德的一间教室里,网络连接并不稳定,而学生往往需要尝试十到十二次才能解出一道题。这种模式根本行不通。 所以,我们让代码直接在设备上执行。SQL 使用 sql.js,也就是编译为 WebAssembly 的 SQLite;Python 使用 Pyodide,也就是编译为 WebAssembly 的 CPython。两者的体积都不小,初始化也都很慢。如果处理不当,它们都会阻塞主线程,让页面卡住。 最后我们定下了一条规则:任何运行时都不能在主线程初始化,也不能在真正需要之前初始化。学生选择课程时,才会按需启动 Worker,而且挑战页面的首次渲染绝不能等待运行时初始化完成。下面这段代码展示了这种做法: // runtime-host.js(主线程) let workerPromise = null; export function getRuntime(kind) { if (!workerPromise) { workerPromise = new Promise((resolve, reject) => { const worker = new Worker('/workers/runtime.js', { type: 'module' }); const timer = setTimeout( () => reject(new Error('runtime-init-timeout')), 45_000 ); worker.addEventListener('message', function onReady(e) { if (e.data?.type !== 'ready') return; clearTimeout(timer); worker.removeEventListener('message', onReady); resolve(worker); }); worker.postMessage({ type: 'init', kind }); }); } return workerPromise; } 在 Worker 内部,我们还对初始化做了保护,确保第二次收到初始化消息时不会重复执行。模块文件也会优先从缓存中加载,这样即使设备已经离线一周,学生再次打开应用时,运行时依然能够启动。 // workers/runtime.js let runtime = null; self.addEventListener('message', async (e) => { const { type, kind, code, fixture } = e.data; if (type === 'init') { if (!runtime) runtime = await boot(kind); self.postMessage({ type: 'ready' }); return; } if (type === 'run') { try { const result = await runtime.exec(code, fixture); self.postMessage({ type: 'result', result }); } catch (err) { // 学生代码出错是正常数据,不是系统异常,绝不能因此终止 Worker。 self.postMessage({ type: 'result', error: String(err?.message ?? err) }); } } }); 最后这段代码里的区别值得多说几句。在普通应用中,执行代码时抛出异常通常意味着出现了 Bug。但在这里,大多数时候这恰恰是预期行为,因为学生都是初学者,学习过程本来就少不了犯错。因此,代码执行链路中的每一层,都必须把失败当作正常情况来处理。 对于 SQL 题目的判定,我们不会直接比较输出字符串,而是比较结果的结构和内容。这样一来,只要题目允许,即使学生使用了不同的格式、别名或列顺序,只要查询正确,就仍然能
这篇文章对您有帮助吗?
订阅66必读
每日精选科技资讯,直达你的邮箱