☰
Android记事本开发:SQLite数据库设计、软删除与自动保存实践
2026/10/19 10:11:56 网站建设 项目流程

记事本类应用算是Android入门里最经典也最容易被低估的一个项目,很多人觉得它不过是几个页面加数据库增删改查,但实际上手做一遍就会发现,里面藏着不少值得琢磨的细节。如果只是跟着教程敲一遍,功能是能跑通,但离“可以日常使用”的状态还有一段距离。

最近我花了一些时间把一个纯Android Studio实现的记事本项目重新整理了一遍,数据库用的就是系统自带的SQLite,没有任何第三方存储框架,界面也是原生View体系做的。这篇文章就完整拆解一下这个项目的设计思路、数据库建模、功能落地过程,以及我在实际开发里踩过的几个坑,希望能给正在做类似练习或者准备把它写进简历项目的朋友一些参考。

1. 功能定位与整体拆解

动手写代码之前,先把需求盘清楚。市面上比较成熟的记事本应用,功能一般会覆盖:新建笔记、编辑笔记、删除笔记、搜索、分类/标签、置顶、回收站、数据导出等。但是作为独立开发的本地存储项目,没必要一上来就把面铺得太宽,合理的做法是先拿“编辑”和“存储”这两条主干,保证核心链路足够稳,再考虑怎么加分支功能。

1.1 核心场景分析

我最终把这个项目定格在五个基础场景上:

  • 快速记一条内容,支持标题和正文两段式结构
  • 笔记列表页能展示摘要、更新时间,给用户一眼能识别的内容信息
  • 点击某条笔记进入编辑页,修改后保存
  • 支持按标题或正文内容做关键词搜索
  • 删除的笔记先进回收站,而不是直接物理删掉

这五个场景覆盖了记事本的高频路径:建、查、改、删、找。做起来不会太复杂,又能把SQLite的常用能力完整用一遍。如果你后续想丰富简历上的项目描述,回收站这个功能也是很好的加分点,因为它牵扯到“软删除”的设计思路,面试的时候可以聊的东西会多一些。

1.2 方案选型的理由

很多初学者在选择数据存储方案的时候会纠结:明明有Room、GreenDAO、Realm这些看起来更“高级”的框架,为什么还选原生的SQLite?我的看法是:如果这个项目本身就是用来练手和理解的,那用SQLite反而更合适。

原因有三点。

第一,SQLite是Android系统自带的,不需要引额外依赖,项目的可移植性最好。别人拿到你的源码,直接打开就能跑,不用配数据库环境。

第二,原生SQLite能让你把SQL语句、表结构设计、游标操作这些事情真正过一遍,而不是依赖ORM框架帮你屏蔽细节。你亲手写过一次CREATE TABLE和Cursor遍历,再去用Room,理解深度完全不一样。

第三,对于记事本这种轻量级单表/双表场景,SQLite的性能完全够用。它的极限不在你的应用场景里,索引加到位、事务用对地方,几千条笔记的查询都是毫秒级。

提示:如果以后真要做多人协作、云同步这种功能,再考虑换数据库或者加后端也来得及。本地记事本场景,SQLite不丢人,反而干净利落。

1.3 项目结构规划

代码结构上我保持了比较传统但清晰的分层方式:

app/src/main/java/com/example/note/ ├── data/ │ ├── NoteBean.java // 笔记实体类 │ ├── NoteDbHelper.java // SQLiteOpenHelper实现 │ └── NoteDao.java // 数据访问层封装 ├── ui/ │ ├── NoteListActivity.java // 列表页 │ ├── NoteEditActivity.java // 编辑页 │ ├── SearchActivity.java // 搜索页 │ └── TrashActivity.java // 回收站页 ├── adapter/ │ └── NoteAdapter.java // 列表适配器 └── utils/ ├── TimeUtils.java // 时间格式化工具 └── TextUtils.java // 字符串处理辅助

界面方面,列表页用一个RecyclerView,编辑页用两个EditText加一个工具栏,没有引入复杂的Material组件库。整体走的是原生风格,好处是逻辑清晰好调试,对新手很友好。

2. 数据库设计与建模细节

数据库是记事本项目的灵魂。数据表设计得好不好,直接影响后面的查询效率、回收站功能、扩展空间。这一节把表结构、字段选择逻辑和关键SQL写清楚。

2.1 表结构设计

我用一张表搞定核心业务,字段如下:

CREATE TABLE note ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', create_time INTEGER NOT NULL, update_time INTEGER NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, delete_time INTEGER DEFAULT 0, is_top INTEGER NOT NULL DEFAULT 0 );

逐字段说一下设计意图。

id用自增主键。虽然UUID字符串也可以,但本地自增整型在排序、分页、索引上都有天然优势,而且SQLite对INTEGER PRIMARY KEY做了优化,查询速度比UUID快得多。

title和content分开存。有人会觉得标题可以从正文第一行截取,但实际用下来分开存更灵活。用户可能标题和正文内容不一样,也可能正文有格式,强行截取会出各种预期之外的显示问题。分开存以后,列表摘要、搜索标题、搜索正文都可以单独走,不用每次现分析内容。

create_time和update_time都用INTEGER存毫秒时间戳。很多初学者喜欢用TEXT类型存时间字符串,比如"2024-05-01 12:30:00",这也能用,但在范围查询、排序上既慢又别扭。时间戳是纯数字,比较大小就是数值比较,配合索引效率高很多,显示的时候再格式化成可读字符串也不费事。

is_deleted是回收站功能的关键。置为1代表已删除,但数据还在表里,随时可以恢复。这种设计叫“软删除”,好处是不会误删数据,用户有后悔药吃。delete_time记录删除时间,方便以后做“回收站30天后自动清理”这类定时任务。

is_top用于置顶功能。置顶笔记在列表里要排在前面,排序时先按is_top降序,再按update_time降序就行。

2.2 索引设计

再说索引。很多人在学习SQLite的时候不太关注索引,觉得数据量小无所谓。但索引是数据库的核心机制之一,项目里不写索引,面试都过不了“数据库优化”这一关。

我建了两个索引:

CREATE INDEX idx_note_update_time ON note(update_time); CREATE INDEX idx_note_is_deleted ON note(is_deleted);

第一个索引用于列表排序,也就是按更新时间倒序展示笔记。第二个索引用于回收站查询,也就是WHERE is_deleted = 1这种高频条件。

title和content我没有建索引,原因是这两个字段的查询用的是LIKE '%keyword%'模糊匹配。这类查询在SQLite里用不上常规B-Tree索引,即使建了索引,%开头的模式也会让索引失效。想优化全文搜索,要么引入FTS4/FTS5虚拟表,要么先不折腾,笔记量不大时全表扫一遍也就几十毫秒的事。

2.3 数据库升级策略

数据库版本管理这一块,我用的是SQLiteOpenHelper的标准机制。onCreate负责建表,onUpgrade负责表结构调整。这个项目预留了一个升级场景:给note表增加is_top字段。

@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE note ADD COLUMN is_top INTEGER NOT NULL DEFAULT 0"); } }

注意一点:ALTER TABLE ADD COLUMN在SQLite里是不支持添加带PRIMARY KEY或UNIQUE约束的列的,所以设计新字段时,尽量都用NOT NULL DEFAULT兜底,避免老数据不符合新约束。

心得:版本升级逻辑一定不要写在onCreate里。有些人刚学的时候会把升级语句也塞进onCreate,结果老用户升级App后根本不会执行,因为数据库已经存在了,系统不会回调onCreate。这个坑我见过不少人踩过。

3. 数据访问层封装与关键代码实现

数据库类的封装直接决定了上层代码写起来顺不顺手。很多人喜欢在Activity里直接写SQLiteOpenHelper的实例,然后大量重复getReadableDatabase()、Cursor遍历这些样板代码。短期能跑,但代码维护极其痛苦。我把所有SQL操作收敛到一个NoteDao里,Activity完全不知道SQL的存在。

3.1 DAO层核心方法

NoteDao的对外接口大致是这样的:

public class NoteDao { private NoteDbHelper dbHelper; public NoteDao(Context context) { dbHelper = new NoteDbHelper(context); } public long insertNote(NoteBean bean) { ... } public int updateNote(NoteBean bean) { ... } public int softDeleteNote(long id) { ... } public int restoreNote(long id) { ... } public int deleteNoteForever(long id) { ... } public List<NoteBean> queryNotes() { ... } public List<NoteBean> queryNoteByKeyword(String keyword) { ... } public List<NoteBean> queryDeletedNotes() { ... } }

一次典型的插入操作长这样:

public long insertNote(NoteBean bean) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", bean.getTitle()); values.put("content", bean.getContent()); values.put("create_time", bean.getCreateTime()); values.put("update_time", bean.getUpdateTime()); values.put("is_deleted", bean.isDeleted()); values.put("is_top", bean.isTop()); long id = db.insert("note", null, values); db.close(); return id; }

虽然Google推荐用try-with-resources或者依赖框架管理数据库连接,但手写项目中,每次操作后显式close()更直观,不容易泄漏。

更新操作要强调一个细节:update_time一定要在Java层刷新,不能依赖SQLite的默认值逻辑。如果粗心大意忘了更新update_time,列表的排序就会是错的,笔记改完之后永远排不到最前面。

public int updateNote(NoteBean bean) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", bean.getTitle()); values.put("content", bean.getContent()); values.put("update_time", System.currentTimeMillis()); return db.update("note", values, "id = ?", new String[]{String.valueOf(bean.getId())}); }

3.2 软删除与批量操作

回收站的删除逻辑我用的是软删除,用事务包一层,避免部分成功部分失败的情况:

public void deleteNotes(List<Long> ids) { SQLiteDatabase db = dbHelper.getWritableDatabase(); db.beginTransaction(); try { for (Long id : ids) { ContentValues values = new ContentValues(); values.put("is_deleted", 1); values.put("delete_time", System.currentTimeMillis()); db.update("note", values, "id = ?", new String[]{String.valueOf(id)}); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } }

批量操作一定要开启事务。SQLite默认是自动提交模式,每条SQL都是一次独立事务,批量更新时来回写磁盘,速度慢得多,而且遇到中途失败,数据处于“删了一半”的脏状态,很难恢复。

3.3 搜索实现

搜索功能的SQL如下:

public List<NoteBean> queryNoteByKeyword(String keyword) { SQLiteDatabase db = dbHelper.getReadableDatabase(); String sql = "SELECT * FROM note WHERE is_deleted = 0 " + "AND (title LIKE ? OR content LIKE ?) " + "ORDER BY is_top DESC, update_time DESC"; String like = "%" + keyword + "%"; Cursor cursor = db.rawQuery(sql, new String[]{like, like}); List<NoteBean> list = new ArrayList<>(); while (cursor.moveToNext()) { list.add(cursorToBean(cursor)); } cursor.close(); db.close(); return list; }

这里有个细节:搜索只在is_deleted = 0的范围内进行。你不想用户在回收站里还能通过搜索看到已删除的笔记,那样逻辑就乱了。

搜索页我建议单独做一个Activity,而不是在列表页加一个过滤框。原因很简单:搜索的行为链路和列表页不一样。列表页是“展示全部笔记”,搜索页是“输入关键词→显示结果→点击结果进编辑页”。两个页面耦合在一起,状态管理很容易打架。

3.4 游标转换的封装

Cursor转实体是写SQLite应用最容易出低级错误的地方。我封装了一个方法:

private NoteBean cursorToBean(Cursor cursor) { NoteBean bean = new NoteBean(); bean.setId(cursor.getLong(cursor.getColumnIndexOrThrow("id"))); bean.setTitle(cursor.getString(cursor.getColumnIndexOrThrow("title"))); bean.setContent(cursor.getString(cursor.getColumnIndexOrThrow("content"))); bean.setCreateTime(cursor.getLong(cursor.getColumnIndexOrThrow("create_time"))); bean.setUpdateTime(cursor.getLong(cursor.getColumnIndexOrThrow("update_time"))); bean.setDeleted(cursor.getInt(cursor.getColumnIndexOrThrow("is_deleted")) == 1); bean.setTop(cursor.getInt(cursor.getColumnIndexOrThrow("is_top")) == 1); return bean; }

用getColumnIndexOrThrow()代替getColumnIndex(),好处是字段名一旦写错,立刻崩溃,不会出现“看起来数据不对但不知道哪里不对”的诡异情况。快速失败远比隐性Bug好排查。

4. 界面实现与列表交互细节

4.1 列表展示的边界处理

RecyclerView的适配器是列表页的核心。我在NoteAdapter里做了几个边界处理,都是真实使用以后才会注意到的细节。

标题为空时怎么显示?直接在列表里留一个TextView的空字符串,看起来会很难看,好像少了一块。我处理为:当title为空时,用“无标题”代替,字体颜色调成灰色,这样用户在视觉上能明确感知到“这条笔记没有标题”,而不是觉得布局坏了。

正文摘要截断怎么处理?很多笔记内容很长,列表页不能把全文铺开。我用了一行摘要加省略号的做法:

String content = note.getContent(); if (content.length() > 80) { content = content.substring(0, 80) + "..."; }

这里有一个需要注意的点:不要用Java字符串的length()去截断中文内容,因为它按字符处理,中英文混合时会截出半个表情符号或半个汉字。比较稳的做法是结合substring和TextUtils.ellipsize,或者直接交给TextView的maxLines和ellipsize属性。简单场景下,android:maxLines="1"和android:ellipsize="end"就够了,不用在Java层手动截取。

时间显示方面,我封装了TimeUtils.formatTime(long time),规则是:

  • 今天更新的显示“今天 14:30”
  • 昨天更新的显示“昨天 09:15”
  • 一周内的显示“周三 08:20”
  • 更早的显示“2025-03-12”

这个处理对用户体验很重要。如果列表每条都显示完整时间戳,视觉噪音很大,用户扫一眼很难抓住时间上的变化关系。

4.2 编辑页的自动保存

编辑页我一开始用的是“手动点击保存按钮”,后来发现很容易丢数据。用户写了半天字,手滑返回了,没点保存,直接白写。

后来改成了自动保存策略:在onPause()里把内容写入数据库,外加一个双保险接口——用户主动点“完成”按钮时也触发保存并结束页面。

@Override protected void onPause() { super.onPause(); saveNote(); }

这个方案的缺点是可能产生一次无用写入。比如用户打开了笔记,一个字没改,返回时仍然触发了一次update。不过在SQLite本地写一条数据开销极小,为了数据安全,这个代价可以接受。

经验:记事本应用的核心诉求是“内容不能丢”,而不是“少写一次数据库”。自动保存的优先级远高于性能优化。

4.3 列表项点击和长按的交互语义

点击进编辑页是常识。长按呢?我实现了两个动作:长按弹出底部弹窗,提供“删除”“置顶/取消置顶”两个选项。删除直接进回收站,置顶后笔记排到列表上方,并在卡片左侧显示一个浅色标签“置顶”。

长按的点击区域要控制好。我是在RecyclerView.ViewHolder的itemView上做setOnLongClickListener,不要在内部子控件上分别监听,否则用户点标题区域和点内容区域会触发不同的交互,体验不统一。

5. 实际开发踩过的坑与排查实录

这一部分是我最想写的内容,因为很多坑不是看文档能避开的,必须亲手踩过才知道。

5.1 中文乱码与编码问题

从SQLite读取中文显示乱码,通常是连接字符串或者控制台输出时的编码问题,而不是存储层问题。Android内置的SQLite默认存储UTF-8,Java字符串也是Unicode,理论上不会乱码。我遇到的乱码场景反而是日志打印时Logcat的编码环境不对,或者代码里用了String.getBytes()不指定字符集,导致在不同设备上表现不一致。

处理建议很简单:不要手动调用getBytes()和new String()去转换编码,原样存、原样取、原样显示,就完事了。任何一次你主动插入的“编码转换”,都是乱码的潜在来源。

5.2 模糊查询中文和特殊字符失效

LIKE查询在SQLite里对中文匹配是支持的,所以“搜索中文笔记标题”这个场景不会出大问题。真正容易的问题是关键词里带%和_这两个通配符。用户如果在搜索框里输入了一个%,你的SQL就会变成匹配所有内容,搜索结果完全不可控。

解决办法是对关键词做转义:

keyword = keyword.replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_");

查询时加上ESCAPE '\'子句:

SELECT * FROM note WHERE title LIKE ? ESCAPE '\'

这个问题在真实用户场景里是必然会遇到的,只是很多Demo项目根本不会考虑这种输入边界。

5.3 数据库被反复创建和升级

我遇到过一个非常隐蔽的Bug:修改了表结构,提高了数据库版本号,结果升级后旧数据全部消失。排查半天发现问题是这样的——我改了DBHelper里DATABASE_NAME,比如从note.db改成了note_v2.db。系统认为这是一个全新的数据库,于是直接执行onCreate,把旧库完全舍弃了。

这不是代码逻辑错误,而是开发习惯的问题。数据库文件名一旦确定并发布了版本,后续就不应该再改。表结构变更走onUpgrade,而不是靠换文件名绕过去。

5.4 列表刷新的时机

用RecyclerView展示SQLite数据时,很多人会在onResume里无条件重新查询并notifyDataSetChanged()。这在数据量小的时候问题不大,但每次从编辑页返回时,列表会闪一下,因为适配器数据重新加载了,而且RecyclerView会丢失滚动位置。

我后来改成了更精细的刷新策略:

  • 数据库内容变化时,只在确实变化时发起刷新
  • 列表页从编辑页返回时,对比update_time的最大值,有变化才刷新
  • 用notifyItemChanged(position)或notifyItemInserted(position)替代全量notifyDataSetChanged()

具体到实现,我在NoteDao增加了一个获取最大更新时间的方法:

public long getMaxUpdateTime() { SQLiteDatabase db = dbHelper.getReadableDatabase(); Cursor cursor = db.rawQuery("SELECT MAX(update_time) FROM note", null); long max = 0; if (cursor.moveToFirst()) { max = cursor.getLong(0); } cursor.close(); db.close(); return max; }

然后在onResume里对比上一次的值,变了才重新查询列表。这个思路对几百条笔记的数据量来说是足够的,而且从根上避免了“每次返回都刷新全部”的性能浪费和闪烁问题。

5.5 回收站恢复时的ID问题

软删除模式下,id不会变。恢复笔记其实就是把is_deleted改回0。这个逻辑本身很简单,但容易踩的坑在于“删除同名笔记”和“恢复之后列表位置”。因为AUTOINCREMENT的自增值不会因为删除而回退,所以无需担心恢复时创建新ID从而打乱引用关系。唯一要处理的就是恢复后刷新列表,并且把笔记按更新时间重新排序。

5.6 空列表的友好提示

很多人做完功能后,忽略了一个看似不起眼但直接影响体验的细节:空列表页的呈现。第一次打开App,一条笔记都没有,如果只是白屏,用户会觉得应用是不是坏了。我在列表页里加了一个空视图提示,中间显示一行灰色文字“还没有笔记,点击右下角新建一条吧”,下方配一个简单地绘制的图形。

这个设计实现起来不难,在Adapter数据为空时切换ListView的可见性就行。但在我测试过的项目中,这个细节对用户体感的提升非常明显。

6. 常见问题速查表

问题表现原因与解决
列表显示旧数据编辑返回后不刷新onResume里没做数据刷新;或刷新策略里没比对update_time变化
恢复回收站笔记后列表不更新恢复后还在“已删除”状态确认恢复时把delete_time归0,否则其他查询会误判
列表滚动位置丢失返回列表顶部刷新数据时不要直接notifyDataSetChanged,定位到对应position再刷新
搜索关键词含%结果异常对%_做转义,加ESCAPE
自动保存导致返回键无效退出页面后内容没保存检查是否在onPause之前弹了“保存成功”之类的阻塞对话框
数据库旧版本升级崩溃ALTER TABLE报错检查旧表是否存在;用Cursor判断列是否存在再执行新增列逻辑
重复点击保存生成多条重复笔记编辑页的保存按钮加防抖,或者在onPause和点击保存之间做逻辑互斥
时间显示不确定显示的永远是1970/1973检查时间戳是否用了秒而不是毫秒,SQLite存的是System.currentTimeMillis()的话取值要配套

7. 这个项目的后续扩展思路

基础功能做完以后,项目可以继续往几个方向延展,我按实用程度排个序,大家可以按自己的兴趣选择。

7.1 置顶功能

我在设计表结构时已经预留了is_top字段,所以这算是一个低成本高感知的功能。只要在DAO层增加“把某条笔记的is_top置1,并把其他笔记的is_top都置0”的逻辑,再调整列表排序规则即可。需要注意的是批量置0时用事务包一下,别出现两条同时置顶的情况。

7.2 标签分类

给笔记增加label字段,然后用GROUP BY label做区分。这里的代价在于界面层:需要一个标签管理入口,还要在列表页展示标签筛选条件。如果你的项目准备用来面试,标签功能是可以讲讲“多条件筛选SQL怎么写”的好话题。

7.3 富文本编辑

纯文本的编辑体验肯定赶不上带加粗、变色、插入图片的富文本。但要注意一点:富文本引入HTML格式后,你的content字段将不再只是纯文本,SQLite存储没压力,真正压力大的是编辑器的选型和解析。如果只是练习,建议先做纯文本模式,把底子打稳。

7.4 数据导出

把笔记导出为纯文本文件或CSV,其实是SQLite应用很有实用性的功能。遍历Cursor,逐行拼接字符串,写入外部存储,加一个MediaStore插入操作让用户在文件管理器里能看到。这个功能实现成本低,而且能显著提升项目的“完整感”。

8. 打包与性能优化的最后一步

最后说一下正式使用时的细节优化,虽然不影响功能正确性,但直接影响用户的安装体验和运行流畅度。

8.1 开启数据库的WAL模式

SQLite在Android上默认的日志模式是DELETE模式,读写操作之间锁竞争比较明显。对于记事本这种单用户本地方案,改成WAL模式后并发读写的容忍度会好不少:

db.enableWriteAheadLogging();

老版本的API可以放在SQLiteOpenHelper.onConfigure()里执行。注意WAL模式会同时生成-wal和-shm两个额外文件,应用卸载或清理数据时这些文件会一并被系统处理,不需要担心。

8.2 减小APK体积

整个项目不引入任何第三方数据库库,也没有庞大的UI框架,APK体积可以控制在很小的范围内。打包时再开启minifyEnabled true做代码压缩,一个记事本App的APK体积基本在2-3MB,这对移动应用来说算是比较轻量的体量。

8.3 数据库备份

本地数据最怕换手机后全丢。SQLite备份最简单的方案是定期把数据库文件复制到外部存储,或者上传到用户自己的云盘。这个功能涉及文件复制和数据一致性,需要先关闭数据库连接再复制文件,否则可能复制的是一份中间状态的脏数据。实现上可以放在Application的某个空闲时间段,比如应用进入后台时触发。

写在最后

这个项目的完整代码算不上复杂,但我觉得它最大的价值在于:让一个刚接触Android开发的开发者,能通过不到一千行代码,把界面、数据存储、状态管理、SQL优化、异常处理这些最基础也最重要的环节完整地过一遍。

如果你正在做一个类似的记事本项目,我的建议是:先确保增删改查的链路非常稳,再考虑加功能。很多人做项目喜欢追求功能多,结果每个功能都做得不够透。反过来,你把搜索边界处理好、回收站恢复逻辑写对、自动保存做稳、时间格式化做到分场景展示,这些基础细节带来的成长反而更大。

根据我个人验证下来的体验,SQLite作为本地记事本的存储方案,在几百条数据量下流畅度完全没问题,哪怕加上模糊搜索和列表滑动,也不会出现性能短板。把这个项目吃透以后,再去接触Room或者别的ORM框架,你会发现很多概念其实是相通的,而且因为你先写过底层SQL,看高层框架的设计就能更明白它替你解决了哪些问题。

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

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

立即咨询