夜雨聆风学习资料网

ARTICLE · 1268432

「智能记单词」App:Android + Java + Room 分模块实战

「智能记单词」App:Android + Java + Room 分模块实战

一个完全离线、不联网也能背单词的 Android 应用:词书浏览、卡片式学习、艾宾浩斯复习、错词本、学习统计、打卡海报,外加一套后台管理端。

技术栈:Java + Room + Material Design 3 + MPAndroidChart;最低兼容 Android 7.0(API 24),目标 API 33。


一、项目背景与技术方案

1.1 要解决的问题

做课程设计选题时,我翻了手机上几个主流背单词软件,发现三个共性问题:

  1. 广告和推荐太多
    :打开先看开屏广告,背 10 个词弹一次「开通会员」;
  2. 必须联网、必须绑手机号
    :地铁里没信号直接白屏;
  3. 只让你背,不告诉你什么时候该复习
    :今天背 40 个,明天全忘,软件第二天照旧推新词。

所以给自己定了三条硬指标:纯本地存储(断网可用)、有复习计划(按遗忘曲线排期、到点提醒)、学习数据可见(学了多少、复习完成率、连续打卡)。

最终成品可以概括成一句话:一个把「学—练—复习—统计」做成闭环的本地单词 App。

1.2 技术选型

层面
选择
说明
语言
Java 8
课程要求 Java,生态成熟、资料多
数据库
Room 2.5.2
相比裸 SQLiteOpenHelper,编译期校验 SQL,实体映射省一半代码
UI
Material Components 1.9.0 + Material 3 主题
CardView、BottomNavigationView、MaterialButton
列表
RecyclerView 1.3.1 + 自定义 Adapter
词书、单词、错词、收藏、管理端共用一套思路
图表
MPAndroidChart 3.1.0
近 7 天学习量柱状图
存储
Room + SharedPreferences
结构化数据进库,登录态/记住密码进 SP
构建
compileSdk 33 / minSdk 24 / targetSdk 33
覆盖 Android 7.0 ~ 13
线程
Executors.newSingleThreadExecutor()
 + runOnUiThread
没引 RxJava,够用且好讲

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 用户端

模块
子功能
用户认证
注册、登录、记住密码、登录态保持、修改密码、按角色分流
首页概览
连续打卡天数、今日新学、今日复习、待复习数、当前词书进度卡
词书浏览
6 个分类(四级/六级/考研/高考/雅思/托福)、词书列表、单词列表、搜索
单词学习
卡片正反面、认识/不认识、收藏、断点续学、词书进度推进
复习
艾宾浩斯 1/2/4/7/15/30 天、快速模式/详细模式、答错自动进错词本
错词本
错误次数记录、单个移除、错词专项复习
收藏
学习/复习页星标切换、收藏列表、收藏专项复习
学习计划
每日新词数、每日复习数、目标完成日期
打卡
连续天数、Canvas 海报生成、分享/保存相册
统计
累计学习词数、今日时长、复习完成率、近 7 天柱状图
个人中心
资料、功能入口、修改密码、退出登录

2.2 管理端

用一个 role 字段区分身份,ADMIN 登录后直接进后台:分类管理、词书管理(上下架)、单词管理(增删改查 + 按词书筛选)、用户管理(含禁用账号)、全局数据统计。


三、数据库设计

项目起动时我只设计了 3 张表(user / word / study_record),写到复习功能就卡住了——「单词是什么」和「我学这个单词的状态」是两回事,硬塞在一张表里,复习档位无法唯一确定。最终拆成 11 张表:

表名
作用
关键字段
users
用户
username、password(SHA-256)、role、nickname、status
categories
词书分类
name、sort_order
word_books
词书
category_id、name、description、word_count、status
words
单词
book_id、word、phonetic、meaning_cn、meaning_en、example
user_word_books
用户词书 + 学习计划
user_id、book_id、daily_new、daily_review、target_date、progress、is_current
learning_records
学习流水
user_id、word_id、book_id、familiarity、create_time
review_records
复习调度
user_id、word_id、review_count、interval_level、next_review_time、last_review_time
wrong_words
错词本
user_id、word_id、wrong_count、last_wrong_time、is_cleared
favorites
收藏
user_id、word_id、create_time
study_statistics
每日统计
user_id、date、study_duration、new_words、review_words、review_total、review_completed
check_ins
打卡
user_id、date、streak_days

三点设计取舍:

  1. 词库与用户词库分离
    。words 只存「词是什么」,learning_records 存「谁在哪天学了哪个词」。同一个词被 100 个人学,words 里也只有一条。
  2. 复习进度单独一张表
    。review_records 是艾宾浩斯的载体,一个用户 + 一个单词只有一条记录,interval_level 表示处在第几档间隔。放进 learning_records 会变成「学一次插一条」,档位无法唯一确定。
  3. 日期用字符串
    。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 {        @Override        public 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;    @Override    protected 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-256    Executors.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/0        cardCurrentBook.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) {}    @Override    public 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_recordsreview_recordswrong_wordsstudy_statistics
认识
插一条流水
档位 +1,重算下次复习时间
—
new_words +1
不认识
插一条流水
档位归零,立即可复习
错误次数 +1
new_words +1
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, "分享打卡海报"));

配合清单里的声明:

<provider    android:name="androidx.core.content.FileProvider"    android:authorities="${applicationId}.fileprovider"    android:exported="false"    android:grantUriPermissions="true">    <meta-data        android: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、艾宾浩斯、课程设计

相关学习资料