构建包含数千道题目的备考应用:架构决策
大多数备考应用的问题并不在题目内容,而在承载内容的架构。对于单一市场中的单一认证,把几百道题目存入一个扁平 JSON 文件完全够用。但现实中,一旦加入地区版本、多个难度层级、不同考试机构采用的不同题型,以及让跨时区用户同时竞争的排行榜,第一周做出的选择就可能在第八周开始引发问题。
我们构建了一款覆盖多个地区、多种专业认证的备考应用。其中的数据建模、Swift Package Manager 模块化、Supabase 后端和离线行为等架构决策,最初并不显而易见。从首个原型到正式发布版本,有些决策发生了重大变化。
数据建模的难题
最简单的考题模型是一张表,包含题干、四个答案选项和正确答案索引。但需要支持图片题、代码块题、选项数量不固定的题目,或多个答案都正确的题目时,这个模型就不够用了。内容导入后再修改数据模式,成本很高,因此值得在一开始就按完整的内容范围进行设计。
我们最终采用的模式,为题目设置了 type 字段(单选、多选、排序、代码片段),并用 JSONB options 列存储答案选项。这样每种题型都能灵活携带所需数据,无需各建一张表。正确答案存储为 JSONB 索引数组,而非单个整数,因此支持多答案题时无需修改数据模式。
题目属于 domains,知识领域属于 exams,考试又属于 regions。这套四层结构听起来像过度设计,但当同一认证机构针对不同市场发布不同考纲,或一款应用同时支持内容有所重叠的日本和德国认证体系时,它就有了必要。借助这一结构,可以通过一条参数化查询获取“该地区这项考试、这个难度级别的所有题目”,无需在应用层筛选。
难度存储为整数(1–5),而非枚举。增加难度级别时,枚举需要数据库迁移,整数则不需要。前端负责把整数映射为标签。
使用 Swift Package Manager 实现模块化架构
对于这种规模的代码库,单体 Xcode target 会带来明确的问题:构建时间变长,而且难以约束各层之间的边界。网络模块不应访问 UI 组件,题目引擎也不应关心自己运行在 iOS 应用中还是测试环境中。Swift Package Manager 将这些边界明确化,并交由编译器强制执行。
我们最终确定的包依赖图包含四个主要包:
- QuestionEngine:纯逻辑,包括题目选择、答案评估、练习评分和间隔重复调度。不依赖 UIKit、SwiftUI 或 Supabase,可以完全独立测试。
- ExamData:数据模型和 Supabase 客户端封装。定义
Question、Exam、UserProgress和LeaderboardEntry类型,并导入 Supabase Swift 客户端。 - OfflineStore:Core Data 技术栈与同步逻辑。依赖 ExamData 提供类型定义,负责本地缓存以及同步所需的变更跟踪。
- AppUI:SwiftUI 视图与视图模型。导入 QuestionEngine 和 ExamData,不直接了解 OfflineStore。
主应用 target 将这四个包组合起来。由于 QuestionEngine 没有平台依赖,这种结构允许我们在不启动模拟器的情况下运行其完整测试套件。Supabase Swift 客户端曾在一个次要版本中引入破坏性变更,影响也因此局限于 ExamData:我们只更新了一个包,其余部分无需修改便能编译。
代价在于,Xcode 中的 SPM 包解析和缓存有时不稳定,尤其是在清理后重新构建时。在 Makefile 中设置一个 target,在 CI 构建前运行 xcodebuild -resolvePackageDependencies,就能消除大多数此类意外。
Supabase 后端
选择 Supabase 作为后端有两个原因:行级安全可以处理多租户数据访问,无需应用层的访问防护;整个数据模式基于 PostgreSQL,因此在构建排行榜读取模型和报表层时,可以使用它的完整查询能力。
用户进度按题目、按练习记录。每行包含题目 ID、提交的答案、是否正确、耗时和练习时间戳。这比大多数应用需要的粒度更细,但让间隔重复调度成为可能:算法能使用完整历史,而非只有累计分数。基于这张表的聚合视图为仪表盘统计提供数据,无需应用自行计算。
行级安全策略确保用户只能读写自己的进度记录。题目、考试、知识领域和地区表都允许公开读取,访问内容无需身份验证,这简化了离线同步。只有用户专属的表需要经过身份验证的会话。
最初我们做错了一件事:尝试用 Supabase Realtime 同步题目内容。Realtime 面向的是小规模、高频的行级变更,而非数千条相对静态内容的同步。我们将题目表上的 Realtime 订阅替换为定时轮询任务,检查考试记录中的 content_updated_at 时间戳,仅在内容变化时下载增量。这不够优雅,但对于这样的内容库规模,可靠得多。
离线优先设计
如果备考应用必须联网,就无法帮助在飞机上学习的考生。在这里,离线优先并非可选项,而是主要使用场景。
我们采用的方案是在 Wi-Fi 下推送内容,并使用本地 Core Data 缓存作为题目引擎的主要数据源。应用联网启动时,会检查考试内容自上次同步以来是否有更新。如有更新,就下载增量并写入 Core Data;如果没有网络,则直接读取 Core Data,正常使用。用户进度先写入本地,有连接时再同步到 Supabase。
同步冲突模型有意保持简单:服务器是题目内容的权威来源,客户端是用户进度的权威来源。不存在可能引发冲突的双向内容编辑。用户进度记录只追加,已完成的练习不会更新,只会添加新练习。因此,同步逻辑就是“上传时间戳晚于上次成功同步的记录”。只有每条记录都带有唯一 ID,确保重试上传不会产生重复记录,并且记录时间戳和同步标记始终一致地使用设备时钟,这条规则才能保持简单。
一个重要细节是:离线存储应向用户显示内容的时效性。“内容上次更新于 3 天前”这样的提示条,可以让用户形成合理预期。题库已加入新题却仍悄然提供旧内容时,用户可能会困惑:为什么自己的分数与最近同步过的其他用户不同?
并发负载下的排行榜
读取排行榜很简单,但在并发更新时正确写入,却有意想不到的细节。简单做法是为每个用户维护一个 score 列,每轮练习结束后更新。但很多用户同时完成练习时,这会产生写入争用,也容易出现两个并发更新相互覆盖的竞态条件。
有效的做法是将排行榜视为由练习历史派生的读取模型,而非持续维护的分数列。PostgreSQL 物化视图在定时刷新时,按用户汇总练习分数。刷新每隔几分钟运行一次,而非每次写入都触发,因此排行榜采用最终一致性,而非实时一致性。对于学习应用,这是可以接受的取舍:用户完成练习后,几分钟内就能看到排名更新,除实时竞赛活动外,足以满足所有使用场景。
对于实时排名至关重要的竞赛活动,我们使用 Supabase 之外的独立 Redis 计数器。但对于常规学习流程,物化视图方案更简单、成本更低,而且能避免持续更新分数列时发生更新丢失的竞态条件。
如果重来,我们会怎样调整
用 JSONB options 列存储答案数据是正确的选择,但早期对选项对象的结构规定得不够明确。不同开发人员逐渐加入了略有差异的字段:有些选项有 explanation 字段,有些有 imageUrl,有些两者都没有。如果插入时不强制校验结构,内容就会逐渐变得不一致。在管理工具的 TypeScript 端添加 Zod 数据模式,并在 Postgres 列上添加 CHECK 约束,本可以更早发现这些问题。应在边界处强制约束 JSONB 数据结构,而非事后补救。
AppUI 与 OfflineStore 之间的 SPM 包边界过于严格。多个 SwiftUI 视图需要知道自己展示的是缓存数据还是在线数据,而这一信息位于 OfflineStore 中。最终,我们向 AppUI 传入了一个可由 OfflineStore 实现的协议。这是正确的抽象,但如果从一开始就设计这个协议,而非事后补上,结构会更清晰。
相关洞察
- Swift 6 并发:实用迁移指南:处理这类多包 SwiftUI 架构中出现的 actor 边界问题。
- 从 SwiftUI 概念到 App Store:数周内完成:如何在完整架构完成之前,为这类应用的 MVP 版本界定范围并发布。
- 解决 App Store 图标拒绝问题:工程工作完成后,往往令团队措手不及的最后提交细节。