ARTICLE · 1268432
「智能记单词」App:Android + Java + Room 分模块实战
一个完全离线、不联网也能背单词的 Android 应用:词书浏览、卡片式学习、艾宾浩斯复习、错词本、学习统计、打卡海报,外加一套后台管理端。
技术栈:Java + Room + Material Design 3 + MPAndroidChart;最低兼容 Android 7.0(API 24),目标 API 33。
一、项目背景与技术方案
1.1 要解决的问题
做课程设计选题时,我翻了手机上几个主流背单词软件,发现三个共性问题:
- 广告和推荐太多
:打开先看开屏广告,背 10 个词弹一次「开通会员」; - 必须联网、必须绑手机号
:地铁里没信号直接白屏; - 只让你背,不告诉你什么时候该复习
:今天背 40 个,明天全忘,软件第二天照旧推新词。
所以给自己定了三条硬指标:纯本地存储(断网可用)、有复习计划(按遗忘曲线排期、到点提醒)、学习数据可见(学了多少、复习完成率、连续打卡)。
最终成品可以概括成一句话:一个把「学—练—复习—统计」做成闭环的本地单词 App。
1.2 技术选型
SQLiteOpenHelper,编译期校验 SQL,实体映射省一半代码 | ||
Executors.newSingleThreadExecutor()runOnUiThread |
1.3 工程结构
按「数据层 / 界面层 / 工具层」分层,包名 com.example.smartdanci:
com.example.smartdanci├── db│ ├── AppDatabase.java // Room 入口 + 预置词库│ ├── entity // 11 个实体│ └── dao // 11 个 DAO 接口├── ui│ ├── splash // 启动页,按登录态与角色分流│ ├── auth // 登录、注册│ ├── main // 主框架 + 5 个 Fragment│ ├── wordbook // 词书详情、搜索、学习计划│ ├── learn // 单词学习│ ├── review // 复习│ ├── wrong // 错词本│ ├── favorite // 收藏│ ├── checkin // 打卡 + 海报│ └── admin // 管理端 6 个页面├── adapter // RecyclerView 适配器├── model // 列表展示模型├── service // 复习提醒(AlarmManager + 通知)└── utils // 艾宾浩斯、日期、Session、密码、收藏
二、功能总览
2.1 用户端
2.2 管理端
用一个 role 字段区分身份,ADMIN 登录后直接进后台:分类管理、词书管理(上下架)、单词管理(增删改查 + 按词书筛选)、用户管理(含禁用账号)、全局数据统计。
三、数据库设计
项目起动时我只设计了 3 张表(user / word / study_record),写到复习功能就卡住了——「单词是什么」和「我学这个单词的状态」是两回事,硬塞在一张表里,复习档位无法唯一确定。最终拆成 11 张表:
users | ||
categories | ||
word_books | ||
words | ||
user_word_books | ||
learning_records | ||
review_records | ||
wrong_words | ||
favorites | ||
study_statistics | ||
check_ins |
三点设计取舍:
- 词库与用户词库分离
。 words只存「词是什么」,learning_records存「谁在哪天学了哪个词」。同一个词被 100 个人学,words里也只有一条。 - 复习进度单独一张表
。 review_records是艾宾浩斯的载体,一个用户 + 一个单词只有一条记录,interval_level表示处在第几档间隔。放进learning_records会变成「学一次插一条」,档位无法唯一确定。 - 日期用字符串
。 date统一yyyy-MM-dd,因为要做「按天聚合」和「连续打卡判断」,字符串比较 +ORDER BY date比时间戳换算省事;毫秒时间戳只留给需要精确到时刻的next_review_time。
首次启动就有词可背,靠 Room 的 Callback.onCreate() 灌数据:
@Database(entities = {User.class, Category.class, WordBook.class, Word.class,UserWordBook.class, LearningRecord.class, ReviewRecord.class,WrongWord.class, Favorite.class, StudyStatistic.class, CheckIn.class},version = 2,exportSchema = false)public abstract class AppDatabase extends RoomDatabase {private static volatile AppDatabase INSTANCE;public static AppDatabase getInstance(Context context) {if (INSTANCE == null) {synchronized (AppDatabase.class) {if (INSTANCE == null) {INSTANCE = Room.databaseBuilder(context.getApplicationContext(),AppDatabase.class,"smartdanci.db").addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration().addCallback(new PrepopulateCallback()).build();}}}return INSTANCE;}private static class PrepopulateCallback extends Callback {@Overridepublic void onCreate(@NonNull SupportSQLiteDatabase db) {super.onCreate(db);// 回调此时 INSTANCE 还没赋值,必须切子线程并等实例可用Executors.newSingleThreadExecutor().execute(() -> {AppDatabase database = INSTANCE;if (database == null) return;prepopulate(database);});}}}
灌数据时用插入返回的自增 id 建立关联,顺序必须是「分类 → 词书 → 单词」:
Category cet4Cat = new Category("大学英语四级", 1);long cet4CatId = db.categoryDao().insert(cet4Cat);WordBook cet4Book = new WordBook(cet4CatId, "四级核心词汇","收录大学英语四级考试高频核心词汇", 30);long cet4BookId = db.wordBookDao().insert(cet4Book);db.wordDao().insertAll(new Word(cet4BookId, "abandon", "/əˈbændən/", "v. 放弃;抛弃","to give up completely", "They had to abandon the plan due to lack of funding."),new Word(cet4BookId, "abstract", "/ˈæbstrækt/", "adj. 抽象的;n. 摘要","existing in thought rather than matter", "The concept is too abstract for beginners.")// ... 其余 28 个);
四、主框架:5 个 Fragment 只创建一次
在讲模块之前,先把「容器」说清楚。很多教程切 Tab 是这么写的:
getSupportFragmentManager().beginTransaction().replace(R.id.container, new HomeFragment()).commit();
replace 每次都会销毁上一个 Fragment 并重新 onCreateView:从词书页切到首页再切回来,滚动位置没了、数据重新加载、页面闪一下。我改成 add + hide/show,5 个 Fragment 只加一次,切换只改可见性:
public class MainActivity extends AppCompatActivity {private final HomeFragment homeFragment = new HomeFragment();private final WordBookFragment wordBookFragment = new WordBookFragment();private final ReviewFragment reviewFragment = new ReviewFragment();private final StatisticsFragment statisticsFragment = new StatisticsFragment();private final ProfileFragment profileFragment = new ProfileFragment();private final FragmentManager fm = getSupportFragmentManager();private Fragment activeFragment;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);// 先全部 add 进来并 hide,最后一个不 hide 作为默认页fm.beginTransaction().add(R.id.fragment_container, profileFragment, "5").hide(profileFragment).commit();fm.beginTransaction().add(R.id.fragment_container, statisticsFragment, "4").hide(statisticsFragment).commit();fm.beginTransaction().add(R.id.fragment_container, reviewFragment, "3").hide(reviewFragment).commit();fm.beginTransaction().add(R.id.fragment_container, wordBookFragment, "2").hide(wordBookFragment).commit();fm.beginTransaction().add(R.id.fragment_container, homeFragment, "1").commit();activeFragment = homeFragment;bottomNav.setOnItemSelectedListener(item -> {Fragment target =item.getItemId() == R.id.nav_home ? homeFragment: item.getItemId() == R.id.nav_wordbook ? wordBookFragment: item.getItemId() == R.id.nav_review ? reviewFragment: item.getItemId() == R.id.nav_statistics ? statisticsFragment: profileFragment;fm.beginTransaction().hide(activeFragment).show(target).commit();activeFragment = target;return true;});}}
代价是 hide/show 不会走 onCreateView,所以每个 Fragment 都要在 onResume() 里主动刷新:
@Overridepublic void onResume() {super.onResume();loadData(); // 从学习页返回时,首页的「今日已学」必须是最新的}
五、功能模块实现
下面按模块逐个说:每个模块做什么 → 核心代码 → 运行效果。
5.1 用户认证模块
功能点:注册校验(用户名唯一、两次密码一致、长度限制)、登录校验(用户存在 / 密码正确 / 账号未禁用)、记住密码、登录态保持、按角色跳转。
核心代码:密码不存明文,注册时 SHA-256 入库,登录时同样加密后比对;登录成功把身份写进 SessionManager,再按 role 分流:
privatevoiddoLogin() {String username = etUsername.getText().toString().trim();String password = etPassword.getText().toString().trim();if (TextUtils.isEmpty(username)) { etUsername.setError("请输入用户名"); return; }if (TextUtils.isEmpty(password)) { etPassword.setError("请输入密码"); return; }String encryptedPassword = PasswordUtils.encrypt(password); // SHA-256Executors.newSingleThreadExecutor().execute(() -> {User user = db.userDao().findByUsername(username);runOnUiThread(() -> {if (user == null) {Toast.makeText(this, "用户不存在", Toast.LENGTH_SHORT).show();} else if (!encryptedPassword.equals(user.password)) {Toast.makeText(this, "密码错误", Toast.LENGTH_SHORT).show();} else if (user.status == 0) {Toast.makeText(this, "账号已被禁用,请联系管理员", Toast.LENGTH_SHORT).show();} else {session.saveLoginInfo(user.id, user.username, user.role, user.nickname);if (cbRemember.isChecked()) {session.saveCredentials(username, password);} else {session.clearCredentials();}if ("ADMIN".equals(user.role)) {startActivity(new Intent(this, AdminMainActivity.class));} else {startActivity(new Intent(this, MainActivity.class));}finish();}});});}
加密工具只有二十来行:
public static String encrypt(String password) {MessageDigest digest = MessageDigest.getInstance("SHA-256");byte[] hash = digest.digest(password.getBytes());StringBuilder hexString = new StringBuilder();for (byte b : hash) {String hex = Integer.toHexString(0xff & b);if (hex.length() == 1) hexString.append('0');hexString.append(hex);}return hexString.toString();}
运行效果:登录页把品牌区(Logo + 名称 + 副标题)、圆角输入框、渐变主按钮、记住密码与注册入口依次排开,视觉焦点只有一个「登录」。

图 1 登录页
注册页四个输入框等距排列,错误提示用 setError 气泡而不是 Toast,避免连续弹窗打断输入。

图 2 注册页
5.2 首页概览模块
功能点:一屏回答「我今天该做什么」——连续打卡几天、今天新学多少、今天复习多少、还有多少词待复习、当前在学哪本词书、进度到哪。
核心代码:所有数字都从库里现算,五个查询放在同一个子线程里,最后一次性回主线程渲染:
Executors.newSingleThreadExecutor().execute(() -> {// 连续打卡天数CheckIn checkIn = db.checkInDao().findByUserAndDate(userId, today);int streak = checkIn != null ? checkIn.streakDays : 0;// 今日新学 / 今日复习int todayNew = db.learningRecordDao().countTodayLearned(userId, todayStart);int todayReviewed = db.reviewRecordDao().countTodayReviewed(userId, todayStart);// 待复习(先修正历史数据,再计数)long now = System.currentTimeMillis();db.reviewRecordDao().fixPendingInitialReviews(userId, now);int pending = db.reviewRecordDao().countPendingReviews(userId, now);// 当前词书与进度UserWordBook uwb = db.userWordBookDao().findCurrentByUser(userId);requireActivity().runOnUiThread(() -> {tvStreak.setText(String.valueOf(streak));tvTodayNew.setText(String.valueOf(todayNew));tvTodayReview.setText(String.valueOf(todayReviewed));tvPendingReview.setText(String.valueOf(pending));// 没有选词书时整张卡片隐藏,而不是显示 0/0cardCurrentBook.setVisibility(bookName.isEmpty() ? View.GONE : View.VISIBLE);});});
运行效果:顶部问候卡放「今天也要加油记单词」和一个每日一词,中间三格数据卡(数字用主题色区分),下面是学习进度环和两个快捷入口。

图 3 首页:问候卡 + 数据卡 + 进度环 + 快捷入口
5.3 词书浏览与搜索模块
功能点:分类横向筛选 → 词书列表 → 词书详情(简介、词数、开始学习、制定计划);单词列表支持关键词搜索(LIKE '%关键词%');无结果时显示空状态。
核心代码:分类和词书是两级联动,选中分类后重新查词书;列表用适配器的 setData() 整体替换,而不是 notifyDataSetChanged() 全量刷新:
privatevoidloadCategories() {Executors.newSingleThreadExecutor().execute(() -> {List<Category> cats = db.categoryDao().findAll();requireActivity().runOnUiThread(() -> {categories.clear();categories.addAll(cats);categoryAdapter.notifyDataSetChanged();if (!cats.isEmpty()) {selectedCategoryId = cats.get(0).id;categoryAdapter.setSelectedId(selectedCategoryId);loadWordBooks(selectedCategoryId);}});});}privatevoidloadWordBooks(long categoryId) {Executors.newSingleThreadExecutor().execute(() -> {List<WordBook> books = db.wordBookDao().findByCategory(categoryId);List<WordBookItem> items = new ArrayList<>();for (WordBook wb : books) {items.add(new WordBookItem(wb));}requireActivity().runOnUiThread(() -> wordBookAdapter.setData(items));});}
搜索框用 TextWatcher 边输入边查,输入为空则清空结果,避免「搜完删字还剩一堆旧数据」:
etSearch.addTextChangedListener(new TextWatcher() {@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}@Override public void onTextChanged(CharSequence s, int start, int before, int count) {}@Overridepublic void afterTextChanged(Editable s) {String keyword = s.toString().trim();if (keyword.length() >= 1) {search(keyword); // 子线程查库 + runOnUiThread 刷新} else {adapter.setData(new ArrayList<>());}}});
运行效果:顶部搜索框 + 分类 Chip(选中态填充主色),下面是单词卡片,每张卡包含单词、词性标签、音标、中文释义和右侧收藏星标。

图 4 单词列表:搜索 + 分类 Chip + 单词卡片
5.4 单词学习模块
功能点:卡片式学习,正面单词 + 音标,点卡片翻面看释义、英文释义与例句;「认识 / 不认识」决定这个词接下来的命运;学完自动推进词书进度,下次进来接着上次的位置。
核心代码:一次点击要同时更新 4 张表,这是整个项目里最容易写漏的地方:
learning_records | review_records | wrong_words | study_statistics | |
|---|---|---|---|---|
private void markWord(int familiarity) {Word word = words.get(currentIndex);long userId = session.getUserId();Executors.newSingleThreadExecutor().execute(() -> {// 1. 学习流水db.learningRecordDao().insert(new LearningRecord(userId, word.id, bookId, familiarity));// 2. 推进词书进度(只前进,不回退)UserWordBook plan = db.userWordBookDao().findByUserAndBook(userId, bookId);if (plan != null && currentIndex >= plan.progress) {db.userWordBookDao().updateProgress(plan.id, currentIndex + 1);}// 3. 复习调度ReviewRecord rr = db.reviewRecordDao().findByUserAndWord(userId, word.id);if (familiarity == 1) {if (rr != null) {rr.reviewCount++;rr.intervalLevel = EbbinghausUtils.advanceLevel(rr.intervalLevel);rr.nextReviewTime = EbbinghausUtils.getNextReviewTime(rr.intervalLevel);rr.lastReviewTime = System.currentTimeMillis();db.reviewRecordDao().update(rr);} else {db.reviewRecordDao().insert(new ReviewRecord(userId, word.id, 0, EbbinghausUtils.getInitialReviewTime()));}} else {// 不认识 → 进错词本 + 复习档位归零WrongWord ww = db.wrongWordDao().findByUserAndWord(userId, word.id);if (ww != null) {ww.wrongCount++;ww.lastWrongTime = System.currentTimeMillis();ww.isCleared = false;db.wrongWordDao().update(ww);} else {db.wrongWordDao().insert(new WrongWord(userId, word.id));}if (rr != null) {rr.intervalLevel = EbbinghausUtils.resetLevel();rr.nextReviewTime = EbbinghausUtils.getInitialReviewTime();db.reviewRecordDao().update(rr);}}// 4. 当日统计String today = DateUtils.today();StudyStatistic stat = db.studyStatisticDao().findByUserAndDate(userId, today);if (stat == null) {stat = new StudyStatistic(userId, today);db.studyStatisticDao().insert(stat);}stat.newWords++;stat.studyDuration += 30; // 每学一个词按 30 秒估算db.studyStatisticDao().update(stat);currentIndex++;runOnUiThread(() -> showWord(currentIndex));});}
运行效果:顶部是「进度 1/50 + 今日已学 0/20」和细进度条,中间大卡片放单词、音标、分隔线、释义与例句,底部是「不认识」(描边红)和「认识」(渐变主色)一对按钮,主次分明。

图 5 学习页:进度条 + 单词卡 + 双按钮
5.5 艾宾浩斯复习模块
功能点:复习入口页展示「待复习 N 个 / 今日已复习 M 个」,提供两种复习方式——快速模式(先看单词回忆,点「显示释义」再自评)和详细模式(直接显示全部信息);认识的词间隔拉长,不认识的打回第 0 档并进错词本。
核心代码:算法的全部内容其实就是一组间隔天数 + 一个时间戳字段:
public class EbbinghausUtils {// 完成一次复习后的间隔(天)private static final int[] INTERVALS = {1, 2, 4, 7, 15, 30};private static final int MAX_LEVEL = INTERVALS.length - 1;/** 首次学完当天即可进入待复习队列 */publicstaticlonggetInitialReviewTime() {return System.currentTimeMillis();}/** 根据当前档位计算下次复习时间 */publicstaticlonggetNextReviewTime(int intervalLevel) {int level = Math.max(0, Math.min(intervalLevel, MAX_LEVEL));int days = INTERVALS[level];return System.currentTimeMillis() + days * 24L * 60 * 60 * 1000;}/** 认识:档位 +1,最高第 5 档(30 天) */publicstaticintadvanceLevel(int currentLevel) {return Math.min(currentLevel + 1, MAX_LEVEL);}/** 不认识:打回第 0 档,明天再来 */publicstaticintresetLevel() {return 0;}}
「到期」不是定时任务,而是查询时用时间戳比较,所以 App 不常驻也能保证不漏:
@Query("SELECT * FROM review_records WHERE user_id = :userId " +"AND next_review_time <= :currentTime ORDER BY next_review_time ASC")List<ReviewRecord> findPendingReviews(long userId, long currentTime);
自评后推进档位,同时补写当日统计:
private void markReview(int mastery) {ReviewRecord record = reviewRecords.get(currentIndex);long userId = session.getUserId();Executors.newSingleThreadExecutor().execute(() -> {if (mastery == 1) {record.reviewCount++;record.intervalLevel = EbbinghausUtils.advanceLevel(record.intervalLevel);record.nextReviewTime = EbbinghausUtils.getNextReviewTime(record.intervalLevel);} else {record.intervalLevel = EbbinghausUtils.resetLevel();record.nextReviewTime = EbbinghausUtils.getInitialReviewTime();// 同时写入错词本}record.lastReviewTime = System.currentTimeMillis();db.reviewRecordDao().update(record);StudyStatistic stat = /* 取当天记录,没有则新建 */;stat.reviewWords++;stat.reviewCompleted++;stat.studyDuration += 20;db.studyStatisticDao().update(stat);currentIndex++;runOnUiThread(() -> showWord(currentIndex));});}
这里踩过一个真实的坑:第一版把新词的 nextReviewTime 算成了 now + 1 天,结果刚背完 20 个词,复习页却显示「暂无待复习单词」,而历史数据已经写死成未来了。解法是查询前先跑一条 UPDATE 修正脏数据:
/** 修复首次学习却被排到未来的复习记录,使其立即可复习 */@Query("UPDATE review_records SET next_review_time = :currentTime " +"WHERE user_id = :userId AND review_count = 0 AND next_review_time > :currentTime")voidfixPendingInitialReviews(long userId, long currentTime);
运行效果:复习页沿用与学习页一致的卡片布局(同一套 CardView + 双按钮),快速模式下释义区折叠,只留一个「显示释义」按钮,减少「先看到答案」的干扰。
5.6 错词本模块
功能点:学习中答错、复习中答错的词自动进错词本;列表展示错误次数;支持单个移除(「这个我已经记住了」)和错词专项复习。
核心代码:错词复习的判定与正常复习相反——答对即出本,同时把复习档位往前推一档:
Executors.newSingleThreadExecutor().execute(() -> {if (mastery == 1) {db.wrongWordDao().clearByUserAndWord(userId, word.id); // 答对:移出错词本ReviewRecord rr = db.reviewRecordDao().findByUserAndWord(userId, word.id);if (rr != null) {rr.intervalLevel = EbbinghausUtils.advanceLevel(rr.intervalLevel);rr.nextReviewTime = EbbinghausUtils.getNextReviewTime(rr.intervalLevel);rr.lastReviewTime = System.currentTimeMillis();db.reviewRecordDao().update(rr);}} else {WrongWord ww = db.wrongWordDao().findByUserAndWord(userId, word.id);if (ww != null) {ww.wrongCount++;ww.lastWrongTime = System.currentTimeMillis();db.wrongWordDao().update(ww);}}// 统计 reviewWords++});
列表项上直接把错误次数渲染成一个小标签,WordItemAdapter 里根据 extraCount 决定显隐:
if (item.extraCount > 0) {holder.tvExtra.setVisibility(View.VISIBLE);holder.tvExtra.setText("错误 " + item.extraCount + " 次");} else {holder.tvExtra.setVisibility(View.GONE);}
运行效果:和单词列表同一套卡片样式,右侧多一个红色「错误 N 次」标签,顶部显示「共 N 个错题」,底部固定「开始复习错题」按钮。
5.7 个人中心模块
功能点:展示昵称与账号、收藏 / 错词本 / 打卡三个入口、修改密码弹窗、退出登录。
核心代码:修改密码用 AlertDialog + 自定义布局,点「确定」后重新绑定按钮事件(默认的 setPositiveButton 会自动关闭弹窗,校验失败就没机会提示):
private void showChangePasswordDialog() {AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());builder.setTitle("修改密码");View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_change_password, null);builder.setView(dialogView);builder.setPositiveButton("确定", (d, w) -> {});builder.setNegativeButton("取消", null);AlertDialog dialog = builder.create();dialog.show();dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {EditText etOld = dialogView.findViewById(R.id.et_old_password);EditText etNew = dialogView.findViewById(R.id.et_new_password);String oldPwd = etOld.getText().toString().trim();String newPwd = etNew.getText().toString().trim();if (oldPwd.isEmpty() || newPwd.isEmpty()) {Toast.makeText(getContext(), "请填写完整", Toast.LENGTH_SHORT).show();return; // 不 dismiss,让用户接着改}if (newPwd.length() < 6) {Toast.makeText(getContext(), "新密码至少6位", Toast.LENGTH_SHORT).show();return;}Executors.newSingleThreadExecutor().execute(() -> {User user = db.userDao().findById(session.getUserId());if (user == null || !PasswordUtils.encrypt(oldPwd).equals(user.password)) {requireActivity().runOnUiThread(() ->Toast.makeText(getContext(), "旧密码错误", Toast.LENGTH_SHORT).show());return;}db.userDao().updatePassword(session.getUserId(), PasswordUtils.encrypt(newPwd));requireActivity().runOnUiThread(() -> {Toast.makeText(getContext(), "密码修改成功", Toast.LENGTH_SHORT).show();dialog.dismiss();});});});}
退出登录时清空 SP 并用 CLEAR_TASK 把返回栈一起清掉,防止按返回键又回到已登录页面:
btnLogout.setOnClickListener(v -> {session.logout();Intent intent = new Intent(getActivity(), LoginActivity.class);intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);startActivity(intent);});
运行效果:顶部渐变头图放头像、昵称、签名,下方三列数据(已掌握/已收藏/学习天数)压在头图下沿,再往下是功能入口列表。

图 6 个人中心:渐变头部 + 数据卡 + 功能入口
5.8 收藏模块
功能点:学习页、复习页、错词复习页都有星标按钮,点一下收藏、再点取消,状态实时同步;收藏列表支持取消收藏和「只复习收藏的词」。
核心代码:收藏逻辑被抽成 FavoriteHelper,三处复用,避免每个页面各写一遍「查 → 删/插 → 改图标」:
public class FavoriteHelper {/** 切换收藏状态 */publicstaticvoidtoggle(Context context, MaterialButton button,AppDatabase db, long userId, Word word) {Executors.newSingleThreadExecutor().execute(() -> {Favorite existing = db.favoriteDao().findByUserAndWord(userId, word.id);boolean favorited;if (existing != null) {db.favoriteDao().deleteByUserAndWord(userId, word.id);favorited = false;} else {db.favoriteDao().insert(new Favorite(userId, word.id));favorited = true;}boolean finalFavorited = favorited;button.post(() -> {updateButtonState(button, finalFavorited);Toast.makeText(context,finalFavorited ? R.string.favorite_added : R.string.favorite_removed,Toast.LENGTH_SHORT).show();});});}/** 进入页面时同步图标状态 */publicstaticvoidrefreshState(MaterialButton button, AppDatabase db, long userId, long wordId) {Executors.newSingleThreadExecutor().execute(() -> {boolean favorited = db.favoriteDao().findByUserAndWord(userId, wordId) != null;button.post(() -> updateButtonState(button, favorited));});}publicstaticvoidupdateButtonState(MaterialButton button, boolean favorited) {int colorRes = favorited ? R.color.streak_gold : R.color.text_hint;button.setIconTint(ColorStateList.valueOf(ContextCompat.getColor(button.getContext(), colorRes)));}}
细节:UI 更新用的是
button.post()而不是runOnUiThread(),这样 Helper 不需要持有 Activity 引用,Activity 和 Fragment 里的按钮都能直接调用。
运行效果:收藏列表与单词列表共用 item_word_list.xml,星标为金色实心表示已收藏;顶部有「共 N 个收藏」和「复习收藏」入口。

图 7 我的收藏:分类标签 + 金色星标
5.9 学习计划模块
功能点:开始学一本词书前,先定「每日新词数、每日复习数、目标完成日期」;计划随时可改,改完立即生效到学习页的「今日已学 x / 目标 y」。
核心代码:保存计划时若已有记录就更新,没有就先把其他词书的 is_current 清掉再插入,保证「当前词书」全局唯一:
privatevoidsavePlan() {int dailyNew = Integer.parseInt(etDailyNew.getText().toString().trim());int dailyReview = Integer.parseInt(etDailyReview.getText().toString().trim());Executors.newSingleThreadExecutor().execute(() -> {UserWordBook existing = db.userWordBookDao().findByUserAndBook(session.getUserId(), bookId);if (existing != null) {existing.dailyNew = dailyNew;existing.dailyReview = dailyReview;existing.targetDate = targetCalendar.getTimeInMillis();existing.isCurrent = true;db.userWordBookDao().update(existing);} else {db.userWordBookDao().clearCurrent(session.getUserId()); // 先清空当前标记UserWordBook uwb = new UserWordBook(session.getUserId(), bookId, dailyNew, dailyReview);uwb.targetDate = targetCalendar.getTimeInMillis();db.userWordBookDao().insert(uwb);}runOnUiThread(() -> {Toast.makeText(this, "学习计划已保存", Toast.LENGTH_SHORT).show();finish();});});}
目标日期用系统 DatePickerDialog,显示成「2026年06月30日」这种中文格式:
tvTargetDate.setOnClickListener(v -> {new DatePickerDialog(this, (view, year, month, dayOfMonth) -> {targetCalendar.set(Calendar.YEAR, year);targetCalendar.set(Calendar.MONTH, month);targetCalendar.set(Calendar.DAY_OF_MONTH, dayOfMonth);updateTargetDateDisplay();}, targetCalendar.get(Calendar.YEAR),targetCalendar.get(Calendar.MONTH),targetCalendar.get(Calendar.DAY_OF_MONTH)).show();});
运行效果:卡片式表单,输入框与开关分块排版,主按钮「保存设置」用渐变,危险操作「清除学习记录」单独放在下面并用警示色,操作前二次确认。

图 8 学习计划 / 设置:目标、提醒开关、清除记录
5.10 打卡与海报分享模块
功能点:每天打卡累计连续天数(断签归 1);打卡后生成一张 800×1200 的海报,可分享到微信也可存进相册。
核心代码:海报不依赖任何图片素材,直接用 Canvas 画底色、圆形徽章和文字:
private Bitmap createPosterBitmap() {int width = 800, height = 1200;Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);Canvas canvas = new Canvas(bitmap);Paint bgPaint = new Paint();bgPaint.setColor(Color.parseColor("#3DABA8"));canvas.drawRect(0, 0, width, height, bgPaint);// 连续天数徽章Paint circlePaint = new Paint();circlePaint.setColor(Color.parseColor("#FFD666"));canvas.drawCircle(width / 2f, 550, 80, circlePaint);Paint streakPaint = new Paint();streakPaint.setColor(Color.WHITE);streakPaint.setTextSize(72);streakPaint.setFakeBoldText(true);streakPaint.setTextAlign(Paint.Align.CENTER);canvas.drawText(String.valueOf(streakDays), width / 2f, 575, streakPaint);return bitmap;}
分享不能用 file://(Android 7.0 起直接抛 FileUriExposedException),必须走 FileProvider:
File file = new File(getCacheDir(), "poster.png");FileOutputStream fos = new FileOutputStream(file);bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);fos.close();Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", file);Intent shareIntent = new Intent(Intent.ACTION_SEND);shareIntent.setType("image/png");shareIntent.putExtra(Intent.EXTRA_STREAM, uri);shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);startActivity(Intent.createChooser(shareIntent, "分享打卡海报"));
配合清单里的声明:
<providerandroid:name="androidx.core.content.FileProvider"android:authorities="${applicationId}.fileprovider"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid:name="android.support.FILE_PROVIDER_PATHS"android:resource="@xml/file_paths" /></provider>
运行效果:大号连续天数 + 「今日已打卡 ✅」状态 + 已学单词数,底部「打卡 / 分享海报 / 保存到相册」三个按钮;已打卡后按钮置灰并锁定。
5.11 学习统计模块
功能点:累计学习词数、今日学习时长(小时)、今日复习完成率、近 7 天学习量柱状图。
核心代码:数据库里只有「有学习记录的那几天」,直接取 7 条会日期断档、柱子错位,所以先按日期建 7 天空数组再填值:
privatevoidbuildWeeklyChartData(long userId, List<BarEntry> entries, List<String> labels) {Map<String, StudyStatistic> statMap = new HashMap<>();for (StudyStatistic item : db.studyStatisticDao().findRecent7Days(userId)) {statMap.put(item.date, item);}SimpleDateFormat dateKeyFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());SimpleDateFormat labelFormat = new SimpleDateFormat("M/d", Locale.getDefault());Calendar calendar = Calendar.getInstance();for (int i = 6; i >= 0; i--) {calendar.setTimeInMillis(System.currentTimeMillis());calendar.add(Calendar.DAY_OF_YEAR, -i);labels.add(labelFormat.format(calendar.getTime()));StudyStatistic dayStat = statMap.get(dateKeyFormat.format(calendar.getTime()));int value = dayStat != null ? (dayStat.newWords + dayStat.reviewWords) : 0;entries.add(new BarEntry(6 - i, value)); // 缺失日期补 0,柱子才对齐}}
MPAndroidChart 默认样式偏「工程图」,关掉描述、图例、网格背景和右侧 Y 轴,柱宽 0.55,颜色取主题色:
BarDataSet dataSet = new BarDataSet(entries, "学习量");dataSet.setColor(primaryColor);dataSet.setDrawValues(true);chartWeekly.setData(new BarData(dataSet));chartWeekly.getDescription().setEnabled(false);chartWeekly.getLegend().setEnabled(false);chartWeekly.setDrawGridBackground(false);chartWeekly.setDrawBarShadow(false);XAxis xAxis = chartWeekly.getXAxis();xAxis.setPosition(XAxis.XAxisPosition.BOTTOM);xAxis.setGranularity(1f);xAxis.setDrawGridLines(false);xAxis.setValueFormatter(new IndexAxisValueFormatter(labels));chartWeekly.getAxisLeft().setAxisMinimum(0f);chartWeekly.getAxisRight().setEnabled(false);chartWeekly.animateY(600);chartWeekly.invalidate();
运行效果:上方三个数字卡(累计学习 / 今日时长 / 复习完成率)横排,下方是近 7 天柱状图,带 600ms 纵向生长动画。
5.12 管理端模块
功能点:分类、词书、单词的增删改查,用户列表(可禁用),全局统计(用户数、词书数、词数、打卡次数)。
核心代码:进入后台先做一次角色校验,防止普通用户绕过入口直接拉起 Activity:
if (!"ADMIN".equals(session.getRole())) {session.logout();startActivity(new Intent(this, LoginActivity.class));finish();return;}
管理端的列表页统一复用 activity_admin_list.xml(标题 + 可选筛选 Spinner + RecyclerView + 「新增」按钮),单词管理按词书筛选:
// 顶部 Spinner 绑定词书列表,选中后重新加载该词书下的单词ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(this,android.R.layout.simple_spinner_item, bookNames);spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);spinnerBooks.setAdapter(spinnerAdapter);
新增/编辑统一用 AlertDialog + 表单布局,一套代码覆盖「增」和「改」两个入口——传 null 是新增,传实体就是编辑。
运行效果:卡片式入口(分类管理 / 词书管理 / 单词管理 / 用户管理 / 数据统计),顶部显示「管理员:xxx」和当前用户数,配色与用户端保持一致。
源码获取
需要学习代码的私信回复:智能单词程序有完整的代码,演示视频,报告文档,可以供给学习
工程规模:约 5000 行 Java(64 个类)、32 个布局文件、87 个 XML 资源。
测试账号:
admin / admin123(管理端)、demo / 123456(用户端)标签:Android、Java、Room、SQLite、Material Design、艾宾浩斯、课程设计















































































