在完成 Context Vocabulary Notebook 的第一版之后,我很快遇到了一个新的矛盾。
这个项目强调本地优先:卡片、复习记录和视频片段都保存在自己的电脑上,不需要注册账号,也不依赖某个厂商的云端。但真正需要复习的时候,我不一定坐在电脑前。排队、通勤、等人,反而是最适合打开单词本的时间。
于是,“本地优先”逐渐暴露出它的另一面:如果数据只能留在一台电脑上,本地优先就可能退化成“本地受限”。
我不想因此接入一个中心化云服务,也不想把数据库文件粗暴地复制到手机。前者会改变项目的隐私边界和运维模式,后者无法处理两边同时产生的新复习记录,更无法可靠同步几百 MB 的视频、图片和音频。
最终,我给项目增加了一个 Android 离线客户端,以及一套由 PC 自己提供的安全同步服务。项目从 0.2.0-alpha 走到 0.3.0-alpha.9,也从“本地 Web 单词本”扩展成了:
PC 权威端
+ Android 加密离线副本
+ 局域网 HTTPS / Tailscale 双通道
+ 事件上传、规范重放与媒体校验

Context Vocabulary Notebook 仍然是本地优先应用,只是“本地”不再被限制在一块屏幕里。
这篇文章复盘的不是怎样套一层 WebView 打包 APK,而是一个更难的问题:怎样在不使用厂商云的前提下,让手机能够真正离线学习,并在重新连接后与 PC 安全、确定地收敛。
一、先确定边界:这不是第二个完整版客户端
在开始写 Android 代码之前,我先给移动端划定了边界。
PC 仍然负责:
- 创建和编辑卡片;
- 管理语境、标签和媒体;
- 批量分析视频;
- 配置 OCR、STT 和 AI;
- 导入导出与完整备份;
- 保存最终权威数据;
- 根据所有设备的复习事件重新计算 FSRS 状态。
Android 只负责更适合移动场景的事情:
- 下载可以复习的卡片快照;
- 离线选择
Again / Good; - 播放离线图片、音频和视频;
- 收藏、取消收藏和标记熟记;
- 暂存离线产生的操作;
- 恢复连接后把事件上传给 PC;
- 接收 PC 重新计算后的规范结果。
这不是因为手机做不了完整编辑,而是因为“移动复习”与“桌面制卡”是两个不同的任务。把桌面端所有能力搬到手机上,会同时扩大界面、同步和冲突处理的复杂度,却未必改善核心使用场景。
因此,系统采用“一台 PC 配对一台 Android”的早期约束。它暂时牺牲了多设备扩展性,换来了更清晰的身份关系和故障边界。对于仍处在 alpha 阶段的本地项目,这比一开始就设计任意数量设备的分布式系统更可靠。
二、为什么不能直接同步 FSRS 状态
最容易想到的同步方式,是让手机离线修改卡片的 due_date、stability 和 difficulty,连接后再覆盖 PC。
但只要 PC 和手机都能复习,这个方案就会立刻出现问题。
假设同一张卡片的初始状态是 S0:
10:00 PC 离线复习一次,得到状态 S1
10:05 手机也基于旧状态 S0 复习一次,得到状态 S2
10:10 手机把 S2 上传给 PC
如果直接覆盖,PC 的那次复习就消失了;如果按更新时间保留较新的 S2,同样会丢失历史;如果尝试合并两个 FSRS 状态,又没有一个天然正确的公式。
因此,我把同步对象从“可变的当前状态”改成了“不可变的复习事件”:
Review Event
├── event_id
├── device_id
├── device_sequence
├── card_id
├── rating
├── reviewed_at
├── recorded_at
├── scheduler_version
├── parameter_version
├── state_before
└── state_after
每个设备为事件分配单调递增的序号。PC 接收事件时按 device_id + device_sequence 幂等处理:同一批上传两次不会产生两次复习。事件写入后不会原地修改,当前 FSRS 状态则可以随时从事件序列重新计算。
同步后,PC 会找出受到影响的卡片,从检查点开始按确定顺序重放事件,再把规范状态写回数据库。手机本地计算的结果用于离线时立即给出下一次复习时间,但恢复连接后,仍以 PC 重放后的状态为准。
这个设计的关键不是“同步更多字段”,而是区分事实与派生结果:
- “某个设备在某个时间给了 Again”是事实;
- “这张卡下一次什么时候到期”是可以重新计算的结果。
事实应该追加并保留,结果则应该由权威端统一生成。
三、同步不是一个接口,而是一段协议
Android 点击“立即同步”时,系统并不是简单请求一个 /sync 接口,而是执行一段有顺序的协议:
1. 检查协议版本与最低客户端版本
2. 上传连续的离线复习事件
3. 上传收藏、取消收藏和熟记操作
4. PC 重放受影响卡片的 FSRS 状态
5. 下载新的规范文本快照
6. 原子替换 Android 本地快照
7. 获取媒体清单
8. 断点下载缺少的媒体并校验哈希
9. 确认已经接收的内容修订
这里有几个容易被忽略的细节。
连续序号
PC 只确认连续收到的事件。例如设备已经确认到 20,收到 21、22、24 时,不能直接把游标推进到 24,因为 23 可能只是在网络中丢失。Android 会保留尚未确认的 outbox 项目,下次继续上传。
原子快照
手机端不能一边删除旧卡片,一边逐条插入新卡片,同时还允许复习页面读取。同步中断时,这很容易产生半新半旧的数据。
现在 Android 会在事务里逐表替换规范快照,并串行化同步任务。只有整套文本数据成功写入,新的修订才会生效。早期版本曾在离线事件上传后触发重复卡片错误,最后就是通过“单次只运行一个同步”和“原子替换快照”解决的。
两个 outbox
复习事件和卡片操作被分开保存。
复习事件需要参与 FSRS 重放;收藏和熟记则是幂等状态操作。把它们混在一个队列里,会让协议语义和失败恢复变得含糊。分开以后,即使某个卡片已经在 PC 删除,服务端也可以明确返回“忽略了哪个操作”,而不是让整批同步失败。
四、真正的离线复习,不只是把文字缓存下来
第一版 Android 客户端只要能够显示单词和原句,似乎就已经满足“离线复习”。但语境单词本的价值恰恰在媒体:一帧画面、一句语音或者原始视频,往往比中文释义更能唤回记忆。
所以同步快照之外,还有独立的媒体清单:
Media Manifest Item
├── media_id
├── context_id
├── type
├── filename
├── mime_type
├── size
├── sha256
└── offline_available
手机根据哈希判断文件是否已经存在。下载支持 HTTP Range,网络中断后可以从已有字节继续,而不是重新传输整个视频。下载完成后再次计算 SHA-256,只有内容与 PC 清单一致才进入私有媒体缓存。
文件名还需要保留扩展名。Android WebView 对视频、图片和音频的 MIME 推断依赖路径与响应信息,如果只用裸哈希保存,文件明明存在也可能无法播放。早期版本还遇到过历史 file:/... 路径与 Capacitor 私有文件 URL 不兼容的问题,后来统一规范旧路径,并让新下载返回可由 WebView 正确访问的绝对路径。
还有一个很实用的补充:如果某个语境只有原始视频,没有单独音频,PC 会从视频生成一个较小的 AAC 音轨。手机仍然可以下载并播放完整视频,但在只需要听一句话时,不必每次都加载更大的文件。
这让我认识到,“支持离线”不是页面在断网时不报错,而是用户真正需要的内容在断网时仍然完整可用,并且缓存能够验证、续传、升级和清理。
五、Again 从“稍后再见”变成了明确的十分钟
移动端加入后,复习规则不能再散落在两个客户端里。
旧版本在选择 Again 后,把卡片标记为当天可以再次出现,但“多久以后”并不清晰。在 Android 和 PC 同时存在队列后,这种模糊规则会导致两个端看到不同的下一张卡。
现在调度逻辑被抽到共享模块中:
Again → 当前时间 + 10 分钟 → 重新进入队列
Good → 使用 FSRS 计算长期到期时间
调度器同时记录库版本和参数版本。数据库迁移会把旧的立即重试状态转换为十分钟冷却,并激活新的调度参数配置。这样,PC、本地 Android 计算和服务端事件重放都使用同一个规则。
Android 的答题交互也与 Web 对齐:
- 先选择
Again或Good; - 再显示释义、备注和语境媒体;
- 确认后进入下一张。
如果先选 Good,看到答案后发现自己其实没有想起来,可以改成 Again,系统会记录更正后的结果并进入下一张。第一次直接选择 Again 时,则仍然停留在答案页,让用户看完语境后再确认。
手机还支持离线收藏和标记熟记。标记熟记会在同一个本地事务中记录当前评分、把卡片移出活动队列,并加入待上传操作,避免出现“评分保存了,但卡片仍留在队列”这样的中间状态。
六、局域网和 Tailscale,不是二选一
我希望普通家庭网络不安装额外服务也能同步,同时又希望人在外面时可以通过自己的 Tailnet 连接 PC。最终系统提供了两个传输通道。
局域网 HTTPS
PC 在独立端口启动受限同步服务,并通过 mDNS 发布 _cvn-sync._tcp.local。Android 可以发现 PC,但不会因为“发现了”就信任它,而是校验配对时保存的完整 SHA-256 SPKI 指纹。
二维码可能包含多个私有网卡地址。手机会并行验证这些地址,选择真正能够连接到配对 PC 的物理局域网地址;Tailnet 地址、链路本地地址和不可用接口会被排除。这样,即使电脑同时装有虚拟网卡、WSL 和 VPN,二维码也不至于优先保存一个手机无法访问的地址。
对于 Windows 11 + WSL,项目正式支持 mirrored 网络,因为它能提供局域网直达和 multicast。设置页会检测网络模式,必要时帮助合并 .wslconfig,但不会擅自关闭 WSL,也不会创建容易失效的 portproxy。
Tailscale Serve
系统也可以检测 Linux 或 Windows 上的 Tailscale,并配置:
tailscale serve --bg 3108
这里使用的是 Serve,不是 Funnel。服务只在用户自己的 Tailnet 内可见,不把同步接口公开到互联网。
Android 默认使用“自动连接”:验证局域网和 Tailscale,选择第一个安全可用的通道;排障时也可以强制指定其中一种。认证失败、设备撤销、协议不兼容和证书身份错误不会通过换通道绕过。
把两种连接统一成同一个同步协议很重要。传输层只决定“怎样到达 PC”,不应该改变“哪些事件被接受、怎样确认修订、怎样下载媒体”。
七、配对不能只靠扫一个二维码
二维码让配对看起来很简单,但它携带的是设备信任关系,不能把“扫到”直接等同于“授权成功”。
当前配对流程是:
PC 创建五分钟有效的配对会话
→ 二维码携带临时 secret、PC 身份和候选地址
→ Android 提交设备名称与配对请求
→ PC 全局弹窗要求人工确认
→ 服务端签发长期设备凭据
→ Android 保存凭据和签名连接配置
扫码不可用时,可以直接复制二维码下方的紧凑配对文本,不需要把敏感内容上传给第三方二维码解析网站。
PC 首次启用同步时会生成长期 P-256 身份和自签名证书。长期设备凭据在 PC 数据库中只保存哈希;设备可以随时撤销。同步前还会检查服务端声明的最低 Android 客户端版本,过旧应用不能先上传本地数据再发现协议不兼容。
普通浏览器应用和 API 继续只监听 localhost。面向手机开放的是单独、受限的 /v1 服务,它不包含卡片编辑、AI 配置、API Key、ZIP 导入导出和普通媒体接口。同步身份和设备凭据也不会进入 ZIP 备份或 Android 系统云备份。
这套设计不等于绝对安全,但它让暴露面与功能边界相匹配:手机只获得完成离线复习所需的最小能力。
八、手机的学习语言可以独立,但不能分裂数据
语境单词本支持八种界面语言和多种学习语言。手机加入后,又出现了一个产品问题:我在 PC 上默认学英语,但今天可能只想在手机上复习日语,是否必须修改整套 PC 设置?
现在,Android 默认跟随 PC 的学习语言,也可以在已有活动卡片的语言之间离线切换。这个覆盖选择会跨重启保存,但不会修改 PC 设置。
同步时,所有语言产生的复习、收藏和熟记操作都会上传;当前语言只影响手机展示的队列和今日进度。“待上传”始终统计所有语言,避免用户切换语言后误以为其他队列已经清空。
如果 PC 主动修改默认学习语言,下次同步会清除手机覆盖并重新跟随 PC。这个规则看似强硬,但可以避免手机长期停留在一个用户已经放弃的本地偏好上。
与此同时,ZIP 导入导出也升级到 Schema Version 2。用户可以导出全部语言,也可以只导出某一种学习语言;导入扫描会列出各语言的卡片数量,并允许只恢复选中语言。旧版 Schema Version 1 仍然兼容。
完整恢复与按语言导入的安全语义也不同:完整个人备份恢复会建立新的信任周期,撤销旧设备配对;局部语言导入则保留当前设备关系和全局设置。数据迁移不再只是“把表插回去”,而是需要理解这次操作是否代表整台 PC 身份的恢复。
九、从能构建 APK 到能够发布
Android 工程基于 Capacitor 8,最低支持 Android 7.0 / API 24。本地构建需要 Node.js 22、Java 21 和 Android SDK Platform 36。
项目增加了独立的 GitHub Actions 工作流:
- 构建移动 Web 资源;
- 同步 Capacitor Android 工程;
- 运行单元测试;
- 组装 APK;
- 生成同名 SHA-256 校验文件;
- 在模拟器上安装和启动;
- 只有配置完整签名材料时才发布签名 APK。
普通用户从 GitHub Releases 下载签名 APK 和校验文件。PR 或缺少签名材料的构建只产生短期 Debug artifact,避免把测试包伪装成正式发布。
平台说明也因此变得更诚实:
| 平台 | 当前状态 |
|---|---|
| Windows 11 / WSL | 已在真实安装环境测试 |
| Linux | 已通过 CI 和安装脚本冒烟测试 |
| macOS | 有 Homebrew 安装逻辑,但仍是实验性支持 |
| Android | 已在真实手机和 API 24/36 模拟器验证 |
| iOS / iPadOS | v0.3.x 暂不提供 |
“代码里有一个 Android 目录”和“用户能安全下载并升级 APK”之间,还隔着签名、校验、最低版本、备份提醒和真实设备验证。发布流程本身就是移动端功能的一部分。
十、这轮开发中几次有价值的失败
设备同步涉及数据库、网络、系统权限、WebView 和媒体文件,很多问题只有真实运行后才会出现。
快照替换产生重复卡片
手机上传离线事件后立即触发多次同步,旧实现可能并发替换快照,最终撞上唯一键。解决办法不是捕获重复错误继续,而是串行化同步并把快照替换放进完整事务。
第一次媒体下载失败导致解除配对
网络抖动只是可重试错误,不应该被当作凭据失效。后来把连接、认证、协议和媒体错误分层,只有真正的身份错误才清除配对。
Android 私有路径存在,但 WebView 无法播放
file:/...、绝对路径和 Capacitor 转换后的 URL 不是一回事。最终需要迁移旧缓存路径,并为新文件统一返回 WebView 可访问的形式。
二维码能扫,但地址不可达
PC 可能同时拥有 Wi-Fi、虚拟网卡、WSL、Hyper-V 和 Tailnet 地址。只选择第一个私有 IP 并不可靠。现在由手机验证多个候选地址,保存真正连通的那个。
设置已经修改,但用户不知道还没保存
学习语言和每日上限会改变复习范围。下拉框改变不等于设置已经写入数据库,因此 PC 设置页增加了明显的“存在未保存更改”提示。
这些问题有一个共同点:单元功能都可能是“正确的”,系统组合起来却仍然失败。同步工程最难的不是写出成功路径,而是定义每一层失败后应该保留什么、重试什么、清除什么。
十一、本地优先的含义发生了变化
在 0.2.0-alpha 中,本地优先意味着:
数据保存在自己的电脑
不需要厂商账号
手动学习和本地识别不上传内容
到了 0.3.0-alpha.9,它又多了一层含义:
PC 仍然掌握权威数据
手机拥有加密、可离线使用的必要副本
同步服务由用户自己的 PC 提供
连接可以经过局域网或用户自己的 Tailnet
不存在保存个人词库的厂商云中间层
这并没有让系统变得更“纯粹”。相反,它带来了身份密钥、证书、设备凭据、事件重放、迁移版本、媒体清单和移动发布等大量复杂度。
但这轮开发也让我看到,本地优先并不等于拒绝多设备,而是需要明确回答三个问题:
- 谁拥有最终权威数据?
- 哪些副本可以离线修改?
- 重新连接后,系统怎样确定且安全地收敛?
只要这三个问题没有答案,“同步”就只是一个模糊功能名。
十二、写在最后
从 2026 年 7 月 14 日的 0.2.0-alpha 到 7 月 20 日的 0.3.0-alpha.9,项目新增了 Android 工程、安全配对、两种同步传输、离线媒体、事件重放、事务迁移、按语言导入导出和移动发布流程。主分支在这段时间发生了 157 个文件变化,新增约 1.1 万行代码,测试及辅助文件也从 48 个增加到了 62 个。
这些数字不是完成度证明。项目仍然是 alpha:当前只支持一台 PC 与一台 Android,macOS 仍缺少真实机器验证,也没有 iOS 客户端或厂商托管的在线同步。
但它已经跨过了一个重要节点。
以前,我只有坐在电脑前才能打开自己的语境词库;现在,手机可以带着加密副本离开 PC,在没有网络的地方完成复习,回家后再把真实发生过的学习事件交还给权威端。
我没有为此引入一朵保存用户数据的云,而是让两台属于自己的设备学会了怎样彼此确认、交换事实并重新达成一致。
这可能不是最省事的同步方案,却是我认为更符合这个项目性格的方案。
如果你也对本地优先、离线学习或自托管设备同步感兴趣,可以查看项目与当前 Android 指南:
- GitHub:yaqxuan/context-vocabulary-notebook
- Android 离线复习与同步指南:ANDROID_SYNC.zh-CN.md
- 当前更新记录:CHANGELOG.md
下一次在地铁里点下 Again 时,PC 也许还安静地关在家里。但那次没有想起来的事实不会丢失——它会留在手机的 outbox 中,等两台设备再次见面。