Efiwe:不靠服务器,Python 和 SQL 在低端安卓浏览器里跑起来
作者 | Chidi Nwaogu译者 | 田橙我花了一年时间,看着学生一个接一个地消失。当时我经营着一家编程培训机构,学生们会进入直播课堂,积极参与,然后突然在上课途中掉线。后来我逐个跟进才发现,几乎没有人是主动退出的。他们的网络连接根本撑不到一节课结束。在一批报名的 80 名学生中,最终只有 6 人毕业。这段经历让
我经营过一家编程培训机构,整整一年,眼看着学生一个个不见了。人进了直播课堂,起初还积极互动,可上着上着就突然掉线。我挨个去问,发现几乎没人主动退出——他们的网络根本撑不到一节课结束。报名的 80 名学生里,最后毕业的只有 6 人。
这件事让我看清:多数编程教育平台都默认了四件事——学习者有笔记本电脑、有宽带、读英文不费劲、付得起订阅费,或者所在机构已经替他付了。这些假设太普遍,普遍到大家几乎忘了它们只是假设。
我做的 Efiwe 平台,就是冲着推翻这四个假设去的。它直接在智能手机浏览器里跑,做完一次初始设置就能离线用。学生交上来的代码也在本地执行,首次加载完成后不再消耗移动数据。目前,160 个国家的 22,000 多名学习者用过 Efiwe,尼日利亚的拉各斯州、奥贡州,以及印度北阿坎德邦的学校也开展了有组织的试点。
这篇文章聊的不是教学方法,是工程实践,重点放在那些出过问题的地方。在这个领域,真正难缠的不是模型质量,而是存储、配额、冷启动和失败路径怎么设计。这些问题也不只出现在教育产品里:只要你的用户设备性能有限、网络时断时续,多半会撞上同样的坎。
一开始要想清楚的是产品怎么分发。做一款原生 Android 应用看起来最顺理成章,但我们最后没走这条路,原因有三条。
头一条是安装门槛。哪怕还没下载任何课程内容,从应用商店装一个 APK 往往也要耗掉几十 MB 流量,而学生可能正掰着手指头算自己的流量套餐。渐进式 Web 应用(PWA)不一样,学生点开就能学,缓存放到后台一点点完成,流量开销也就摊进了学习过程里。
第二条是更新频率。我们的更新是持续发的。有了 Service Worker,新版本在用户下次访问时就能生效;应用商店发新版做不到这一点。在那些用户为了省流量而拖着不更新应用的市场里,相当一部分人可能还在跑几个月前就已经修过问题的旧代码。
第三条,Web 平台让我们用一套运行时就把代码执行的问题解决了。学生得能写 Python、SQL、JavaScript 和 HTML,还要马上看到结果,而且全程不能靠服务器。浏览器里的 WebAssembly 让我们实现一次,就能在各个平台上跑。
用原生应用外壳包一个 WebView,我们也考虑过。这样同样能用 Web 运行时,加载远程内容的话更新问题也能解决。可安装门槛还是绕不过去:学生仍然得先下 APK 才能开始学,而正是这笔流量他们舍不得花。一个产品的初衷本来就是降低学习门槛,光凭这一点,就够我们拍板了。
这个选择也有代价:浏览器的存储机制得照单全收,包括它的配额限制。结果我们在生产环境里碰到的大多数麻烦,恰恰就出在这儿。
一般判一道编程题,得把学生代码发到服务器,在沙箱里跑完,再把判定结果传回来。也就是说,每试一次都要走一趟网络往返。在
所以,我们让代码在设备上直接执行。SQL 用的是 sql.js,Python 用的是编译成 WebAssembly 的 CPython,两者体积都不小,初始化也都慢。处理不好,它们就会把主线程堵死,页面跟着卡住。
最后我们立了一条规矩:任何运行时都不许在主线程初始化,也不许在真正用上之前初始化。学生选了课程,才按需拉起 Worker;挑战页面的首次渲染,绝不能等运行时初始化完。下面这段代码就是这种做法:
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 内部,初始化我们还加了保护,第二次收到初始化消息时不会重复执行。模块文件也优先从缓存里读,这样设备哪怕离线一周,学生再打开应用,运行时照样能启动。
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) {self.postMessage({ type: 'result', error: String(err?.message ?? err) });}}});
最后这段代码里有处区别值得多说两句。普通应用里,执行代码抛异常通常说明有 Bug;可在这里,大多数时候这恰恰是预期行为——学生都是初学者,学习过程本来就少不了犯错。所以代码执行链路的每一层,都得把失败当成正常情况来对待。
SQL 题目的判定,我们不直接比对输出字符串,而是比结果的结构和内容。这样只要题目允许,学生用了不同的格式、别名或列顺序,查询本身正确就照样能通过。
export function gradeSql(actual, expected, opts = {}) {if (!actual || !actual.length) return {pass: false, reason: 'empty'};const [got] = actual;const cols = got.columns.map((c) => c.toLowerCase());const want = expected.columns.map((c) => c.toLowerCase());if (!opts.orderedColumns) {cols.sort();want.sort();}if (cols.join('|') !== want.join('|')) {return { pass: false, reason: 'columns', got: cols, want };}const norm = (rows) =>rows.map((r) =>r.map(String).join('\u0001')).sort().join('\u0002');const rowsMatch = opts.orderMatters? JSON.stringify(got.values) === JSON.stringify(expected.values): norm(got.values) === norm(expected.values);return rowsMatch ? { pass: true } : { pass: false, reason: 'rows' };}
这里的 reason字段不是可有可无的摆设。它会作为后文要讲的反馈系统的输入,让我们能告诉学生:列选对了,但筛选条件有问题——而不是只丢一句答案错了。
Efiwe 的内容覆盖 246 种语言。翻译后的编程题文本会缓存在设备上,选约鲁巴语或印地语的学生,就不必每次学习都重新下载同样的内容,断网之后翻译也照样能用。
最初这些缓存我们是用 localStorage 存的。后来证明选错了,而且一下子暴露出两个问题。
第一个是容量。localStorage 一般给每个源设约 5MB 的上限,具体多少因浏览器和平台而异。一门含数百道编程题的课程,哪怕只缓存几种语言的翻译,也很容易顶破这个限制。一旦超了,写入操作就会抛出QuotaExceededError而且,假如每次写操作都没做异常处理,故障就可能从意想不到的地方钻出来。
另一个麻烦更棘手,也更不容易察觉:localStorage 是同步的,每一次读和写都会把主线程卡住。换到一台中端笔记本上,这也许只耽误几毫秒,根本没人会留意。可放到一部入门级 Android 手机上,闪存控制器本来就慢,CPU 又已经跑满,启动时来一次较大的同步读取,卡顿就会很明显——偏偏这种设备最经不起卡。我们在压缩 JavaScript 代码体积上花了不少心思,结果却在存储层悄悄埋了个阻塞主线程的点。
所以后来我们换成了 IndexedDB。它是异步的,配额一般按可用磁盘空间的比例来分,不再是固定的几 MB;结构化数据也能直接存,省掉了 JSON 序列化和反序列化那一道。
选 IndexedDB 本身不难,难的是让老用户平滑迁移过去,还不能丢数据。已经上线的用户手里还握着旧存储里的数据,其中不少人会连着离线好几天,他们什么时候跑到新代码上,谁也说不准。要是直接切,缓存的翻译和没做完的学习进度可能就无声无息地没了。为此我们安排了一个双重读取的过渡期:先读新存储,没找到再读一次旧的;读到之后把数据迁过去,然后删掉旧键。
import { get as idbGet, set as idbSet } from './idb.js';const LEGACY_PREFIX = 'efiwe.tr.';export async function readTranslation(key) {const fresh = await idbGet('translations', key);if (fresh !== undefined) return fresh;let legacy = null;try {const raw = localStorage.getItem(LEGACY_PREFIX + key);legacy = raw ? JSON.parse(raw) : null;} catch {legacy = null;}if (legacy === null) return undefined;try {await idbSet('translations', key, legacy);localStorage.removeItem(LEGACY_PREFIX + key);} catch {}return legacy;}
这个函数里有两处细节,差不多就是我们这次迁移学到的全部。其一,读旧数据的动作被放进了异常处理里,原因是万一上次会话中断,localStorage 里的数据只写进去一部分,那么JSON.parse就可能抛出异常。一次缓存解析出错把整个页面搞崩,这是我们绝对不想要的。其二,迁移的写入单独包在另一个异常处理里,因为设备存储满了的时候,读可能成功,写却会失败。真遇上这种情况,正确的做法还是先把内容交给学生,迁移留到下次再试。
如果这篇文章只能让别的工程师记住一件事,我最想让他们记住的是下面这次事故。
在尼日利亚试点时,有位老师反映,一些学生在手机上注册完账户后,学习页面一直出不来。账户建好了,登录也没问题,可课程就是加载不了,屏幕一片空白。
我们起初怀疑是低端 Android 设备的硬件兼容性。这个猜测听着挺合理,结果证明我们错了。问题集中在更便宜的设备上,我们还因此白花了不少排查时间。其实那只是相关性,不是真正的因果:便宜设备往往存储容量更小,而容量越小,越容易被占满。
真正的原因,出在课程启动流程里的一个await。启动的时候,我们会先把初始课程清单写进缓存,再往下走。可设备要是已经没空间了,写入就可能失败。这个错误没被捕获,Promise 链在调用渲染之前就断了。学生也看不到任何报错,因为程序并不是以 UI 能检测到的方式崩掉的,只是课程的渲染函数压根没机会被调用。
原来的代码大致长这样:
async function startCourse(courseId) {const manifest = await fetchManifest(courseId);await cacheManifest(courseId, manifest);renderCourse(manifest);}
改完之后是这样:
async function startCourse(courseId) {const manifest = await fetchManifest(courseId);cacheManifest(courseId, manifest).catch((err) => {reportNonFatal('manifest-cache-failed', err);setOfflineAvailability(courseId, false);});renderCourse(manifest);}
现在就算数据存不进设备,课程也能正常启动。离线功能会打折扣,我们也会明确告诉学生,但不会因此拦住他们继续学。修好之后,我们在不同的存储条件下都验证过,包括一台故意被填满存储的设备,确认问题解决才关掉这次故障记录。从收到反馈到修复并验证完,整个过程当天就完成了。
后来我们总结出一条能普遍套用的规则:启动流程里的持久化写入,必须发起后不等结果,并且明确捕获异常,绝不能用await去等写入完成。要是页面渲染非得等到写入成功才能往下走,那用户只要把手机存储塞满,就可能撞上白屏。
我们还加了一道预检查机制,让系统能主动降级功能,而不是等写入失败了才发现问题。
export async function storageBudget() {if (!navigator.storage?.estimate) {return { known: false, freeBytes: Infinity };}const { quota = 0, usage = 0 } = await navigator.storage.estimate();return { known: true, freeBytes: Math.max(0, quota - usage), quota, usage };}export async function canCacheCourse(estimatedBytes) {const { known, freeBytes } = await storageBudget();if (!known) return true;return freeBytes > estimatedBytes * 1.25;}
出于隐私保护的考虑,navigator.storage.estimate()它返回的数值本身就做过填充处理,而且只是近似值,所以只能拿来当规划依据,不能当保证。不过这些信息够我们判断:是该尝试预缓存整门课程,还是按需加载;也够我们提前提醒学生,想用离线模式就得先清出一些存储空间。
Efiwe 会针对每个学生的错误答案给出个性化反馈。尼日利亚试点中,这是实地调研评分最高的一项功能,平均 4.3 分(满分 5 分),10 位老师里有 9 位打了 4 分或 5 分。
可它也是和离线优先理念冲突最大的一项功能,因为质量最好的反馈要靠 API 调用云端模型,而这个产品最基本的设计前提,恰恰是网络可能压根不存在。
我们最后采用了两级方案,并把两者的切换机制定死。第一级是云端模型,网络可用时调用,超时时间设得很严。第二级是能力小一些的端侧方案,把对提交代码的确定性分析和本地模型合在一起。第二级始终可用,对事实的判断也始终准确,因为事实判断这块由确定性分析负责。
const HOSTED_TIMEOUT_MS = 4000;export async function getFeedback(submission, challenge) {const local = analyseLocally(submission, challenge);if (!navigator.onLine) return { ...local, tier: 'device' };try {const hosted = await withTimeout(requestHostedFeedback(submission, challenge, local),HOSTED_TIMEOUT_MS);return { ...hosted, tier: 'hosted' };} catch {return { ...local, tier: 'device' };}}function withTimeout(promise, ms) {return Promise.race([promise,new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)),]);}
有三个设计细节,我们花的时间比原先估计的多不少,才总算琢磨透。
头一条,本地分析每次都先跑,而且是无条件先跑,并不等云端出错再顶上。它开销小,结果也固定,跑完还会当作上下文塞进云端请求。这样一来,云端模型拿到手的是一份已经做过初步诊断的代码,不用白手起家重新分析,输出更稳,提示词也能短一截。
第二条,我们会去看navigator.onLine,但不会完全信它。这个属性只能说明设备上有没有网络接口,证明不了网络真能用。手机连的是要登录认证的 Wi-Fi,或者移动网络已经卡死,它照样可能返回。所以我们只拿它当快速判断离线的信号。真正让学生不至于干等的,是超时机制。true
最后一条,响应里会记下反馈是哪一级给出的。答案到底出自哪条路径,我们不藏着。这不只是自家遥测数据的事,还因为能力弱一些的第二级方案给出的反馈,表达方式本来就不一样。
早先我们的反馈很简陋,基本就是对和错。学生交上来的是,还是压根没弄懂函数是什么,收到的评语都差不多:“这里错了,应该改成这样。”consolee.log("hi")
试点里有一位老师把这问题点得很准。学生是拿到了纠正意见,可要是没有她坐在旁边带着改,学生根本不知道从哪儿下手。这跟我们想做一个能规模化服务学生的编程导师,初衷就拧了。
办法是先量一量学生的答案离正确有多远,再定给哪种反馈。只差一点,就点出具体哪个字符出了问题;错在代码结构上,就讲清楚背后的概念。
export function proximityHint(submitted, expected) {const d = levenshtein(submitted, expected);if (d === 0) return null;if (d > 2 || Math.abs(submitted.length - expected.length) > 2) {return null;}const diff = firstDivergence(submitted, expected);if (diff.kind === 'extra') {return `快答对了!你多写了一个 "${diff.char}"。删掉它就对了。`;}if (diff.kind === 'missing') {return `快答对了!你漏写了字母 "${diff.char}"。加上它就对了。`;}return `快答对了!检查一下第 ${diff.index + 1} 个字符。`;}
团队内部我常打个比方:两位老师改同一份作业。经验少的在错答案旁边打个红叉,写上正确答案;经验多的会准确圈出错处,针对要改的地方留提示。这一调整,让我们的反馈从前者变成了后者。整套流程全由确定性逻辑完成,所以离线状态下反馈效果也一模一样。
尼日利亚试点里,10 位老师中有 8 位认为,学生没有自己的设备是最大的坎。有的手机兄弟姐妹共用,有的从老师那儿借,还有的是一节课上一组学生轮流使。
那会儿我们的身份认证模型默认一台设备对应一名学生。两个学生共用一部手机,就得反复退出、重新登录。网络慢的时候,每切一次都可能搭进去一分钟课堂时间。更糟的是,学生没同步到服务器的本地学习进度,也可能就这么丢了。
所以账户切换不是锦上添花的便利功能,是实际使用环境逼出来的必需项。现在一台设备最多存 20 个用户档案,轻点一下就能换,不用彻底退出登录。落到架构上,就是每个本地存储空间都得按用户档案单独划命名空间,让学习进度、翻译缓存和运行时状态各管各的,而不是全局共享。
const scoped = (profileId, store) => `${store}::${profileId}`;export const progressStore = (profileId) => ({get: (k) => idbGet(scoped(profileId, 'progress'), k),set: (k, v) => idbSet(scoped(profileId, 'progress'), k, v),});
产品要是面向多人共用设备的市场,这套机制越早加越好。上线之后再拆一个全局共享的本地存储系统,代价比第一天就做好命名空间隔离高得多。
前面说的经验,大多来自两次实际部署。印度北阿坎德邦有四家机构,用受统一管理的设备跑我们的平台。尼日利亚拉各斯州和奥贡州,十所学校的十位老师做了八周试点,几乎全靠学生自己的手机。
两次试点一对比,哪些是平台自身的问题、哪些是使用环境带来的问题,就能分开了。对工程开发路线来说,这才是试点最值钱的地方。尼日利亚那边,离线可靠性平均打分 4.0 分(满分 5 分),低分全指向同一种情形:首次缓存还没做完,网就断了。这不是离线功能本身不靠谱,是首次运行时的任务执行顺序出了岔子。先把离线运行必需的最小内容缓存好,再处理其他可选内容,代码层面就能解决。至于学生能不能拿到设备,压根不是代码能解决的事。硬装作技术什么都能摆平,只会白费工程开发时间。
最让我泄气的发现跟语言有关。支持 246 种语言,本来就是我们做这个平台的重要理由之一。可试点里,10 位老师有 4 位从没试过本地语言界面,还有 1 位试过,却不知道怎么用。所有老师在入门培训时都看过这项功能的演示。
问题出在语言切换按钮搁在页面底部。一个正忙着刷编程挑战的学生,没理由专门滚到页面底下去。功能明明在,有文档,也演示过,实际用起来却跟隐形了一样。
功能好不好找,和好不好用是两码事。用户得事先知道去哪儿找才能发现某个功能,那对大多数人来说,这功能就等于没有。我们正把语言切换控件挪到学生学习时自然能看到的地方。更关键的收获是,现在发任何功能前都会先查一个问题:不问“告诉用户位置,他能不能找到”,而问“用户按自己的实际操作路径用产品,会不会自然碰上它”。
还有三个问题悬而未决。与其给个看上去圆满的收尾,我更想如实说一说。
在配置最差的机器上,WebAssembly 运行时的冷启动速度依旧没达到我的预期。模块若要从缓存而非网络加载,流式编译也只能解决其中一部分麻烦。
端侧模型的质量,才是离线反馈能力真正的天花板。眼下确定性分析扛下的活儿,还是比我希望的多。它之所以表现还行,很大程度是因为编程题有能验证的正确答案;换到答案没那么明确的领域,就没了这层保险。
存储这一块同样棘手。配额数据是填过填充的,缓存清理机制不透明,各家浏览器对持久化存储请求的支持也不一致。本文提到的这些防御性设计,只是在补 Web 平台自身的短板,并没有真正把问题解决掉。persist()
不过这些都不足以成为放弃这种开发方式的理由。另一条路是继续假定每个学生都有笔记本电脑和宽带,那大多数真正需要这类产品的人就会被挡在门外。这条路的工程难度确实更大,可它能服务的人群规模,也因此多了数十亿。
https://www.infoq.com/articles/building-offline-first-android-app-low-end-devices/
声明:本文为 InfoQ 翻译,不代表平台观点,也不构成投资建议,未经许可禁止转载。