简介:一份基于Android平台的读书笔记App毕业设计论文文档,面向高校计算机专业学生与Android开发初学者,采用Java语言开发设计,解决了传统Web应用只能在PC机上使用、无法随时随地阅读的问题。文档从选题背景、研究现状出发,完整覆盖开发环境介绍、Android系统架构与内核分析、需求分析、系统设计、系统测试与维护等环节,客户端注册登录、书籍添加、空间查看、书籍搜索等核心功能均有详细阐述,并突出“操作简单、功能实用”的设计理念。读者可借此了解Android移动应用从需求到实现的全过程,同时获得毕业设计论文结构、摘要撰写、目录编排等方面的规范参考。资源为1个docx文件,压缩包大小853KB,内容结构清晰便于直接查阅与修改。目前已有59人学习下载,适合作为毕业设计选题、课程设计报告撰写及Android入门实战的参考资料,具有较强的参考和复用价值。
1. 为什么要自己做一个读书笔记App,而不是用现成的笔记软件
很多做本地阅读的开发者都会遇到同一个问题:用户在阅读器里花了两个小时划线、批注,真正想回看时,才发现笔记和原文是脱节的。市面上的笔记工具以“文档”为记录单位,你找不到一本书的第 137 页第二段划线文字,更别说按书籍、章节、页码回看自己的批注。标题里“设计”和“实现”并置,意味着这不是一个换皮的文本编辑器,而要把数据模型、编辑体验和检索路径都围绕“书 + 笔记”设计。这篇博文按我实际做类似项目的顺序展开:先定 Room 表结构,再把编辑页的状态恢复和防抖保存做扎实,然后补上全文检索和 SAF 导出,最后说模拟器和真机在发布前的验证细节。适合正在做本地阅读类 App 的开发者、把毕业设计当独立项目的学生,以及准备上架工具类应用的小团队。
2. 数据层设计:Room 表结构决定书与笔记怎么关联
2.1 为什么选 Room 而不是手写 SQLiteOpenHelper
书架、图书、笔记、阅读进度这几类数据都是强关系模型,用文件存储虽然快,但做“某本书下的所有笔记”“某页的第几条划线”这类查询会写出大量遍历代码。Room 在 SQLite 之上提供了编译期校验和 LiveData/Flow 响应式支持,DAO 接口写错了在编译时就能暴露,这对单人维护项目是很大的兜底。
GreenDAO 或 ActiveAndroid 也不是不能选,但它们近年的维护节奏已经放缓,而 Room 作为 Android 官方架构组件,迁移路径最明确:先用Migration加列,再逐步替换 SQLiteOpenHelper 手写逻辑。需要说明的是,Room 对 FTS 虚拟表的原生支持并不完整,第 4 章里全文检索部分我会用原生 SQL 方式建虚拟表,直接用SupportSQLiteOpenHelper来创建,避免 DAO 注解与虚拟表约束冲突。
2.2 三张核心表:books、notes、reading_state
笔记不是独立的字符串,它的最小关联单位是book_id + page_index + start_offset + end_offset,这四个字段决定了“在哪本书的哪一页、从哪个字符到哪个字符”。start_offset 和 end_offset 是相对该页纯文本的偏移量,不能用绝对行号,因为换行符和排版在不同字体下会变化。
CREATE TABLE books ( book_id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, title TEXT, author TEXT, pages INTEGER DEFAULT 0, cover_path TEXT, add_time INTEGER NOT NULL ); CREATE TABLE notes ( note_id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, page_index INTEGER NOT NULL, start_offset INTEGER DEFAULT 0, end_offset INTEGER DEFAULT 0, note_text TEXT NOT NULL, tag TEXT, create_time INTEGER NOT NULL, update_time INTEGER NOT NULL ); CREATE TABLE reading_state ( book_id INTEGER PRIMARY KEY, page_index INTEGER DEFAULT 1, chapter_title TEXT, scroll_y INTEGER DEFAULT 0, update_time INTEGER NOT NULL );三段字段类型中,note_text保存用户真正输入的批注文字,而划线选中的原文放在另一张 highlight 表或作为start_offset/end_offset配合原文动态截取。我一般会单独存一份selected_text,因为原文件可能被重新排版或替换,完全依赖偏移量重建原文并不可靠。
reading_state和 books 是 1:1 关系,直接以book_id作为主键,避免每次翻页都写大字段。last_read_page可以放 books 表,但scroll_y这类视图状态不属于书籍实体,放独立表能在未来加阅读统计时不动主表结构。
2.3 用 DAO 写入一条笔记的最小路径
@Dao interface NoteDao { @Insert suspend fun insertNote(note: NoteEntity): Long @Query(""" SELECT * FROM notes WHERE book_id = :bookId AND page_index = :page ORDER BY start_offset ASC """) suspend fun getNotesByPage(bookId: Long, page: Int): List<NoteEntity> @Query(""" DELETE FROM notes WHERE note_id = :noteId """) suspend fun deleteNote(noteId: Long) }@Insert返回的 Long 是路由 rowid,后续如果要建立 note 和 tag 的多对多关系,可以用这个值做关联表的 joinId。getNotesByPage用start_offset ASC排序,保证同一页多条笔记的显示顺序与原文位置一致,而不是按创建时间。
除非笔记数超过几万条,否则不必给 notes 表加content_length这种预计算列。偏移量查询依赖索引时,直接在(book_id, page_index)上建联合索引即可:
CREATE INDEX idx_notes_book_page ON notes(book_id, page_index);这个索引会把同页笔记聚簇在相邻位置,翻页加载时只扫几十行,性能远好于全表过滤。
3. 编辑器核心:让笔记输入框不丢字、不卡顿
3.1 用 ViewModel 保存选中区间与草稿状态
读书笔记的输入场景和普通聊天框不同,用户经常划选一段原文、思考几十秒、再输入批注。这个过程中一旦 Activity 因屏幕旋转或内存不足被重建,EditText 里的文字和光标位置都会丢失。setText 后系统虽会尝试恢复光标,但状态一旦落到数据库读取之后的重建流程里,恢复时机稍微错位就会跳到开头。
常见做法是让 ViewModel 持有两个字段:startIndex和endIndex,在 TextWatcher 之外用setSelection回调更新。注意不要在 TextWatcher 的afterTextChanged里读selectionStart,因为此时文本已经变化,索引可能越界;应该用 EditText 的onSelectionChanged回调去记录:
class EditorViewModel : ViewModel() { val selectionStart = MutableLiveData<Int>() val selectionEnd = MutableLiveData<Int>() val draftText = MutableLiveData<String>() } editText.setOnSelectionChangedListener { selStart, selEnd -> viewModel.selectionStart.value = selStart viewModel.selectionEnd.value = selEnd } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt("sel_start", viewModel.selectionStart.value ?: 0) outState.putInt("sel_end", viewModel.selectionEnd.value ?: 0) }ViewModel 持有的 draftText 不能直接作为 EditText 的唯一数据源,因为它是防抖保存的输入副本,不是实时绑定源。正确做法是让 EditText 保持自由输入,ViewModel 只负责持久化和恢复,避免每次按键都回到主线程做 IO。
3.2 防抖自动保存:合并 300ms 内的连续输入
直接在afterTextChanged里写库,输入法连续上屏时会击穿 SQLite 的事务吞吐。Lite 模式下每秒可接受几十次写入,但每次写操作都会触发 Binder 调用和磁盘同步,键盘明显掉帧的系统不在少数。用协程做防抖是更干净的选择:
private val saveJob = Job() private val scope = CoroutineScope(Dispatchers.IO + saveJob) fun scheduleSave(content: String, noteId: Long) { scope.launch { delay(300) withContext(Dispatchers.IO) { noteDao.updateText(noteId, content) } } }delay(300)的语义是:只要 300ms 内没有新的输入事件,才真正执行写入。连续输入时每次scheduleSave都会把上一个协程取消掉。需要特别处理的是退出编辑页的时机——防抖任务可能还没执行完,Activity 就销毁了,解决办法是在onStop里直接调用一次同步或挂起保存。
防抖时长的选择要看用户输入习惯:快速打字时两次按键间隔一般不超过 200ms,300ms 可以合并绝大部分冗余写入;如果加入了标记语法或图片引用,需要把时长提高到 800ms,因为粘贴大段文本时系统会逐段回调 TextWatcher。
3.3 标记底色与字号调整的轻量做法
读书笔记里最常见的标记是黄色底色高亮原文、红色标注重点,这不是富文本编辑器里的整段样式,而是基于偏移量的区间样式。用SpannableString动态绘制即可,不需要引入 span 序列化库:
fun applyHighlight(text: String, start: Int, end: Int, colorHex: Int): Spanned { return SpannableString(text).apply { setSpan( BackgroundColorSpan(colorHex), start, end.coerceAtMost(text.length), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } }SPAN_EXCLUSIVE_EXCLUSIVE表示在区间边界输入的字符不会被继承样式,这符合笔记场景:高亮只作用于划选的原文,后续输入的文字自动使用默认样式。字号调整则不用 Span,直接给整个 EditText 设置textSize,保存时只记一个 float 字段。
表格对比纠结的点:
| 保存时机 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 每次 onTextChanged | 数据最安全 | IO 频繁,键盘卡顿 | 短文本、无性能要求 |
| 防抖 300ms 合并 | 兼顾安全和流畅 | 进程被杀可能丢最近 300ms 内容 | 通用笔记输入 |
| onStop 强制保存 | 兜底防丢 | 无法覆盖崩溃场景 | 所有 EditText 页面 |
真正要避免的是在onTextChanged里做字符串拼接、长度统计、Spannable 重建。这些操作会成倍放大输入法上屏的耗时,把一次 5ms 的插入拉到 30ms 以上。
4. 搜索与备份:把笔记捞回来,再把它带出去
4.1 用 FTS4 虚拟表给 note_text 建全文索引
读书笔记通常只有几百到几千条,但 LIKE 查询在几万条记录上会全表扫描,而且不带索引。SQLite 自带的 FTS4 虚拟表能把查询路径从“遍历全部笔记”变成“扫描倒排索引”,配合MATCH语法做词级检索。建表时遵守“虚拟表 rowid 对应 notes.note_id”的约定:
CREATE VIRTUAL TABLE notes_fts USING fts4(note_text, tokenize=unicode61); INSERT INTO notes_fts(rowid, note_text) SELECT note_id, note_text FROM notes;unicode61分词对英文按空格和标点分割,对中文基本无效。维护虚拟表时要在notes表插入后同步执行INSERT INTO notes_fts,删除时用DELETE FROM notes_fts WHERE rowid = :noteId,否则会出现索引和主表数据不一致的脏结果。
4.2 中文检索的 2-gram 方案与命中高亮
中文分词在 Android 端没有现成的标准方案,引入 jieba 分词库重且慢。对读书笔记这个体量,2-gram 是性价比最高的做法:把用户输入的关键词拆成相邻双字组合,如“读书笔记”拆成“读书”“书笔”“笔记”,在写入 FTS 时同样按 2-gram 切分 note_text。这样查询时用AND组合多个 token:
SELECT n.note_id, n.note_text, n.book_id, n.page_index FROM notes n JOIN notes_fts f ON f.rowid = n.note_id WHERE notes_fts MATCH '"读书" OR "书笔" OR "笔记"' ORDER BY f.rank LIMIT 50;rowid关联是 FTS 查询的核心,不能写成JOIN notes ON notes.note_id = f.note_id,因为虚拟表里没有 note_id 这一列。ORDER BY f.rank让最相关的匹配优先返回,排名由词频和文档频率共同决定。2-gram 索引体积约为原文的 1.5 到 2 倍,对 Apk 体积和磁盘占用影响很小,但建索引时要注意在INSERT INTO notes_fts前用BEGIN TRANSACTION包裹,加快回填速度。
命中高亮用offsets()函数拿到的字节偏移量是按原始字符串字节位置计算的,不能直接用于 UI 的字符索引。正确做法是先用snippet(notes_fts)生成带高亮标记的片段,再对<b>标签做一次转换:
val snippetText = cursor.getString(snippetIndex) val highlightPattern = Regex("<b>(.*?)</b>")避免自己遍历匹配关键词位置,中文双字切分在边界字符上很容易错位,依赖 SQLite 提供的 snippet 实现既省事又不容易出StringIndexOutOfBoundsException。
4.3 用 SAF 把笔记导出成 Markdown
让用户能带走的笔记才有真正价值。导出到 App 专属外部目录会导致用户升级后目录清空或换个文件管理器就找不到,正确路径是通过 Storage Access Framework 创建用户可见文件:
val createFileLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> if (uri != null) { contentResolver.openOutputStream(uri)?.use { os -> val markdown = buildMarkdown(notes) os.write(markdown.toByteArray(Charsets.UTF_8)) } } }CreateDocument会弹出系统文件选择器,用户自主选择下载目录或云盘目录,权限由系统授权,不需要申请WRITE_EXTERNAL_STORAGE,也不需要在清单里声明存储权限,这是 Android 11 及以上最稳妥的导出方式。buildMarkdown(notes)里按照“书名为一级标题、章节为二级标题、笔记内容按时间排序”拼字符串,每条笔记引用原书页码:
# 《置身事内》 ## 第一章 地方政府的权力与事务 > 第 12 页 | 原句:土地本身并不值钱 笔记:这里可以和分税制改革联系起来看。MARKDOWN_BLOCK_PLACEHOLDER
contentResolver.openOutputStream默认覆盖已有文件,如果用户选择同名文件,系统会提示确认替换,不用自己处理文件冲突。导出完成后建议发一个 Toast 并带上文件路径,用户最关心的是“存到哪里了”,而不是抽象的通知文案。
5. 从能跑到能发布:android测试、签名与验证脚本
5.1 模拟器上的冒烟验证:adb 检查 note 是否真正落盘
很多人写完笔记功能只测试界面能否输入,却不知道数据有没有落库。用 Debug 包跑模拟器时,可以直接通过 adb 检查 SQLite 文件:
adb shell run-as com.example.readnote cat databases/note.db > local_note.db sqlite3 local_note.db "select count(*) from notes;" sqlite3 local_note.db "select note_text, page_index from notes order by note_id desc limit 5;"run-as依赖应用是 debug 版本,release 包无法使用,但这对于自动化冒烟测试已经足够。配合adb shell input tap和input keyevent可以在不打开 App 的情况下模拟一段输入,再重启 App 验证防抖保存是否真正生效——重启后翻到上一次的页码,确认文本和光标都还在,这是笔记类功能最少要跑通的稳定性测试。
如果发现 read 出来是空表,先检查 Room 数据库是否被fallbackToDestructiveMigration或版本号变更清空了,这是开发期“假装数据丢了”的头号原因。
5.2 上架前检查:exported 声明、签名 SHA1 与最少权限
Android 12 起 targetSdkVersion 提高到 31 之后,未声明android:exported的 Activity 无法安装。笔记 App 通常只有启动入口,设置为:
<activity android:name=".MainActivity" android:exported="true">只有被系统或第三方应用通过 Intent 调用的组件才需要 exported=true,EditorActivity、SearchActivity 这类仅供内
本文还有配套的精品资源,点击获取