我花了一年时间,看着学生一个接一个地消失。当时我经营着一家编程培训机构,学生们会进入直播课堂,积极参与,然后突然在上课途中掉线。后来我逐个跟进才发现,几乎没有人是主动退出的。他们的网络连接根本撑不到一节课结束。在一批报名的 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.jslet 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 题目的判定,我们不会直接比较输出字符串,而是比较结果的结构和内容。这样一来,只要题目允许,即使学生使用了不同的格式、别名或列顺序,只要查询正确,就仍然能够通过。
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 字段并不是可有可无的装饰。它会作为后面介绍的反馈系统的输入,让我们能够告诉学生:你的列选对了,但筛选条件有问题,而不只是简单地告诉他答案错了。
持久化存储:为什么 localStorage 不够用了
Efiwe 提供 246 种语言的内容。翻译后的编程题文本会缓存在设备上,这样选择约鲁巴语或印地语的学生,就不需要每次学习都重新下载相同的内容,而且离线后也能继续使用翻译。
最初,我们用 localStorage 来保存这些缓存。后来证明,这是个错误的选择,而且同时暴露了两个问题。
第一个问题是容量。localStorage 通常对每个源设置大约 5MB 的容量上限,具体限制会因浏览器和平台而异。一门包含数百道编程题的课程,即使只缓存几种语言的翻译内容,也很容易超过这个限制。一旦超出,写入操作就会抛出 QuotaExceededError。如果没有对每次写入都做好异常处理,错误就可能在意想不到的地方冒出来。
第二个问题更严重,也更隐蔽:localStorage 是同步的。每次读取和写入都会阻塞主线程。在一台中端笔记本电脑上,这可能只是几毫秒,没人会注意。但在一部入门级 Android 手机上,闪存控制器速度慢,CPU 又已经满负荷运行,启动时一次较大的同步读取就可能造成明显卡顿,而这恰恰发生在最经不起卡顿的设备上。我们花了不少精力优化 JavaScript 代码体积,却在存储层里悄悄埋下了一个主线程阻塞点。
于是,我们迁移到了 IndexedDB。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() 返回的数值本身就经过了填充处理,而且只是近似值,因此它只能作为规划依据,不能当作保证。但这些信息已经足够帮助我们判断,是应该尝试预缓存整门课程,还是按需加载;也足以让我们提前提醒学生,如果想使用离线模式,就需要先清理一些存储空间。
两级 AI 反馈机制,以及明确的降级规则
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 运行时的冷启动速度仍然达不到我的预期。如果模块必须从缓存而不是网络加载,流式编译也只能解决其中一部分问题。
端侧模型的质量,是离线反馈能力真正的上限。目前,确定性分析承担的工作量仍然超出了我的期望。它之所以能够表现不错,很大程度上是因为编程题有可以验证的正确答案。如果换成答案没那么明确的领域,就没有这样的备用手段了。
存储仍然是个难缠的问题。配额数据经过填充处理,缓存清理机制不透明,而且不同浏览器对 persist() 持久化存储请求的支持也不一致。本文介绍的这些防御性设计,只是在弥补 Web 平台自身的不足,并没有真正解决这些问题。
但这些都不足以成为放弃这种开发方式的理由。另一种选择,是继续假设每个学生都有笔记本电脑和宽带网络,而这会把大多数真正需要这类产品的人拒之门外。这条路在工程实现上确实更难,但能够服务的人群规模,也因此扩大了数十亿。
原文链接:
https://www.infoq.com/articles/building-offline-first-android-app-low-end-devices/
本文由 AI宝库自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
I spent a year watching students disappear one by one. At the time, I operated a programming training facility, where students went to live classes, actively participated, and suddenly dropped off in the course. I followed up one by one, and almost no one left. Their network won't last a session. Of the 80 students enrolled, only six eventually graduated.
This experience made me realize that most programming educational platforms are based on four default prerequisites. These premises are so widespread that they are almost forgotten as mere assumptions: learners have laptops, broadband networks, easy reading of English, and can afford subscriptions, or their institutions have paid for them.
I developed the Efiwe platform to break all these assumptions. It can run directly in a smartphone browser and can be used by offline once you have completed the initial setup. The code submitted by students is also implemented locally, and after the first loading is completed, there is no need to consume mobile data traffic. Efiwe is now used by more than 22,000 learners in 160 countries and has been piloted in organized schools in Lagos, Ogon, Nigeria, and North Akand State, India.
The article does not discuss teaching methods, but engineering practice, especially where there have been problems. In this area, the real challenge lies not in the quality of models, but in the design of storage, quotas, cold start-ups and failure paths. These problems also lie far beyond education products. The same challenge is likely to be encountered as long as the product is directed at users with limited equipment performance and intermittent network connections.
Why the PWA instead of the original application?
The first thing we have to decide is how to distribute the product. The development of an original Android application seems to be the most natural option, but we did not do it in the end for three reasons.
The first is the installation threshold. Even if no course content has been downloaded, the installation of an APK from the app store often requires the consumption of dozens of MB traffic, and students may be using their own flow packages carefully. The progressive Web application (PWA) allows students to start their studies at point level, and cache can gradually complete it in the back and the flow costs are then distributed to the learning process.
The second is the frequency of updates. We'll keep publish updated. Using Service Worker, the new version will be effective at the next user visit. The new version of the app store publish failed to do so. In markets where users delay updating applications in order to save traffic, there may be a significant proportion of users who have repaired older code problems several months ago.
Third, the Web platform allows us to solve code implementation problems with a set of running hours. We need students to be able to write Python, SQL, JavaScript and HTML, and see the results immediately, and the whole process cannot rely on servers. WebAssembly in the browser lets us do it at once, running on all platforms.
We've also thought about wrapping a WebView with a raw shell. This will also solve the problem of updating when running using Web, if you load remote content. But it does not solve the installation threshold: students still need to download APK before they can start their studies, and this flow is probably something they can't afford. This is enough for us to make a decision on a product that is essentially designed to lower the learning threshold.
This option also comes at a cost: we must accept the browser ' s storage mechanism, including its quota limits. And most of the troubles we have in our production environment come from here.
Do not rely on server to execute code
Usually, the answer to a programming question requires sending the code submitted by the student to the server, executing it in the sandbox and returning the decision to the student. This means that every time you try, you have to go back and forth. In a classroom in Ijebuod, Nigeria, Internet connectivity is not stable and students often have to try 10 to 12 times to solve a problem. This model is simply not working.
So we let the code work directly on the equipment. SQL uses sql.js, i.e. SQLite compiled as WebAssembly; Python uses Pyodide, i.e. CPYthon compiled as WebAssembly. They're not small, they're slow to start. If it's not handled properly, they'll block the main line and get the page stuck.
Finally, we have established a rule that no operation can be initiated before the main route is really needed. When students choose a course, they start Worker on demand, and the first rendering of the challenge page must not wait for initialization to be completed. The following code illustrates this approach:
{/ runtime-host.js (main route)let workingPromise = null; report transaction {(!worldPromise}{workerPromise = new Promise (resolve, subject)={const worker=newworker}}New Worker (/workers/runtime.js', {type: 'module'}); report timer =settime()()=reporter()=new Errorr('init-timeout)}, {45 000); worker.adeventLister('msession on Reality {e.deta?! = report()}); = report((new Errr-init-time)} {east 000}}; {messesage, fmoody on behalf of {if.deta?type!!!! =Reuter(under); {what under}?
Within Worker, we also protect initialization to ensure that second-time initialization messages are not repeated. The module file will also be loaded from cache on a priority basis, so that even if the device has been offline for a week, the application can be activated when the student opens again.
If (type = = = = `init') {if (!runtime) rantime = AWIT Boot(kind); self.postMessage ({type: 'treaty'}); returrn;}(type = = 'run ' ) {try {consult = `awa itruntime.exec(!runtime) = WAIT Boot(kind); self.possage ({type: 'treaty'}});} catchr}(type = = 'run ' ) {try {consult = `awa itruntime.exec(code, condition); self.possessage {type ' = type, };} ctch/r(er) {data cannot be terminated, therefore {data {ustresult = awa itruntime.exec(code, condition); self possset {: {ype ' result, } report} } } {/er Self.postMessage
The difference in this last code is worth saying. In the ordinary application, dropping out of the code normally means that Bug appears. But here, most of the time, this is precisely what is expected, because students are beginners, and the learning process is inherently wrong. Thus, each level of the code implementation chain must be treated as normal.
For the determination of the SQL topic, we will not directly compare the output string, but rather the structure and content of the results. Thus, as long as the subject is allowed, even if the students use different formats, aliases or rankings, they can still pass as long as the query is correct.
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' };}
Here's the reston field is not without decoration. It will serve as an input to the feedback system that will be introduced later, so that we can tell students that your selection is right, but the filter conditions are problematic, and not simply that he is wrong.
Enduring storage: Why localStorage is not enough
Efiwe provides content in 246 languages. The translated programming text will be cache on the equipment so that students who choose Yoruba or Hindi do not have to re-download the same content every time they learn, and offline can continue to use the translation.
Initially, we used localStorage to save these caches. This later proved to be a wrong choice and revealed two problems at the same time.
The first issue is capacity. LocalStorage usually sets a capacity limit of approximately 5MB per source, with specific limitations varying from browser to platform. A course with hundreds of programming topics could easily exceed this limit, even if it was translated in only cache languages. Once over, the writing operation will throw out QuotaExceededError. If every writing is not handled in an abnormal manner, mistakes may appear in unexpected places.
The second problem is more serious and more hidden: localStorage is synchronized. Each reading and writing block the main route. On a medium laptop, this could be a few milliseconds, and nobody would notice. But on an entry-level Android phone, the flash controller is slow, the CPU is running at full capacity, and a larger simultaneous reading at the start could result in MingGPUton, which happens on the most unaffordable equipment in Carton. We spent a lot of effort optimizing the size of JavaScript code, but buried a main-line barrier point in the storage.
So we moved to IndexedDB. IndexEDDB is an anecdotal, and storage quotas are usually proportionally distributed according to the available disk space, rather than to fixed MBs, and can be stored directly in structured data without the need for JSON sequenced and inverse sequenced.
Selecting an IndexedDB is easy, and it's really hard to get current users moving smoothly without losing data. Users already on the line are still keeping data in their old storage, many of whom will be running offline for days, so when will they be able to run to the new code? The translation of cache and the uncompleted learning schedule could be lost silently if the switch was made directly. So we set a double-reading transition period: read the new storage first, if not, read the old storage once; find it and move the data over and then delete the old key.
{get as idbGet, set as idbSet} from './idb.js'; const LEGACY PREFIX = 'efiwe.tr.'; export async conversion readTranslation (key) {const fresh = await idbGet', key); if (fresh!= = undensened) return fresh; /// One-time migration from old synchronized storage. Let legacy = nulla; try {contraw = localStorage.getitem (LEGACY PREFIX + key); legacy = Raw ?JSON.parse (raw ): nulla;} catch {legacy = nulla; / / Data damage or unreadable, } (legacy = = = localStormage). It is more important for users to have access to content than to complete the migration. {\cHFFFFFF}{\cH00FFFF} Turn around.
There are two details in this function, almost the entire experience we learned from this migration. First, the operation to read old data is wrapped in an anomaly because if the last session is interrupted and the data in localStorage is only partially written, JSON.parse may throw out the anomaly. We do not want a cache parse error to ruin the entire page. Second, the migration writing is individually wrapped in another anomaly because when the storage space of the equipment is full, the reading may be successful and the writing may fail. In such cases, it is still true to give the content to the students and then try to migrate again.
Treat storage quotas as a malfunctioning scenario to be considered
If this article only reminds other engineers of one thing, I'd prefer the next.
During the pilot in Nigeria, a teacher provided feedback that learning pages were not always available after some students had created accounts on their mobile phones. The account was created successfully and login was no problem, but the course could not be loaded. The screen is blank.
We initially suspected low-end Android equipment for hardware compatibility. This guess seems reasonable, but it turns out we're wrong. As problems are concentrated on cheaper equipment, we have also wasted a great deal of time. In fact, it is only a correlation, not a real causal relationship: cheap equipment tends to have smaller storage capacity, while the smaller the capacity, the more likely it is to be occupied.
The real reason is a course startup process. On start-up, we'll start with the initial course list with cache and then proceed with the follow-up. However, if there is no remaining space for the equipment, writing may fail. As this error is not captured, the Promise chain will be interrupted before the rendering is performed. And students don't see any mistakes because the program didn't collapse in a way that UI could detect. Only the rendering function of the course has no chance of being called.
The original code was about:
/ Before modification: Storage failure will interrupt the rendering process silently. {const climate = await fitchManifest (course Id); an await carcheManifest (course Id, manifest); / / RenderCourse (manifest); / / never be implemented here)
Modified Code:
/ Modified: Persistent storage is only an optimization and no longer a precondition. (courseId)
Now, even if it is not possible to keep the data on the equipment, the course can be started normally. offline's functionality will be affected, and we will inform students clearly, but this will not prevent them from continuing their studies. Following the repairs, we conducted validations under various storage conditions, including a device that was deliberately filled with storage space, to confirm that the problem was resolved before the failure records were closed. From the feedback received to the completion of the repairs and validation, the process was completed on the same day.
Later, we came up with a rule that could be applied universally: the durability of writing in the start-up process must be done in a way that does not wait for the results after the launch, and clearly captures anomalies, and must not wait for writing to be completed. If the rendering of the page has to wait until the writing is successful, the user can encounter a white screen as long as it has full storage space.
We have also added a pre-screening mechanism that allows the system to proactively downgrade functions, rather than wait until the writing fails to detect problems.
Export asymmetric serviceBudget() if (!navigator.storage?.teste) {known: false, freeBytes: Information};} report {quota = 0, usage = 0} = await navigator.storage.stormate(); refund {known: True, freeBytes: Math.max (0, Quota-usage), Quota, retruage }; {report asyncaction canCayCourse (ESTimatedBytes)} {known, freeBytes} = African statusBuddy(); if!
For reasons of privacy protection, the values returned by Navigator.storage.estimate() are per se filled and are only approximations, and therefore can only serve as a basis for planning and not as a guarantee. But this information is enough to help us judge whether to try to pre-empt the entire cache course or load on demand, or to warn students in advance that if we want to use the offline model, we need to clean up some storage space.
Two levels AI feedback mechanism and clear downgrading rules
Efiwe provides personalized feedback on the wrong answers for each student. In the Nigerian pilot, this was the highest-rated function of field research, with an average score of 4.3 (5 out of 5) and 9 out of 10 teachers gave 4 or 5 points.
But this is also one of the most conflicting functions with the offline priority concept, because the best-quality feedback comes from the cloud-end model that is called through API, and the most basic design premise of this product is that the network may not exist at all.
We have finally adopted two-tiered programmes and have clearly defined the mechanisms for switching between them. The first level is a cloud-based model, which calls when the network is available and sets strict timeout time. The second level is a small-capacity end-side programme that combines a definitive analysis of submission codes with local models. The second level is always available and the judgement of the facts is always accurate, as the assessment of facts is the responsibility of the definitive analysis component.
/ / Always executed at low cost if (!navigator.online) returns {...local, tier: 'device'} }; Try {...hosted = = `arising with Time', {...listedFeedback >, };
There are three design details here, and it took us longer than we thought.
First, local analysis is always carried out first and without conditions, and is not initiated as a back-up programme until the cloud fails. Its operating costs are low and its results are established, and it is also transmitted to cloud requests as context. This means that the cloud-end model is faced with a code that has already completed a preliminary diagnosis and does not need to be analysed from scratch, so the output is more stable and prompt is shorter.
Second, we'll check the navigator.onLine, but we'll never totally believe it. This attribute only tells us whether the device has a network interface and does not prove that the network is actually available. Even if the mobile phone is connected to a Wi-Fi network that requires login authentication, or the mobile network is stuck, it may return the fact. So we'll just use it as a quick signal to judge the state of offline. It is the timeout mechanism that truly protects students from long waiting periods.
Finally, we will record in our response which level of feedback comes from. We will not hide what path the answer is. This is not only about our own telemetry data, but it is also important because feedback from the second tier, which has a weak capacity, can be expressed in different ways.
Let feedback really help to learn
Our early feedback was very simple and basically right and wrong. Whether the student submitted a consolie.log ( "hi") or did not understand at all what the function was, the feedback received was similar: "This is wrong, this should be the case."
One of the teachers in the pilot identified the problem with precision. Although her students received corrections, they had no idea how to change it without her sitting next to her mentors. This runs counter to our original intention to develop a programming mentor who can scale up services for students.
The solution is to measure how close the answers submitted by the students are to the correct answer before deciding which feedback should be provided. If it's just a little bit close, then point out which character is problematic; if it's an error in code structure, explain the concept behind it.
(d = 0) return nul; if (d > 2 || Math.abs > 2) {return null; /// = firstDiv = firstDivergence; {diff.kind ==== 'extra') {return {diff.char}! Just delete it. If (diff.kind = 'missing') Plus it's right. `;}return `Quick answer! Check the {diff.index +} character. `;}
I used to use a metaphor inside the team: two teachers changed the same job. Inexperienced teachers will place a red fork next to the wrong answer and write the correct answer. More experienced teachers are able to draw the wrongs and write tips where they need to be modified. This adjustment has changed our feedback from the first to the second. And the whole process is carried out entirely by a certainty logic, which means that even if offline, the feedback effect is exactly the same.
Many people share equipment, which must be considered at the structural level
In the pilot in Nigeria, 8 out of 10 teachers considered the lack of their own equipment to be the greatest obstacle. Some mobile phones are shared by siblings, some are borrowed from teachers and others are used by a group of students on a rotational basis in a class.
At the time, our I.D. model defaulted on one device for one student. If the two students shared a cell phone, they had to leave and re-enter. In the case of a slow network, each switch could waste one minute of classroom time. Worse still, students may lose local learning progress that has not been synchronized to the server.
Therefore, account switching is not an enabling function, but a necessary requirement for the actual use of the environment. Now, a device can keep up to 20 user files, and it can be switched in a little bit, without having to exit the log. From a structural point of view, this means that we have to set up separate naming spaces for each local storage space by user files, so that learning progress, translation of cache and running time state are isolated from each other and not shared across the board.
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),});
The mechanism should be joined as soon as possible if the product is for a market where equipment is shared by many people. Once the product is online, then split is a globally shared local storage system at a much higher cost than a namespace segregation from day one.
What did field data change?
Most of the experience we have mentioned earlier comes from two actual deployments. In Northern Akand State, India, four agencies operate our platform using centrally managed equipment. In Lagos and Ogon States of Nigeria, 10 teachers from 10 schools have piloted for eight weeks, almost entirely dependent on their own mobile phones.
The comparison of the two pilots allows us to distinguish between the problems of flat Z89Z and those posed by the use of the environment. This is also the most valuable place for the pilot project to take place. In Nigeria, the average rating for offline reliability was 4.0 (out of 5), while all low scores pointed to the same situation: the first cache had not been completed and the network had been disconnected. This is not that the offline function is per se unreliable, but rather that the sequence of tasks performed at the time of the first operation is in question. By giving priority to the minimum content required to run cacheoffline, other options can be addressed at the code level. By contrast, the availability of equipment for students is not at all a matter of code. Pretending that technology could solve everything would only waste time in engineering development.
The discovery that I felt most frustrated was about language. Support for 246 languages is one of the important reasons why we are developing this platform. But in the pilot, 4 of the 10 teachers never tried the local language interface, and one of them did not know how to use it. Furthermore, all teachers have seen this feature during the introductory training.
The problem is that the language switch button is placed at the bottom of the page. There was no reason why a student who was continuing to complete the programming challenge could deliberately roll to the bottom of the page. The function is present, documented, demonstrated, but in practice it is almost invisible.
Whether the function is easily discovered is different from whether it is useful. If the user has to know where to look in advance to discover a function, for the majority of the population this function is virtually non-existent. We're moving the language switch control to the place that students naturally see in their learning. But the more important gain is that we will now examine the question in front of any publish function: instead of asking, “If you tell the user where it is, can the user find it”, we ask, “Will the user naturally encounter it when using the product according to its own operational path”.
Outstanding issues
Three issues remain unresolved. Instead of writing a happy ending, I would rather say it.
WebAssembly's running at a cold start-up rate still below my expectations. If the module had to be loaded from cache rather than from the network, it could only address part of the problem.
The quality of the end-side model is the true upper limit of offline feedback capacity. Currently, the workload of the definitive analysis continues to exceed my expectations. Much of its performance is due to the correct and verifiable answers to programming questions. If the answer were not so clear, there would be no such alternative.
Storage remains a difficult issue. Quota data are filled and the cache clean-up mechanism is not transparent, and support for Persist() permanent storage requests from different browsers is inconsistent. These defensive designs, as presented here, are just to compensate for the shortcomings of the Web platform itself and do not really address them.
None of these, however, is sufficient to justify abandoning this development. Another option would be to continue to assume that every student has a laptop and broadband network, which would exclude most people who really need such products. The road is indeed more difficult to achieve, but the size of the population that can be served has also increased by billions.
Original link:
https://www.infoq.com/articles/building-offline-first-android-app-low-end-devices/
Auto-fetched and translated by AI Gem Box. All rights belong to the original author.