☰
Android备忘录源码实战:SQLite本地存储与列表联动避坑指南
2026/10/15 11:15:10 网站建设 项目流程

简介:这份Android备忘录源码压缩包面向具备一定Android基础的开发者与移动应用学习者,提供一套可直接研读的完整项目实例,帮助理解备忘录类应用的架构设计与核心功能实现。包内共190个文件,以74个png界面素材、61个class编译产物、22个xml布局与配置、20个java源码为主,另含prefs、ogg音频、apk与dex等运行相关文件,整体约1.29MB,体积轻便便于本地导入Android Studio对照分析。源码覆盖Activity入口、SQLite数据库持久化、ContentProvider数据共享、ViewModel状态保持、RecyclerView适配器列表展示以及Notification定时提醒等典型模块,并涉及权限声明与UI资源定制,目录结构清晰,适合按模块拆解学习。目前已有1802人学习下载,可作为课程设计、毕业项目或Android组件综合练习的参考蓝本,帮助读者快速掌握从界面到数据层的完整开发链路。

1. 从一份备忘录源码说起:Android 本地存储到底该怎么练

很多做 Android 的朋友都有个尴尬期:四大组件背得滚瓜烂熟,RecyclerView 也能写,但真给一个「从零到能跑」的完整小项目,反而不知道从哪下手。这份 Android 备忘录源码压缩包,恰好卡在这个位置上——它不炫技,没有花哨的架构,就是一个能记事、能改、能删、能持久化的单机 App。但正因为简单,它把 Android 入门阶段最容易翻车的几件事全暴露出来了:SQLite 建表与升级、Activity 与 Adapter 的数据同步、列表点击与长按的事件分发、以及退出应用后数据还在不在。

我一般把这类源码当「骨架」用,而不是当「成品」抄。它的价值不在于界面多好看,而在于你能顺着一条完整的读写链路,把「用户输入 → 数据落库 → 列表刷新 → 再次启动还原」这条线走通。适合谁?适合刚学完基础、想找一个能改能跑的小项目练手的同学,也适合想快速搭一个本地记录类 Demo 的开发者。下面我按「先看懂结构、再动手改、最后避坑」的顺序拆一遍,中间会给出可直接抄的代码和参数说明。

2. 拆开压缩包先看什么:目录结构与数据流走向

拿到一份陌生源码,最忌讳上来就改代码。先花十分钟把目录和数据流摸清楚,后面能省掉大量「改了没反应」的玄学时间。这份备忘录源码的结构是典型的单 Module 工程,没有引入 Room、没有 Hilt,属于「原生 SQLite + 原生控件」的路子。

2.1 工程目录与关键文件定位

解压后你会看到类似这样的结构(不同版本可能略有差异,但核心文件跑不掉):

app/src/main/ ├── java/com/example/noteapp/ │ ├── MainActivity.java // 列表页,展示所有备忘录 │ ├── EditActivity.java // 编辑页,新增/修改单条 │ ├── NoteDbHelper.java // SQLite 建库建表与版本管理 │ ├── NoteDao.java // 增删改查封装 │ └── NoteAdapter.java // RecyclerView 或 ListView 适配器 ├── res/layout/ │ ├── activity_main.xml │ ├── activity_edit.xml │ └── item_note.xml └── AndroidManifest.xml

判断一份 Android 源码值不值得细看,我有个土办法:看它有没有把「数据库操作」和「界面逻辑」分开。如果 MainActivity 里直接db.rawQuery满天飞,那基本是练手代码,改起来会很痛苦;如果有一个独立的 Dao 或 Helper 类,说明作者至少想过分层,这种源码改起来顺手。这份备忘录源码属于后者,NoteDbHelper管建表,NoteDao管读写,Activity 只管调。

2.2 一条备忘录的完整数据流

把数据流画清楚,比看十遍代码都管用。这份源码的链路是这样的:

  1. 用户在EditActivity输入标题和内容,点保存;
  2. EditActivity拿到EditText的文本,组装成一个Note对象;
  3. 调用NoteDao.insert(note)或update(note);
  4. NoteDao内部通过SQLiteDatabase执行 SQL;
  5. 返回MainActivity后触发列表重新查询,NoteAdapter.notifyDataSetChanged()刷新界面。

这里有个新手常忽略的点:列表刷新不是自动的。很多人改完数据回到列表页发现没变化,以为是数据库没写进去,其实是 Adapter 没收到通知。常见做法是在onResume()里重新查一次数据,而不是依赖onActivityResult的回调——后者在复杂跳转下容易漏。

@Override protected void onResume() { super.onResume(); // 每次回到列表页都重新拉取,避免数据不同步 List<Note> notes = noteDao.queryAll(); adapter.setData(notes); adapter.notifyDataSetChanged(); }

这段代码的逻辑很简单:onResume在页面每次可见时都会走,包括从编辑页返回。参数上没什么可调的,但要注意queryAll()返回的 List 如果是同一个引用,setData里最好做一次拷贝,否则可能出现「数据变了但界面没变」的假象。我一般会在 Adapter 里重新new ArrayList<>(notes),多一次拷贝换一份安心。

3. SQLite 建表与版本升级:备忘录能不能长期用就看这里

备忘录这类 App 最核心的技术点不是界面,是数据能不能稳定存下来、以后加字段会不会把老用户数据搞丢。这份源码用的是原生SQLiteOpenHelper,没有 ORM 帮你兜底,所以建表和升级逻辑必须自己写清楚。

3.1 建表语句与字段设计

先看NoteDbHelper里的建表逻辑,这是整个 App 的地基:

public class NoteDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; private static final int DB_VERSION = 1; public static final String TABLE_NOTE = "note"; public static final String COL_ID = "_id"; public static final String COL_TITLE = "title"; public static final String COL_CONTENT = "content"; public static final String COL_TIME = "update_time"; public NoteDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 主键用 _id,方便配合 CursorAdapter 使用 String sql = "CREATE TABLE " + TABLE_NOTE + " (" + COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + COL_TITLE + " TEXT NOT NULL, " + COL_CONTENT + " TEXT, " + COL_TIME + " INTEGER)"; db.execSQL(sql); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单粗暴:版本变了就重建表,仅适合练手 db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTE); onCreate(db); } }

逻辑说明:onCreate只在数据库第一次创建时调用,建一张note表。字段设计上,_id用INTEGER PRIMARY KEY AUTOINCREMENT是 Android 的惯例,配合CursorAdapter时不用额外映射。update_time存的是long类型的时间戳,比存字符串好排序。

参数说明:DB_VERSION是版本号,改结构时必须加一,否则onUpgrade不会触发。onUpgrade里我写的是「删表重建」,这是练手项目的常见做法,但线上项目千万别这么干——用户数据会全丢。正确做法是用ALTER TABLE ADD COLUMN逐版本迁移,或者用 Room 的 Migration。这里保留删表逻辑,是为了让你先跑通,后面第 5 章我会讲怎么改成安全迁移。

3.2 增删改查的封装与 Cursor 关闭

NoteDao是真正干活的地方,重点看它有没有正确关闭 Cursor:

public List<Note> queryAll() { List<Note> list = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = null; try { cursor = db.query(TABLE_NOTE, null, null, null, null, null, COL_TIME + " DESC"); while (cursor.moveToNext()) { Note note = new Note(); note.setId(cursor.getInt(cursor.getColumnIndexOrThrow(COL_ID))); note.setTitle(cursor.getString(cursor.getColumnIndexOrThrow(COL_TITLE))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow(COL_CONTENT))); note.setUpdateTime(cursor.getLong(cursor.getColumnIndexOrThrow(COL_TIME))); list.add(note); } } finally { if (cursor != null) { cursor.close(); // 必须关,否则内存泄漏 } } return list; }

逻辑说明:db.query的第三个参数是whereClause,这里传 null 表示查全部;最后一个参数是排序,按更新时间倒序,最新的备忘录排最上面。getColumnIndexOrThrow比getColumnIndex好,字段名写错会直接抛异常,而不是返回 -1 导致后面取值错位。

参数说明:cursor.close()放在finally里是血泪经验。早期我写代码经常在while循环后直接 return,Cursor 没关,跑久了就报「CursorWindowAllocationException」。另外getReadableDatabase()和getWritableDatabase()在大多数情况下返回的是同一个对象,读操作也可以用前者,但写操作必须用后者。

4. 列表与编辑页联动:Adapter 刷新和事件分发的几个坑

数据存好了,接下来就是界面怎么把它显示出来、怎么响应用户操作。这部分是新手翻车最密集的区域,尤其是点击和长按的区分、以及 Adapter 数据更新。

4.1 Adapter 的 ViewHolder 与点击回调

先看NoteAdapter的核心写法:

public class NoteAdapter extends RecyclerView.Adapter<NoteAdapter.VH> { private List<Note> data = new ArrayList<>(); private OnItemClickListener listener; public interface OnItemClickListener { void onClick(int position); void onLongClick(int position); } public void setData(List<Note> notes) { // 拷贝一份,避免外部修改影响内部 this.data = new ArrayList<>(notes); notifyDataSetChanged(); } public void setOnItemClickListener(OnItemClickListener l) { this.listener = l; } static class VH extends RecyclerView.ViewHolder { TextView title, time; VH(View itemView) { super(itemView); title = itemView.findViewById(R.id.tv_title); time = itemView.findViewById(R.id.tv_time); } } @Override public VH onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_note, parent, false); return new VH(v); } @Override public void onBindViewHolder(VH holder, int position) { Note note = data.get(position); holder.title.setText(note.getTitle()); holder.time.setText(formatTime(note.getUpdateTime())); holder.itemView.setOnClickListener(v -> { if (listener != null) listener.onClick(holder.getAdapterPosition()); }); holder.itemView.setOnLongClickListener(v -> { if (listener != null) listener.onLongClick(holder.getAdapterPosition()); return true; // 返回 true 表示消费了事件 }); } @Override public int getItemCount() { return data.size(); } }

逻辑说明:setData里做了一次new ArrayList<>(notes)拷贝,这是防止外部 List 被清空后 Adapter 还持有引用导致崩溃。点击回调用接口往外抛,Activity 实现接口后处理跳转和删除,Adapter 不碰业务逻辑。

参数说明:getAdapterPosition()比直接用position安全,因为列表可能正在做动画或数据变更,position可能已经过期。长按回调里return true很重要,返回 false 的话事件会继续传递,可能触发点击,出现「长按后弹了删除框又跳转了编辑页」的诡异现象。

4.2 编辑页的两种模式与 Intent 传参

EditActivity要同时支持「新增」和「修改」,靠 Intent 里带不带 id 来区分:

// MainActivity 中跳转 Intent intent = new Intent(this, EditActivity.class); intent.putExtra("note_id", note.getId()); // 修改时传,新增时不传 startActivity(intent); // EditActivity 中接收 int noteId = getIntent().getIntExtra("note_id", -1); if (noteId == -1) { // 新增模式,标题栏显示「新建」 } else { // 修改模式,先查出来回填 Note note = noteDao.queryById(noteId); etTitle.setText(note.getTitle()); etContent.setText(note.getContent()); }

逻辑说明:用-1作为「没有 id」的哨兵值,因为自增主键从 1 开始,不会冲突。保存时判断noteId == -1就 insert,否则 update。

参数说明:getIntExtra的第二个参数是默认值,必须给,否则取不到时会返回 0,而 0 可能被误认为合法 id。这里用 -1 是常见约定。另外 Intent 传参只适合小数据,备忘录内容如果很长,建议只传 id,内容从数据库查,避免TransactionTooLargeException。

5. 避坑与排查:这份源码跑不起来时先看这几条

源码能不能一次跑通,取决于你的环境和它的依赖是否匹配。下面这几条是我在帮人看这类项目时遇到频率最高的,按「现象 → 原因 → 解决」列出来。

5.1 编译报错找不到符号 R

现象:打开工程后满屏红色,R.layout.xxx、R.id.xxx全部标红,编译提示「cannot find symbol R」。

原因:通常是资源文件有错误导致 R.java 没生成,或者 Gradle 版本与项目不匹配。这份源码如果比较老,compileSdkVersion可能还是 28 甚至更低,而你本地装的是新版 Android Studio。

解决:先看res目录下有没有 XML 报错,尤其是activity_main.xml里的自定义属性。然后把build.gradle里的compileSdkVersion、targetSdkVersion提到你本地已有的版本,minSdkVersion保持不动。改完Sync Now一次,R 文件会重新生成。

5.2 运行后列表空白但数据库有数据

现象:App 能启动,数据库里确实有记录,但列表页一条都不显示。

原因:Adapter 的setData没被调用,或者onResume里查询返回的 List 是空的。还有一种可能是item_note.xml的根布局高度写成了0dp或wrap_content但父容器约束有问题。

解决:在onResume里加一行日志Log.d("NoteApp", "size=" + notes.size()),确认数据层有没有拿到。如果 size 正常但界面不显示,检查item_note.xml根布局高度,改成固定值或wrap_content试一次。RecyclerView 还要确认LayoutManager设置了,没设的话什么都不显示。

5.3 长按删除后列表错位

现象:长按删除某一条后,剩下的列表项内容跟实际数据对不上,点进去是另一条。

原因:删除后只调用了notifyDataSetChanged(),但 Adapter 内部的data列表没有同步移除,或者移除的索引不对。

解决:删除操作要同时更新数据库和内存列表。常见做法是data.remove(position)后再notifyItemRemoved(position),而不是只调notifyDataSetChanged()。如果用了notifyDataSetChanged(),必须保证data已经重新赋值。我一般统一走「重新查询 + setData」这条路,虽然性能差一点,但不会错位。

5.4 数据库升级后老数据丢失

现象:改了表结构、把DB_VERSION加一,重新安装后老备忘录全没了。

原因:onUpgrade里写了DROP TABLE,这是练手代码的默认写法,但会清空数据。

解决:把onUpgrade改成增量迁移。比如从版本 1 到 2 要加一个is_star字段:

@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE " + TABLE_NOTE + " ADD COLUMN is_star INTEGER DEFAULT 0"); } // 以后每加一个版本就加一个 if,不要删表 }

逻辑说明:ALTER TABLE ADD COLUMN是 SQLite 支持的轻量迁移方式,不会动已有数据。参数上DEFAULT 0保证老记录也有默认值。注意 SQLite 不支持删除列和修改列类型,复杂变更只能建新表导数据,那是另一个话题了。

5.5 时间显示成时间戳数字

现象:列表里每条备忘录的时间显示成1712345678000这种长数字。

原因:update_time存的是System.currentTimeMillis(),直接setText就显示成数字了。

解决:用SimpleDateFormat格式化:

private String formatTime(long timestamp) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); return sdf.format(new Date(timestamp)); }

参数说明:Locale.getDefault()跟随系统语言,避免在某些机型上出现英文月份。格式串按需改,HH是 24 小时制,hh是 12 小时制,别写混。

6. 进阶改造:把这份备忘录源码变成你自己的练手底座

跑通只是第一步,真正让这份源码产生价值的是你往上面加东西。我一般会拿这类项目做三件事:加搜索、加分类、加导出。下面挑两个改动量小但收益明显的说。

6.1 加一个关键字搜索:SQL 的 LIKE 与索引

搜索的核心就是给queryAll加一个whereClause:

public List<Note> search(String keyword) { List<Note> list = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = null; try { String where = COL_TITLE + " LIKE ? OR " + COL_CONTENT + " LIKE ?"; String[] args = new String[]{"%" + keyword + "%", "%" + keyword + "%"}; cursor = db.query(TABLE_NOTE, null, where, args, null, null, COL_TIME + " DESC"); // 解析逻辑同 queryAll,略 } finally { if (cursor != null) cursor.close(); } return list; }

逻辑说明:LIKE ?配合%keyword%做模糊匹配,?占位符能防止 SQL 注入。参数说明:args数组的顺序要和where里的?一一对应,写反了会查不到。数据量大了之后LIKE %x%没法走索引,会全表扫描,几百条以内无感,上万条就要考虑 FTS(全文搜索)了。

6.2 加一个「置顶」功能:字段扩展与排序调整

置顶的本质是加一个is_top字段,然后排序时优先按它排:

// 建表时加字段(新版本) db.execSQL("ALTER TABLE " + TABLE_NOTE + " ADD COLUMN is_top INTEGER DEFAULT 0"); // 查询排序改为 String orderBy = "is_top DESC, " + COL_TIME + " DESC";

逻辑说明:is_top DESC让置顶的(值为 1)排在前面,然后同组内按时间倒序。参数说明:DEFAULT 0保证老数据默认不置顶。切换置顶状态就是一条UPDATE note SET is_top = ? WHERE _id = ?,改完记得刷新列表。

6.3 验证改造是否成功的三个检查点

改完别急着说「好了」,按这三步走一遍:

检查点操作预期结果
数据持久化新增一条,杀掉进程重进记录还在,时间正确
升级兼容装旧版存数据,覆盖装新版老数据不丢,新字段有默认值
边界输入标题留空、内容超长、特殊字符不崩溃,有合理提示或截断

这三条是我每次改完数据库相关代码必走的流程。尤其是第二条,很多人只在模拟器上装一次,根本没测过升级路径,上线后老用户一更新就炸。

从那以后我每次拿到一份 Android 小项目源码,都强制先跑一遍「新增 → 杀进程 → 重进 → 改 → 删」这条完整链路,确认数据层没问题再动界面。这份备忘录源码的价值不在它本身多完善,而在于它给了你一个能随便改、改坏了也不心疼的底座。希望帮到你。

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

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

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

立即咨询