☰
移动开发期末大作业:备忘录App高分选题与全栈实现指南
2026/10/8 5:02:17 网站建设 项目流程

简介:这是一份面向计算机相关专业学生的移动开发期末大作业完整项目——备忘录App,适合正在准备课程大作业、毕业设计或需要Android实战练习的学习者。项目经导师指导并评审通过,得分98分,源码均经本地编译与严格调试,可稳定运行,难度适中,助教老师已审定内容,能满足学习与使用需求。资源包共67个文件,约4.07MB,以22个xml布局、10个java源码、10个webp与5个jpg图片资源为主,另含gradle构建脚本、properties配置、apk安装包及导入说明文档,结构完整。已有150人学习下载。读者可获得一套可直接导入运行的Android备忘录项目源码、配套导入文档与项目截图,便于快速理解工程目录组织、界面布局与功能实现逻辑,也能借助排错思路解决导入过程中的常见问题,适合作为课程设计参考与实战练习素材。

1. 移动开发期末大作业选备忘录 app:为什么它是性价比最高的高分选题

带过几届移动开发课程设计,每年到期末都有学生来问同一个问题:大作业做什么选题既不容易翻车,又能拿到高分。我的答案一直没变——备忘录 app。原因很直接:它的业务逻辑足够简单,你不需要花两周去理解需求;但它的技术覆盖面又足够广,增删改查、本地存储、列表渲染、页面跳转、时间处理这些移动开发的核心考点一个不落。更关键的是,它天然支持做差异化——别人只做了文字记录,你加上文档导入、图片附件、搜索过滤,分数立刻拉开档次。

这篇笔记面向的是正在做移动开发期末大作业的读者,不管你用的是 Android 原生、Flutter 还是 uni-app,备忘录 app 的架构思路是相通的。我会从选题拆解讲到数据层设计、界面实现、文档导入这个加分项怎么做,再到导出项目截图和文档的收尾工作,把每一步的参数和踩坑点都摊开说。如果你正在纠结选题,或者已经选了备忘录但不知道怎么做得出彩,接下来的内容可以直接抄作业。

2. 备忘录 app 的数据层怎么设计:从字段定义到本地存储选型

2.1 一条备忘记录到底需要哪些字段

很多同学上来就建表写id、title、content三个字段,写到后面发现要加分类、要加提醒时间、要加附件路径,只能反复改表结构。我一般会在动手前把字段一次性想清楚,下面这张表是我做了几版备忘录之后沉淀下来的最小可用字段集:

字段名类型说明是否必填
idINTEGER / String主键,自增或 UUID是
titleTEXT标题,列表页展示用是
contentTEXT正文内容否
categoryTEXT分类标签,如"工作""生活"否
created_atINTEGER创建时间戳(毫秒)是
updated_atINTEGER最后修改时间戳是
is_pinnedINTEGER是否置顶,0/1是
attachment_pathTEXT附件文件路径否

时间字段用整数时间戳而不是字符串,排序和比较都方便,展示时再格式化。is_pinned用 0/1 而不是布尔类型,是因为多数移动端数据库对布尔支持不一致,用整数最稳。attachment_path存相对路径而非绝对路径,避免应用重装后路径失效——这个坑后面避坑章节还会展开说。

2.2 本地存储选型:SQLite、Room 还是轻量 KV

选存储方案是备忘录 app 第一个分水岭。我见过太多同学用 SharedPreferences 存整个列表的 JSON 字符串,数据量一上来就卡,搜索也没法做。常见做法是分三档:

  • 轻量 KV(SharedPreferences / UserDefaults):只适合存配置项,比如"上次同步时间""主题色"。拿来存备忘列表,超过 50 条就会明显卡顿。
  • SQLite 原生 API:灵活,但手写 SQL 和 Cursor 处理很啰嗦,容易在游标关闭上翻车。
  • Room / ORM 框架:在 SQLite 上包一层,编译期校验 SQL,支持 LiveData/Flow 自动刷新列表。做备忘录这种标准 CRUD,Room 是首选。

如果你用 Flutter,对应的是 sqflite 或 Drift;uni-app 则推荐用 uniCloud 数据库或本地 SQLite 插件。核心原则一样:结构化数据用关系型存储,别用 KV 硬扛。

2.3 用 Room 建表的最小可跑代码

下面这段是我常用的 Room 实体和 DAO 定义,直接改包名就能用:

// MemoEntity.kt —— 实体类,对应数据库中的 memo 表 @Entity(tableName = "memo") data class MemoEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "content") val content: String = "", @ColumnInfo(name = "category") val category: String = "默认", @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "is_pinned") val isPinned: Int = 0, @ColumnInfo(name = "attachment_path") val attachmentPath: String? = null )
// MemoDao.kt —— 数据访问接口,Room 会在编译期生成实现 @Dao interface MemoDao { // 按置顶优先、更新时间倒序查询,列表页直接用这个 @Query("SELECT * FROM memo ORDER BY is_pinned DESC, updated_at DESC") fun getAllMemos(): Flow<List<MemoEntity>> // 关键词搜索,标题和正文都匹配 @Query("SELECT * FROM memo WHERE title LIKE '%' || :keyword || '%' OR content LIKE '%' || :keyword || '%'") fun searchMemos(keyword: String): Flow<List<MemoEntity>> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(memo: MemoEntity): Long @Update suspend fun update(memo: MemoEntity) @Delete suspend fun delete(memo: MemoEntity) }

逻辑说明:Flow<List<MemoEntity>>返回值让数据库一变,UI 自动收到新数据,不用手动调刷新。ORDER BY is_pinned DESC, updated_at DESC保证置顶的永远在最上面,同组内按最近修改排序。搜索用LIKE拼接,注意参数化写法:keyword而不是字符串拼接,避免 SQL 注入。

参数说明:autoGenerate = true让主键自增,插入时传 0 即可。onConflict = REPLACE表示主键冲突时覆盖,适合"编辑后保存"的场景。suspend关键字让这些方法必须在协程里调用,Room 不允许在主线程操作数据库——这是硬性约束,不是建议。

2.4 数据库版本迁移别等到崩溃才处理

Room 默认在版本号变化且没提供迁移时会直接抛异常清库。做期末作业时你可能中途加了个字段,改完version = 2一跑就闪退。正确做法是写 Migration:

val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { // 新增 attachment_path 字段,默认值为 NULL database.execSQL("ALTER TABLE memo ADD COLUMN attachment_path TEXT DEFAULT NULL") } }

然后在构建数据库时注册:.addMigrations(MIGRATION_1_2)。如果只是开发阶段、数据不重要,也可以直接用fallbackToDestructiveMigration()让它清库重建,但答辩演示前千万别留着这行,否则演示中途改个版本数据全没了,场面会很尴尬。

3. 备忘录 app 界面怎么搭:列表页、编辑页和搜索的落地实现

3.1 列表页的 RecyclerView / ListView 适配器怎么写

列表页是用户打开 app 看到的第一屏,也是答辩时老师最先看的地方。核心是三件事:数据绑定、点击跳转、长按删除。用 RecyclerView 的适配器大致长这样:

// MemoAdapter.kt —— 列表适配器 class MemoAdapter( private val onItemClick: (MemoEntity) -> Unit, private val onItemLongClick: (MemoEntity) -> Unit ) : ListAdapter<MemoEntity, MemoAdapter.MemoViewHolder>(DiffCallback) { // DiffUtil 回调,只刷新变化的条目,避免整列表重绘 object DiffCallback : DiffUtil.ItemCallback<MemoEntity>() { override fun areItemsTheSame(oldItem: MemoEntity, newItem: MemoEntity) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: MemoEntity, newItem: MemoEntity) = oldItem == newItem } inner class MemoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val tvTitle: TextView = itemView.findViewById(R.id.tv_title) val tvContent: TextView = itemView.findViewById(R.id.tv_content) val tvTime: TextView = itemView.findViewById(R.id.tv_time) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemoViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_memo, parent, false) return MemoViewHolder(view) } override fun onBindViewHolder(holder: MemoViewHolder, position: Int) { val memo = getItem(position) holder.tvTitle.text = memo.title holder.tvContent.text = memo.content // 时间戳格式化为可读字符串 holder.tvTime.text = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) .format(Date(memo.updatedAt)) holder.itemView.setOnClickListener { onItemClick(memo) } holder.itemView.setOnLongClickListener { onItemLongClick(memo); true } } }

逻辑说明:用ListAdapter而不是裸RecyclerView.Adapter,配合DiffUtil自动计算差异,只刷新变化的条目。数据量大时这个优化很明显,答辩演示几十条数据滑动不会卡。

参数说明:areItemsTheSame比较 id 判断是不是同一条,areContentsTheSame比较内容判断要不要重绘。onLongClick返回true表示事件已消费,不再触发单击。

3.2 编辑页的数据回填与保存时机

编辑页要同时支持"新建"和"编辑"两种模式。常见做法是用一个memoId参数区分:传 -1 表示新建,传具体 id 表示编辑。回填时从数据库查出来填进输入框,保存时判断 id 决定 insert 还是 update。

保存时机有个容易忽略的点:用户按返回键时要不要自动保存?我的建议是不要。自动保存会让"取消编辑"变得不可能,用户改错了想退出都退不掉。正确做法是显式提供保存按钮,返回时如果有未保存修改就弹个确认框。这个交互细节答辩时老师很可能会问,提前想好怎么答。

3.3 搜索过滤用数据库查还是内存过滤

搜索功能有两种实现路径:一是在 DAO 里写LIKE查询,二是把全部数据加载到内存用filter过滤。数据量小于 200 条时两者体感差不多,但数据量上去后内存过滤会明显变慢,而且没法做分页。我一般直接用数据库查询,就是 2.3 节里那个searchMemos方法。

搜索框的防抖也要处理,用户每敲一个字就查一次数据库太浪费。用debounce(300)操作符延迟 300 毫秒,停止输入后才触发查询:

// 搜索框文本变化监听,300ms 防抖后触发查询 searchEditText.textChanges() .debounce(300, TimeUnit.MILLISECONDS) .flatMapLatest { keyword -> if (keyword.isBlank()) memoDao.getAllMemos() else memoDao.searchMemos(keyword.toString()) } .onEach { list -> adapter.submitList(list) } .launchIn(lifecycleScope)

debounce(300)表示 300 毫秒内没有新输入才继续往下走,flatMapLatest保证只处理最新一次查询结果,避免旧查询返回覆盖新结果。

4. 文档导入这个加分项怎么做:从文件选择到内容解析

4.1 用系统文件选择器拿到文档 URI

标题里提到的"导入文档"是拉开分数差距的关键功能。实现路径是调系统文件选择器,拿到用户选中的文件 URI,再读取内容。Android 上用ActivityResultContracts.OpenDocument:

// 注册文件选择器,限定 txt 和 pdf 类型 val pickDocument = registerForActivityResult( ActivityResultContracts.OpenDocument() ) { uri: Uri? -> uri?.let { // 持久化读取权限,避免重启后失效 contentResolver.takePersistableUriPermission( it, Intent.FLAG_GRANT_READ_URI_PERMISSION ) parseDocument(it) } } // 在按钮点击时启动,传入 MIME 类型数组 pickDocument.launch(arrayOf("text/plain", "application/pdf"))

逻辑说明:OpenDocument返回的是 content URI,不是文件路径。Android 10 以后分区存储强制生效,你拿不到真实路径,只能通过ContentResolver读流。takePersistableUriPermission这行很关键,不加的话 app 重启后 URI 权限就没了,再读会抛 SecurityException。

参数说明:arrayOf("text/plain", "application/pdf")限定可选文件类型,想支持 Word 就加application/msword。注意不同厂商文件管理器对 MIME 的识别有差异,测试时多换几个机型。

4.2 解析 txt 和 pdf 的最小实现

txt 解析最简单,直接读流:

// 读取 txt 文件内容 fun readTextFromUri(uri: Uri): String { return contentResolver.openInputStream(uri)?.use { inputStream -> BufferedReader(InputStreamReader(inputStream)).readText() } ?: "" }

pdf 解析需要引入第三方库,Android 上常用PdfRenderer(系统自带,但只能渲染成图,不能直接抽文字)或iText、PDFBox-Android。如果只是期末作业,我建议优先支持 txt 和 markdown,pdf 用PdfRenderer渲染成图片存为附件即可,不强行抽文字——抽文字涉及字体编码,坑很深,投入产出比不划算。

导入后的内容怎么处理?两种策略:一是把文档内容直接填进备忘录正文,二是把文件路径存进attachment_path字段,正文只放摘要。我一般用第二种,因为大文档全塞进正文会让列表页渲染变慢,而且数据库单字段有大小限制。

4.3 导入功能的边界处理

导入文档有几个必须处理的边界:文件为空、文件过大(超过 5MB 建议提示)、编码不是 UTF-8(中文可能乱码)、用户选了不支持的类型。编码问题可以用InputStreamReader指定字符集,或者用juniversalchardet自动探测。文件大小在读取前用contentResolver.openFileDescriptor(uri, "r")?.statSize拿到,超过阈值直接提示用户。

5. 备忘录 app 开发避坑:5 个血泪教训

5.1 数据库操作放主线程导致 ANR

现象:列表数据超过 100 条后,打开 app 卡顿甚至弹出"应用无响应"。原因:在onCreate里同步查询数据库,主线程被阻塞。解决:所有数据库操作放Dispatchers.IO协程里,UI 层用Flow或LiveData订阅结果。Room 的suspend方法本身就会切到 IO 线程,但如果你用的是阻塞式查询 API,必须手动包withContext(Dispatchers.IO)。

5.2 附件用绝对路径,换设备就失效

现象:在自己手机上测试正常,换台手机或重装 app 后附件打不开。原因:attachment_path存了/storage/emulated/0/...这种绝对路径,不同设备路径不一样。解决:存相对路径或 content URI,读取时再拼完整路径。用takePersistableUriPermission持久化 URI 权限,比存路径更可靠。

5.3 时间戳单位混用,排序全乱

现象:列表排序忽前忽后,明明刚编辑的排在后面。原因:有的地方用秒级时间戳,有的用毫秒级,比较时数值差 1000 倍。解决:全项目统一用毫秒级System.currentTimeMillis(),格式化时注意SimpleDateFormat接收的是毫秒。如果从服务端拿数据,先确认对方返回的是秒还是毫秒,必要时乘 1000。

5.4 搜索框每输入一个字就查库

现象:快速打字时界面卡顿,日志里数据库查询刷屏。原因:TextWatcher每次onTextChanged都触发查询。解决:加 300ms 防抖,用debounce操作符或手动维护一个Handler延迟任务。同时用flatMapLatest取消上一次未完成的查询。

5.5 忘记处理空列表状态

现象:一条备忘都没有时,界面一片空白,老师以为 app 崩了。原因:只写了有数据时的列表渲染,没写空状态。解决:在列表页加一个EmptyView,数据为空时显示"还没有备忘,点击右下角添加"。这个细节花不了十分钟,但答辩观感差别很大。

6. 项目截图和文档怎么整理才能拿高分

6.1 截图不是随便截,要覆盖关键路径

答辩材料里的截图有讲究,不是截得越多越好,而是要覆盖功能闭环。我一般会准备这六张:列表页(有数据状态)、新建编辑页、搜索过滤结果、文档导入过程、空状态页、数据库表结构或 ER 图。每张截图配一句说明,讲清楚这张图展示了什么功能点。截图分辨率保持一致,别有的横屏有的竖屏,看起来不专业。

6.2 项目文档的结构和写法

文档部分建议按"需求分析 → 技术选型 → 数据库设计 → 核心功能实现 → 测试与总结"来组织。技术选型那节要写清楚为什么选 Room 而不是 SharedPreferences,为什么用 Flow 而不是 LiveData,这种"选择理由"是老师判断你是否真理解技术的依据。核心功能实现部分贴关键代码片段,不要整文件粘贴,每段代码下面用两三句话说明逻辑。

6.3 答辩演示的防翻车清单

演示前一定要做这几件事:把fallbackToDestructiveMigration()去掉、准备至少 10 条测试数据(空列表演示很尴尬)、把手机调成勿扰模式、提前把要演示的文档放进文件管理器方便选择、确认 app 图标和名称已改(别顶着默认的 "My Application" 上去)。我吃过一次亏,演示时搜索功能因为没做防抖,快速输入直接卡住,老师等了五秒,那五秒比五年还长。后来我养成了一个习惯:任何涉及用户输入的交互,上线前都手动快速敲一遍,看会不会卡。希望这些经验能帮你少走点弯路,帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询