Android离线背单词App开发:基于Kotlin与Room的数据与记忆算法实践
做这个安卓端记单词背单词系统的想法最开始来自一次很普通的吐槽。身边有朋友在备考每天在地铁上抱着手机刷单词可用的App不是弹广告就是要开会员才能导入自己的词单离线词库还得单独买。听多了我干脆想与其到处找成品不如自己动手写一个完全离线、词库可自定义、复习计划自动安排的本地工具。于是就有了这个项目不联网也能用打开就是一批常用词自己的课本词单通过TXT文件直接导入每天学多少、复习哪些词由本地算法安排所有数据都存在手机里。做完整套东西技术上没有特别高深的地方但把数据层、记忆算法层、交互层串起来的过程中踩了不少坑。这篇就把项目定位、数据库设计、背词流程、进度统计和几个典型排错过程完整拆一遍适合正在找Android综合项目练手的开发者也适合想自己做离线工具的朋友参考。1. 项目定位与技术选型先想清楚做什么再动手写代码1.1 需求边界离线、可导入、会复习三个核心词任何一个工具类App第一步都是把“做什么、不做什么”说清楚。市面上成熟的背单词软件功能非常多但用户最反感的恰恰是离核心场景太远的东西。我在需求阶段把这个项目的边界压缩成三条第一完全离线单词库、学习记录、复习计划都在本地不依赖任何服务器第二词库可导入用户能把老师发的、资料里整理的词单直接变成自己的单词本第三自动安排复习不是学完一遍就结束而是根据记忆规律把该复习的词排到每一天。围绕这三条我列了一张功能清单单词列表展示、学习模式与复习模式切换、自动间隔提醒、每日学习目标、进度统计。同时明确砍掉了云账号、排行榜、分享打卡这类需要网络和社交功能的东西。砍功能是非常关键的决策它保证了整个项目的数据流是单向的、可追踪的排错时不会出现“明明没网怎么数据变了”的诡异问题。实现方式也定得很朴素。应用启动后进入首页显示今日学习进度和待复习数量点击学习进入卡片页正面是单词点击翻面看释义再选择“认识”“模糊”“忘记”答完自动进入下一个词。复习则按照记忆算法给出的时间点把到期的词聚合成一个复习队列。整套界面不追求花哨信息密度高、操作路径短就行。1.2 技术栈Kotlin Room RecyclerView不引入任何网络框架技术选型上我采用Kotlin作为开发语言Java虽然也能写但Kotlin的空安全设计和协程支持在大量数据列表操作里省了很多事。数据存储使用Room它是SQLite的官方ORM封装版本编译期就能校验SQL语句值对象与数据表之间用注解自动映射比手写SQLiteOpenHelper维护成本低得多。界面层使用RecyclerView承载单词列表和复习列表配合ViewModel LiveData做生命周期感知的数据观察界面旋转时数据不会丢。这里有一个很容易被忽略的选择逻辑项目既然是完全离线的为什么还要用Room而不是直接操作SQLite原因在于Room把“数据表字段变化”也纳入了版本管理。单词表要加字段时不再需要手动写一整套升级逻辑Room的Migration机制会规范地处理老旧数据库的升级。这个特性在后面排错章节会重点展开平时不觉得真正遇到用户更新版本后闪退就知道它多重要了。组件职责划分如下模块技术组件职责数据持久化Room单词库、学习记录的增删改查生命周期管理ViewModel跨配置保存当前学习的词状态异步与数据观察LiveData / Flow列表数据变化自动刷新列表展示RecyclerView单词列表、复习列表的滑动复用计划调度AlarmManager本地每日复习提醒文件导入自研解析器TXT/CSV词单的编码识别与解析整个项目里没有引入HttpClient、也没有引入图片加载框架网络层是零依赖的这既是需求约束的结果也让APK体积保持很小。安装包去掉混淆后只有几兆在旧手机上启动速度也快。1.3 为什么坚持本地存储而不是接云服务很多人问我为什么一开始就把云同步排除掉。理由很简单背单词的数据是高度私人的用户希望词库和学习进度自己可控。接云服务意味着要解决账号体系、服务端存储、推送通道、数据加密等问题对单机版项目来说是巨大的复杂度膨胀。而且本地存储很难出现“词库丢了一部分”的故障备份就是把数据库文件复制走一个文件搞定。如果以后真的要加云同步这个设计也不会推翻。Room数据库本身是完整自洽的只需要在数据层之上增加一个导出接口把学习记录序列化为JSON或CSV通过WebDAV一类协议同步到个人网盘即可。核心业务逻辑完全不用动。所以我一直认为选本地存储不是退而求其次而是一种主动的架构取舍。2. 单词库与记忆模型数据层是整个项目的真正难点2.1 三张表的设计词表、单词、学习记录分开放记单词系统的数据模型最直观的想法是把所有字段塞进一张表比如“表名words字段有单词、释义、音标、例句、复习次数、下次复习时间”。但实际写下来会发现这张表会把“词库的静态属性”和“学习过程的动态状态”混在一起。词库要分享给朋友或者重新导入时旧的学习记录就会跟着变得混乱。我在设计中把数据拆成三张表word_lists词表、words单词、study_records学习记录。word_lists表只负责词库本身的信息词表名称、描述、创建时间、单词数量。words表保存这个词表里的具体单词所属词表ID、单词本身、音标、中文释义、例句。study_records表则单独记录学习动态数据单词ID、当前状态、复习次数、最近学习时间、下次复习时间、记忆难度系数。这里最关键的思考是单词的“内容”与“学习进度”是两类生命周期完全不同的数据。单词内容可能一直不变但学习进度从第一天开始就会频繁更新。外键和索引也要提前考虑。words表里的list_id设了外键指向word_lists复习队列查询会频繁用到“下次复习时间”字段所以在next_review_time上建了索引。数据量在几千词级别时索引带来的查询速度差异不大但积累到几万词的词库后索引能让复习筛选从毫秒级稳定保持这一点在大量导入材料类词单时体验特别明显。2.2 记忆算法的取舍用简化版SM-2而不是完整复刻背景知识简单提一句。人类记忆随时间自然衰减刚背完的词遗忘最快之后逐渐变慢。间隔重复的思路就是在快遗忘之前主动复习一次每一次成功复习又能把下一次的间隔拉长。这套思路被很多记忆卡片软件验证过其中SM-2算法是最经典的实现学术背景是艾宾浩斯遗忘曲线。完整版SM-2会根据每次回答的评分0到5分更新难度系数公式大致是EF更新为EF 0.1 - (5 - q) * (0.08 (5 - q) * 0.02)间隔则是前次间隔乘以EF。这个公式本身不难难在要处理非常多的边界情况用户中途退出、答错后降级、长时间不登录回来发现一堆逾期词。对于个人成品项目来说我决定改成分档间隔策略答对时复习次数加一间隔天数按档位阶梯上升答错或选“忘记”时直接重置为最短间隔。const val DAY_MILLIS 24 * 60 * 60 * 1000L const val TEN_MINUTES_MILLIS 10 * 60 * 1000L data class ReviewPlan(val reviewCount: Int, val nextReviewTime: Long) fun calculateNextReview(record: StudyRecord, isCorrect: Boolean): ReviewPlan { val now System.currentTimeMillis() return if (isCorrect) { val newCount record.reviewCount 1 val intervalDays when (newCount) { 1 - 1L 2 - 3L 3 - 7L 4 - 15L else - 30L } ReviewPlan(newCount, now intervalDays * DAY_MILLIS) } else { ReviewPlan(0, now TEN_MINUTES_MILLIS) } }这段代码是整个复习逻辑的核心。答对时间隔分别是1天、3天、7天、15天、30天答错直接重置。看到这里可能会有疑问答错为什么只等10分钟而不是第二天再复习这是我实际测试后调整的参数。遗忘的词如果当天就进行一次“抢救性复习”记忆留存效果比直接推到第二天好很多而且用户此时还处在学习状态心理上更容易接受不会产生“今天任务怎么永远做不完”的挫败感。2.3 内置词库与自定义导入两种方式互补解析要点多项目预置了一个常用高频词词表覆盖大约3000个单词按四级、六级常用度粗略划分。内置词库的价值在于用户第一次打开App就能立刻投入学习不需要先研究如何制作词单。但光有内置词库不够因为大多数有真实备考需求的人都有自己的词表所以必须支持自定义导入。导入格式我定为每行一个词条用竖线分隔字段单词、音标、释义可选例句。示例abandon | /əˈbændən/ | v. 放弃抛弃 | He abandoned the plan. 他放弃了这个计划。解析的痛点在于容错。真实世界里的词单质量参差不齐有人给的是带空行的文本有人字段顺序不一样有人用的是繁体标点。我的解析器按行读取去掉首尾空白忽略以井号开头的注释行再按竖线切分。字段数量不足三的行直接跳过并记录到导入结果里反馈给用户而不是让整个文件导入失败。这种设计在真实使用中几乎每天都能发挥作用因为用户的输入永远比你预想的更随意。3. 背单词主流程与交互实现状态机决定一切3.1 学习状态机生词、学习中、待复习、已掌握背单词的交互逻辑本质上是一个状态机。每个单词在学习记录里有四种状态NEW未学、LEARNING学习中、REVIEWING待复习、MASTERED已掌握。第一次导入时状态是NEW第一次答题后变成LEARNING如果答对且复习次数达到阈值升级到MASTERED如果曾经答道过但还未掌握则进入REVIEWING队列。状态转换的触发条件如下表当前状态触发动作转换结果NEW首次答题认识/模糊/忘记LEARNINGLEARNING连续答对3次MASTEREDLEARNING答错保持LEARNING间隔重置REVIEWING到期复习且答对复习次数加一间隔拉长MASTERED后续复习答错降级为LEARNINGMASTERED长期未复习保留状态进入复习队列这个状态机最大的好处是可测试。每个转换都是确定性的只需要一个纯函数传入当前状态和答题结果就能得到下一个状态。我刚开始写的时候把状态判断散落在各个点击事件里很快发现逻辑经常互相覆盖比如“忘记”按钮改了次数却没改状态。重构之后所有状态转换都收敛到同一处UI层只负责收集用户动作不负责决策。3.2 新词学习与复习模式选择策略和队列构建学习模式拆成两条独立路径。新词学习从词表里取状态为NEW的词每次限制20个学完这批才继续下一批。复习模式则查询数据库中所有next_review_time早于当前时间的记录按照到期时间排序一次最多加入30个词。这里我坚持两条队列分开处理而不是混在一起因为用户学新词的节奏和复习的节奏是完全不同的。复习通常讲求速度每个词停留时间短新词学习则需要更长的专注度。两条队列的数据源来自不同查询但共用同一个卡片组件。这里有一个细节进入复习模式前我会先计算到期词的数量如果为零就直接弹一句提示“今天复习任务已完成”。这个看似不起眼的逻辑大幅减少了用户困惑否则用户点了复习却看到空列表第一反应是App坏了。3.3 卡片翻转、四选一判分与干扰项生成背词交互我选择了“先看词-再回忆-最后翻卡验证”的流程。界面显示单词和音标时释义是被遮挡的点击卡片后翻转显示释义。用户可以选择“认识”“模糊”“忘记”三个按钮来记录回忆结果。这个交互的好处是保留了主动回忆的过程纯粹把释义摆出来让用户对照学习效果会差很多。判分逻辑直接对接记忆算法点击“认识”记为答对调用calculateNextReview时isCorrect传true“忘记”记为答错传false点击“模糊”同样视为答错但下次复习间隔仍沿用10分钟规则。我这样设定是因为“模糊”意味着回忆不完全如果直接按答对处理会过度拉长间隔导致下一次真正遗忘时才被系统发现。卡片复习之外我还加了一个四选一测验模式用来在计划外做快速自测。干扰项直接从当前词表随机抽取三个其他单词的释义避免使用全词库随机抽取——同一个词表里的词在词义上往往有关联干扰效果更好。随机算法用系统自带的Collections.shuffle不额外引入任何库。选项生成后会在内存里随机打乱位置防止用户凭位置记忆作答这是我实际测试几轮后加上的细节。4. 数据存储、进度统计与每日提醒把系统做完整4.1 从DAO到仓库Room的封装思路数据访问层分成两层DAO负责具体的SQL语句Repository负责业务层面的数据组装。DAO使用注解定义接口例如查询到期复习词的方法Dao interface StudyRecordDao { Query(SELECT * FROM study_records WHERE status ! 3 AND next_review_time :currentTime ORDER BY next_review_time ASC LIMIT :limit) fun getDueRecords(currentTime: Long, limit: Int): FlowListStudyRecord }使用Flow作为返回类型意味着数据变化时会自动推送界面层只需要订阅不用手动刷新。这里有个实践上的体会Room查询默认是在后台线程执行如果在主线程直接调用会抛异常。Room的Flow查询天然支持异步配合Repository层使用“数据库访问只在仓库里发生”的约束可以避免大量重复的错误。Repository层做的事情更业务化。比如构建复习队列时从study_records拿到到期记录后还要回到words表把单词的音标、释义、例句全部查出拼装成完整的卡片数据对象。这步如果放在Activity里界面层就会背上数据组装的责任代码会迅速膨胀。仓库模式把组装逻辑与UI隔离后面做单元测试也方便很多。4.2 每日目标与首页进度一个startOfDay的教训每日学习目标的逻辑本质是把“今天”判定做好。最开始的版本里我用System.currentTimeMillis()当天零点作为起始时间结果用户在学习记录里发现凌晨零点前后统计会突然归零。看起来好像没错但实际用户是晚上十一点开始学跨过零点后之前的进度全部“消失”感受非常差。正确的做法是用Calendar或LocalDate计算出本地时区的startOfDay以这个时间戳作为“今日已学”的统计起点。也就是说只要系统日期没变统计区间就不变。用Kotlin写可以这样做val todayStart LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() val todayEnd todayStart 24 * 60 * 60 * 1000L首页展示的内容分四块今日已学单词数、待复习数、总掌握数、学习进度条。进度条用LinearProgressIndicator满值来自用户设置的每日目标默认50个。总掌握数直接从study_records里统计状态为MASTERED的记录数。为了兼顾连续打卡的激励感我还用SharedPreferences存了“最近一次学习日期”和“连续天数”如果上次学习日期是昨天连续天数加一否则重置为一天。这个逻辑虽然简单却是很多用户愿意每天打开App的动机来源。4.3 本地提醒AlarmManager的适配细节每日提醒我使用AlarmManager没有引入任何推送SDK因为离线项目没必要外发数据。实现上用户在设置里打开“每日复习提醒”系统会注册一个定时任务到点弹出一条通知通知内容包含待复习单词数量点击通知直接跳转到复习页面。Android版本差异是最大的坑。Android 13以上需要动态申请通知权限8.0以上必须创建通知渠道低版本的setRepeating在某些厂商ROM上会被系统清理。我的处理方案是权限动态申请通知渠道在Application初始化时创建定时任务用setExactAndAllowWhileIdle尽量保证准时。另外注册了开机广播设备重启后重新注册提醒任务否则用户重启手机后发现提醒消失了会非常困惑。5. 开发中踩过的三个典型问题完整排查链路5.1 Room数据库升级导致闪退旧版本设备上的“no such column”这个问题可以说是我在项目上线前最后一周遭遇的噩梦。当时我给words表增加一个例句翻译字段更新版本号从1升到2。新装设备完全正常但在模拟器里从旧版本升级安装点击单词详情就闪退。抓日志只看到一行android.database.sqlite.SQLiteException: no such column: example_translation (code 1)排查过程是这样推进的。第一步怀疑缓存问题清数据、重装后一切正常说明问题只出现在数据升级路径。第二步打开Room生成的实现类确认SQLite数据库在打开时确实执行了migration逻辑但旧表里根本没有新字段旧的SQL语句在早期查询就会因为列不存在而崩溃。第三步翻出之前用旧版本学习的老数据发现里面的表结构确实缺字段必须补一个显式的Migration。修复方法很简单val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE words ADD COLUMN example_translation TEXT NOT NULL DEFAULT ) } }然后把Migration对象传给Room的addMigrations方法。这件事给我的教训很深开发阶段几乎不会走“旧数据升级”路径因为每次都是全新安装数据库从头创建。真正上线后用户手里全是旧数据升级路径才是决定成败的关键。从那以后测试计划里永远包含两条路径全新安装和旧版本升级。5.2 列表刷新瞬间闪动DiffUtil才是正确解法背词历史记录页面在使用过程中出现过明显的列表闪动。当时实现逻辑是每次更新学习状态后直接调用adapter.notifyDataSetChanged()整个列表会重新绑定所有可见项滑动列表时还出现过勾选状态错乱的问题。根因有两层。表层原因是notifyDataSetChanged不区分具体变化位置对所有item做全量刷新视觉上就是闪动。深层原因是RecyclerView的ViewHolder复用机制一个view被滑动回收后会被复用于新的item如果新item的选中状态没有被正确重置就会出现错乱。解决方式是把列表更新机制改成DiffUtil。DiffUtil通过比较两个列表计算出哪些item需要更新、哪些需要插入或移除然后只做局部刷新。关键前提是每个item要有一个稳定且唯一的ID这里我直接用数据库的主键ID不依赖位置。改造后滑动和状态更新都很平滑还顺带解决了勾选错乱问题因为DiffUtil会触发bind方法重新设置所有可见状态。5.3 导入文件乱码与字段缺失编码探测与容错解析自定义词单导入上线后陆续有朋友反馈“死活导不进去”打开一看全是乱码或者显示导入数量是零。排查发现两个典型原因。第一是文件编码问题Windows环境下用记事本保存的词单默认可能是GBK而代码里强制按UTF-8读取自然全是乱码。第二是词条格式不规范有些行缺少音标整体按三个字段解析就丢掉了一大片。编码问题的解法是自动探测而不是强制指定。读取文件前先检查字节流是否包含UTF-8的BOM标记有BOM就按UTF-8解析如果没有BOM再尝试用GBK解析如果GBK能解析出不常见的“替换字符”则回落为UTF-8。这样能覆盖绝大多数实际场景。字段缺失的解法是在解析时用getOrElse给每个字段提供默认值。音标缺失就填空字符串例句缺失就只保留释义。解析完只把字段数在2个及以上的行当成有效词条并把失败行数统计出来在导入结果页展示为“已导入xx词跳过xx行”。让用户对数据质量一目了然比默默丢数据强得多。6. 成品项目打包与后续扩展方向6.1 打包时容易被忽略的三个细节成品项目的价值在于能直接安装使用所以打包阶段不能只看build能不能过。第一个细节是代码混淆。开启混淆后Room生成的实体类和DAO实现类如果被混淆运行时会因为找不到列名或方法而崩溃。我的做法是给所有数据实体类加Keep注解并在混淆配置里保留Room相关的泛型信息。第二个细节是通知权限的运行时申请。Android 13及以上版本如果不在运行时申请通知权限设置里的提醒开关等于白开。我在设置页的开关点击事件里加入权限申请流程拒绝后回退提示文案不会直接崩溃。第三个细节是签名和渠道。正式发布版要使用单独的签名文件不能继续用debug签名否则后续更新时会因为签名不一致无法覆盖安装。我特意维护了一份版本号递增表每次发布前对比数据库版本号和软件版本号避免出现“数据库版本没变但安装包更替”的状态。6.2 从成品到产品六个值得继续扩展的方向核心功能闭环之后这个项目还有很多自然的演进方向难度递增如下离线语音发音接入本地TTS引擎点击单词朗读学习效率能提升一个档次而且仍然保持离线。OCR拍照取词利用系统相机扫描纸质词单省去手动输入的麻烦这会涉及图像识别与文本后处理。桌面小组件把今日待复习数放到手机桌面无需打开App就能看到进度。学习数据导出把学习记录导出成CSV文件用户可以自行分析自己的复习节奏。多词库并行学习同一时间学习多个词表复习队列按优先级合并。个人云备份通过标准文件协议把数据库上传到指定网盘隐私与便利兼顾。我个人的建议是优先做语音发音和桌面小组件这两个功能对用户的日常体验提升最明显而且实现复杂度可控。OCR看起来炫酷但识别准确率和中文混合文本的切分是巨大坑适合作为进阶挑战。做完这个项目的整体感受是工具类应用最核心的竞争力不在界面设计而在数据模型是否清晰、算法是否符合真实学习习惯。词库的维护和记忆算法的合理简化比多写两个动画效果重要得多。如果你准备复刻类似的成品项目建议先把状态机和复习间隔的单元测试写全再开始搭界面这会让你在后续所有功能开发中都有底气。