做这个安卓端记单词背单词系统的想法,最开始来自一次很普通的吐槽。身边有朋友在备考,每天在地铁上抱着手机刷单词,可用的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 | 首次答题(认识/模糊/忘记) | LEARNING |
| LEARNING | 连续答对3次 | MASTERED |
| LEARNING | 答错 | 保持LEARNING,间隔重置 |
| REVIEWING | 到期复习且答对 | 复习次数加一,间隔拉长 |
| MASTERED | 后续复习答错 | 降级为LEARNING |
| MASTERED | 长期未复习 | 保留状态,进入复习队列 |
这个状态机最大的好处是可测试。每个转换都是确定性的,只需要一个纯函数传入当前状态和答题结果,就能得到下一个状态。我刚开始写的时候把状态判断散落在各个点击事件里,很快发现逻辑经常互相覆盖,比如“忘记”按钮改了次数却没改状态。重构之后,所有状态转换都收敛到同一处,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): Flow<List<StudyRecord>> }使用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看起来炫酷,但识别准确率和中文混合文本的切分是巨大坑,适合作为进阶挑战。
做完这个项目的整体感受是,工具类应用最核心的竞争力不在界面设计,而在数据模型是否清晰、算法是否符合真实学习习惯。词库的维护和记忆算法的合理简化,比多写两个动画效果重要得多。如果你准备复刻类似的成品项目,建议先把状态机和复习间隔的单元测试写全,再开始搭界面,这会让你在后续所有功能开发中都有底气。