ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android记事本功能拓展:全文搜索、回收站与版本历史实现

Android记事本功能拓展:全文搜索、回收站与版本历史实现

1. 从“能记”到“好用”:记事本应用功能拓展的整体设计思路

做过Android开发的人都有一个共识:系统自带的记事本Demo能跑通,但离“能用”差着十万八千里。我最早接触NotePad这个经典案例是在学习SQLite的时候,当时照着文档把增删改查跑通,觉得不过如此。直到真正把它拿给身边人用,反馈回来的问题才让我意识到——一个记事本应用的核心竞争力,根本不在于能不能存文字,而在于记录效率、检索速度和数据安全感这三件事上。

这次功能拓展的核心目标很明确:在保留原有轻量级架构的前提下,把NotePad从一个“能存文本的壳子”升级成一个日常真正愿意打开使用的工具。具体来说,要解决四个痛点:第一,笔记多了之后找不到,需要全文搜索和标签分类;第二,误删之后无法恢复,需要回收站机制;第三,重要笔记容易被误改,需要版本历史;第四,纯文本太单调,需要基础富文本和附件支持。

为什么选择在原有架构上做增量拓展,而不是推倒重来?这里面的考量很实际。NotePad的原始架构是典型的SQLite + ContentProvider + ListView三层结构,虽然ListView现在看起来有些过时,但ContentProvider这层抽象其实设计得相当合理——它把数据访问和UI完全解耦,意味着我可以在不改动UI层的前提下,直接扩展数据库Schema和Provider的URI匹配规则。这就像给老房子做翻新,承重墙不动,水电线路重新走一遍就行,比推倒重建省事得多,风险也小得多。

另一个关键决策是不引入Room或GreenDAO这类ORM框架。很多同行第一反应是“都什么年代了还用裸SQLite”,但我的判断是:NotePad的数据模型极其简单,就是一张笔记表加几张关联表,查询逻辑也不复杂。引入ORM带来的编译期注解处理、额外的依赖体积、以及学习成本,在这个场景下投入产出比并不高。更重要的是,裸SQLite能让我对索引和查询计划有完全的控制权,这在做全文搜索优化时非常关键。当然,如果你的项目已经上了Room,那继续用Room做迁移也完全没问题,思路是相通的。

在UI层面,我选择保留Activity + Fragment的经典组合,但把ListView替换成RecyclerView。原因很简单:回收站和版本历史需要多选操作和局部刷新,ListView的Adapter在notifyDataSetChanged时全量重绘的机制会导致滚动位置丢失,用户体验很差。RecyclerView的ItemAnimator和局部刷新能力是刚需。这个替换成本不高,但收益立竿见影。

注意:功能拓展最忌讳的是“一口气全加上”。我的建议是分阶段来,先做搜索和标签,再做回收站,最后做版本历史和富文本。每个阶段独立测试、独立上线,出问题也容易定位。

2. 数据库Schema扩展与ContentProvider改造实操

2.1 笔记表结构升级与索引设计

原始NotePad只有一张notes表,字段大概就是id、title、content、created、modified这几个。要支持标签、回收站和版本历史,至少需要扩展成四张表。先看核心的notes表改造:

CREATE TABLE notes ( _id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', content TEXT, created INTEGER NOT NULL, modified INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, deleted_time INTEGER DEFAULT 0, is_starred INTEGER NOT NULL DEFAULT 0, color_tag INTEGER DEFAULT 0 );

这里新增了几个关键字段。deleted是软删除标记,0表示正常,1表示在回收站。为什么不直接物理删除?因为回收站需要保留原始数据,物理删除后就真找不回来了。deleted_time记录删除时间,用于回收站的排序和自动清理(比如30天后彻底删除)。is_starred支持星标置顶,color_tag是颜色分类标记。

索引的设计直接决定了搜索性能。我在title和content上建了FTS4虚拟表来做全文搜索,而不是用LIKE查询。原因很直接:LIKE '%keyword%'这种写法无法使用索引,数据量到几千条时就会明显卡顿。FTS4的中文分词虽然不如专业分词库精准,但配合simple分词器做单字匹配,实际体验已经够用。

CREATE VIRTUAL TABLE notes_fts USING fts4( title, content, content=notes, tokenize=simple );

注意content=notes这个参数,它表示FTS表是notes表的“外部内容表”,不重复存储数据,只建索引。这样既省空间,又保证了数据一致性——notes表更新时通过触发器同步FTS索引。

标签表的设计用了经典的多对多结构:

CREATE TABLE tags ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created INTEGER NOT NULL ); CREATE TABLE note_tags ( note_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (note_id, tag_id), FOREIGN KEY (note_id) REFERENCES notes(_id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(_id) ON DELETE CASCADE );

版本历史表则记录每次修改的快照:

CREATE TABLE note_history ( _id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, title TEXT, content TEXT, modified INTEGER NOT NULL, FOREIGN KEY (note_id) REFERENCES notes(_id) ON DELETE CASCADE );

2.2 ContentProvider的URI匹配与批量操作

ContentProvider的改造核心是扩展UriMatcher的匹配规则。原始代码只有notes和notes/#两条规则,现在需要加上notes/#/tags、tags、tags/#、notes/#/history、search等。这里有个容易踩的坑:UriMatcher的匹配顺序是从上到下的,通配符规则必须放在具体规则后面。比如notes/#要放在notes/search后面,否则search会被当成id匹配掉。

static { matcher.addURI(AUTHORITY, "notes", NOTES); matcher.addURI(AUTHORITY, "notes/#", NOTE_ID); matcher.addURI(AUTHORITY, "notes/search/*", NOTE_SEARCH); matcher.addURI(AUTHORITY, "notes/#/tags", NOTE_TAGS); matcher.addURI(AUTHORITY, "tags", TAGS); matcher.addURI(AUTHORITY, "tags/#", TAG_ID); matcher.addURI(AUTHORITY, "notes/#/history", NOTE_HISTORY); matcher.addURI(AUTHORITY, "trash", TRASH); }

批量操作是另一个重点。回收站清空、批量打标签这些场景,如果逐条调用ContentResolver,每次都是一次跨进程通信,效率极低。正确做法是用ContentProviderOperation做批量事务:

ArrayList<ContentProviderOperation> ops = new ArrayList<>(); for (long id : selectedIds) { ops.add(ContentProviderOperation.newUpdate(NOTE_URI) .withSelection("_id=?", new String[]{String.valueOf(id)}) .withValue("deleted", 1) .withValue("deleted_time", System.currentTimeMillis()) .build()); } try { resolver.applyBatch(AUTHORITY, ops); } catch (OperationApplicationException e) { Log.e(TAG, "批量移入回收站失败", e); }

applyBatch会把所有操作打包成一次Binder调用,在Provider端作为一个事务执行,要么全成功要么全失败。这比循环单条操作快一个数量级,而且保证了数据一致性。

实操心得:FTS表的触发器一定要在数据迁移完成后再创建。如果先建触发器再导入旧数据,每条插入都会触发索引更新,几千条数据导入会慢到怀疑人生。正确顺序是:建表 → 导入数据 → 建触发器 → 重建FTS索引。

3. 全文搜索、标签系统与回收站的完整实现

3.1 搜索功能的查询优化与结果高亮

搜索功能看起来简单,做起来坑最多。第一个问题是搜索范围:只搜标题还是标题加内容?我的方案是两者都搜,但标题匹配的权重更高,排序时标题命中的排在前面。FTS4本身不支持权重排序,需要在Java层做二次排序。

public Cursor searchNotes(String keyword) { String ftsQuery = buildFtsQuery(keyword); String sql = "SELECT n.*, " + "CASE WHEN n.title LIKE ? THEN 1 ELSE 0 END AS title_match " + "FROM notes n " + "JOIN notes_fts f ON n._id = f.rowid " + "WHERE notes_fts MATCH ? AND n.deleted = 0 " + "ORDER BY title_match DESC, n.modified DESC"; return db.rawQuery(sql, new String[]{"%" + keyword + "%", ftsQuery}); }

buildFtsQuery负责把用户输入的关键词转成FTS4能识别的查询语法。中文场景下,最简单的做法是每个字之间加空格,让simple分词器逐字匹配。比如“项目计划”转成“项 目 计 划”,这样只要笔记里包含这四个字中的任意组合都能命中。虽然会有一些误匹配,但召回率优先于精确率,用户可以通过结果列表快速筛选。

结果高亮是提升体验的关键细节。在Adapter的onBindViewHolder里,用SpannableString把命中的关键词标黄:

private CharSequence highlightKeyword(String text, String keyword) { if (TextUtils.isEmpty(keyword)) return text; SpannableString spannable = new SpannableString(text); int index = text.toLowerCase().indexOf(keyword.toLowerCase()); while (index >= 0) { spannable.setSpan(new BackgroundColorSpan(0xFFFFEB3B), index, index + keyword.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); index = text.toLowerCase().indexOf(keyword.toLowerCase(), index + 1); } return spannable; }

搜索的防抖处理也必不可少。用户每输入一个字符就查一次数据库,不仅浪费资源,还会导致结果列表闪烁。我用Handler做300毫秒的延迟查询,输入停止后才真正执行搜索。

3.2 标签系统的增删改查与筛选联动

标签系统的核心交互是:在编辑笔记时添加/移除标签,在列表页按标签筛选。添加标签的流程需要处理“标签已存在”和“标签新建”两种情况:

public long getOrCreateTag(String name) { Cursor cursor = resolver.query(TAG_URI, null, "name=?", new String[]{name}, null); if (cursor != null && cursor.moveToFirst()) { long id = cursor.getLong(cursor.getColumnIndex("_id")); cursor.close(); return id; } if (cursor != null) cursor.close(); ContentValues values = new ContentValues(); values.put("name", name); values.put("created", System.currentTimeMillis()); Uri uri = resolver.insert(TAG_URI, values); return ContentUris.parseId(uri); }

这里有个并发问题:如果两个线程同时判断“标签不存在”然后都去插入,就会违反name字段的UNIQUE约束。解决方案是用insertWithOnConflict配合CONFLICT_IGNORE,插入冲突时忽略,然后再查一次:

resolver.insertWithOnConflict(TAG_URI, values, SQLiteDatabase.CONFLICT_IGNORE); // 重新查询获取id

标签筛选的UI我用了ChipGroup,每个标签一个Chip,点击切换选中状态。选中的标签之间是“与”关系还是“或”关系?我的选择是“与”——选了两个标签,只显示同时拥有这两个标签的笔记。这更符合用户筛选时的心理预期。对应的SQL用GROUP BY ... HAVING COUNT(DISTINCT tag_id) = ?实现:

SELECT n.* FROM notes n JOIN note_tags nt ON n._id = nt.note_id WHERE nt.tag_id IN (?, ?) AND n.deleted = 0 GROUP BY n._id HAVING COUNT(DISTINCT nt.tag_id) = 2 ORDER BY n.modified DESC

3.3 回收站机制与自动清理策略

回收站的设计要点有三个:软删除、恢复、自动清理。软删除前面已经说了,就是更新deleted字段。恢复就是把deleted改回0,同时清空deleted_time。自动清理我用的是AlarmManager加JobScheduler的组合方案——JobScheduler在系统空闲时执行清理任务,AlarmManager作为兜底,确保每天至少执行一次。

public void scheduleCleanup() { ComponentName service = new ComponentName(context, CleanupJobService.class); JobInfo job = new JobInfo.Builder(JOB_ID, service) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE) .setRequiresDeviceIdle(true) .setPeriodic(TimeUnit.DAYS.toMillis(1)) .build(); JobScheduler scheduler = context.getSystemService(JobScheduler.class); scheduler.schedule(job); }

清理逻辑是删除deleted_time超过30天的记录。这里要注意:删除notes记录时,关联的note_tags和note_history会因为外键的ON DELETE CASCADE自动删除,但FTS索引不会自动更新,需要手动执行INSERT INTO notes_fts(notes_fts) VALUES('rebuild')重建索引。

注意:回收站的自动清理一定要给用户提示。我在设置页加了一个“回收站保留时长”的选项,默认30天,用户可以改成7天、14天或永久保留。永久保留意味着不自动清理,但会显示占用空间大小,让用户自己决定。

4. 版本历史、富文本与附件支持的进阶实现

4.1 版本快照的触发时机与存储策略

版本历史最容易犯的错误是每次保存都存快照。用户编辑一篇长笔记,可能按了十几次保存,如果每次都存,历史表会迅速膨胀。我的策略是差异触发:只有当标题或内容的变化超过一定阈值(比如内容变化超过50个字符,或者距离上次快照超过10分钟)才创建新版本。

private boolean shouldCreateSnapshot(String oldContent, String newContent, long lastSnapshotTime) { if (oldContent == null) return true; int lengthDiff = Math.abs(newContent.length() - oldContent.length()); long timeDiff = System.currentTimeMillis() - lastSnapshotTime; return lengthDiff > 50 || timeDiff > TimeUnit.MINUTES.toMillis(10); }

快照的存储也有讲究。如果笔记内容很大(比如几万字),每条快照都存全文会占用大量空间。我用了简单的差分压缩:只存变化的部分。具体实现是计算新旧内容的公共前缀和公共后缀,只存中间变化的部分加上前缀长度和后缀长度。恢复时根据前缀、变化内容、后缀拼接出完整内容。这个算法实现简单,对文本编辑场景的压缩效果很好。

版本历史的UI用BottomSheetDialog展示,列表显示修改时间,点击可以预览该版本的内容,长按可以恢复到该版本。恢复操作会先把当前内容存为一个新版本,然后再应用旧版本,这样恢复操作本身也是可逆的。

4.2 富文本编辑的轻量级方案

完整的富文本编辑器(支持加粗、斜体、列表、图片混排)实现成本很高,我选择了一个折中方案:用Markdown语法做输入,用TextView做渲染。用户在编辑框里输入**加粗**,预览区实时渲染成加粗效果。这样既避免了复杂的Span操作,又保证了数据的可移植性——Markdown是纯文本,存进数据库没有任何兼容性问题。

渲染用的是Markwon库,它轻量、渲染速度快,支持大部分常用Markdown语法。集成很简单:

Markwon markwon = Markwon.builder(context) .usePlugin(StrikethroughPlugin.create()) .usePlugin(TablePlugin.create()) .build(); markwon.setMarkdown(previewTextView, markdownContent);

编辑和预览的切换用ViewPager2做左右滑动,左边编辑右边预览。这里有个细节:预览的更新不能每次输入都触发,否则长笔记会卡顿。我用的是TextWatcher加500毫秒防抖,输入停止后才更新预览。

4.3 附件管理的文件存储与权限适配

附件支持图片和普通文件。图片用系统相册选择器获取URI,然后复制到应用的私有目录。为什么不直接存URI?因为相册的URI权限是临时的,应用重启后就失效了。复制到私有目录虽然占空间,但保证了附件的持久可用。

private String copyToPrivateStorage(Uri sourceUri, String fileName) throws IOException { File destDir = new File(context.getFilesDir(), "attachments"); if (!destDir.exists()) destDir.mkdirs(); File destFile = new File(destDir, fileName); try (InputStream in = resolver.openInputStream(sourceUri); OutputStream out = new FileOutputStream(destFile)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) > 0) { out.write(buffer, 0, len); } } return destFile.getAbsolutePath(); }

Android 10以上的分区存储适配是个绕不开的坎。我的方案是:应用私有目录的文件用FileProvider生成content URI供内部使用;如果用户要导出附件到公共目录,用MediaStoreAPI插入。这样既符合分区存储规范,又不需要申请存储权限。

附件表的设计:

CREATE TABLE attachments ( _id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, file_path TEXT NOT NULL, file_name TEXT NOT NULL, file_size INTEGER NOT NULL, mime_type TEXT, created INTEGER NOT NULL, FOREIGN KEY (note_id) REFERENCES notes(_id) ON DELETE CASCADE );

实操心得:附件复制操作一定要放在后台线程,并且显示进度条。我见过太多应用在主线程做文件IO导致ANR的案例。用ExecutorService加Handler回传进度是最稳妥的方案。

5. 常见问题排查与性能优化实录

5.1 搜索卡顿与FTS索引失效的排查

问题现象:笔记数量到2000条左右时,搜索开始出现明显延迟,输入关键词后要等1-2秒才出结果。

排查过程:先用EXPLAIN QUERY PLAN看查询计划,发现FTS查询走了全表扫描而不是索引。进一步检查发现,FTS表的触发器在数据迁移时被意外删除了,导致新插入的数据没有同步到FTS索引,查询时FTS表数据不完整,优化器选择了全表扫描。

解决方案:重建触发器并执行INSERT INTO notes_fts(notes_fts) VALUES('rebuild')重建索引。重建2000条数据的索引大约耗时3秒,放在应用启动时的后台线程执行,用户无感知。

预防措施:在数据库版本升级的onUpgrade里,每次都要检查触发器是否存在,不存在就创建。这个检查成本极低,但能避免大问题。

5.2 回收站数据不一致的修复

问题现象:用户反馈有些笔记在回收站里看不到,但在“全部笔记”里也找不到,像是凭空消失了。

排查过程:查询数据库发现这些笔记的deleted字段是1,但deleted_time是0。回收站的查询条件是deleted=1 AND deleted_time>0,所以这些笔记两边都不显示。

根因分析:批量移入回收站的代码里,ContentValues只放了deleted=1,忘了放deleted_time。这是典型的“复制粘贴漏改”问题。

修复方案:写一个修复脚本,把所有deleted=1 AND deleted_time=0的记录补上当前时间。同时在Provider的update方法里加校验:如果更新了deleted字段,必须同时更新deleted_time,否则抛异常。这种防御性编程能避免同类问题再次发生。

5.3 版本历史导致数据库膨胀的优化

问题现象:使用三个月后,数据库文件从几百KB涨到了50MB,用户开始抱怨占用空间大。

排查过程:分析各表数据量,发现note_history表占了90%以上的空间。有些笔记被编辑了几百次,每次都快照全文。

优化方案:第一,启用前面说的差异触发策略,减少快照数量。第二,对历史快照做数量限制,每篇笔记最多保留50个版本,超出后删除最旧的。第三,对超过30天的历史快照做压缩存储,用GZIP压缩内容字段。这三招组合下来,数据库体积回落到5MB以内。

清理脚本:

DELETE FROM note_history WHERE _id NOT IN ( SELECT _id FROM note_history nh WHERE nh.note_id = note_history.note_id ORDER BY modified DESC LIMIT 50 );

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
搜索无结果FTS索引未同步检查触发器是否存在重建触发器并rebuild索引
回收站笔记消失deleted_time为0查询deleted=1且deleted_time=0补全deleted_time字段
数据库膨胀历史快照过多统计各表行数和大小限制版本数+压缩存储
标签筛选结果为空标签关联丢失检查note_tags表重建关联或修复外键
附件无法打开文件被清理检查私有目录文件是否存在提示用户重新添加附件
列表滚动卡顿主线程查询数据库用StrictMode检测查询移到后台线程
富文本渲染错乱Markdown语法不兼容对比渲染结果和输入统一使用CommonMark标准

避坑技巧:数据库版本升级时,一定要在onUpgrade里用switch做逐版本迁移,不要用if (oldVersion < newVersion)一刀切。我踩过的坑是:从版本1直接跳到版本3时,版本2的迁移逻辑被跳过了,导致字段缺失。逐版本迁移虽然代码多几行,但安全可靠。

6. 一些实际开发中的个人体会

这个项目从功能拓展到稳定运行,前后迭代了大概半年时间。最大的体会是:记事本这类工具应用,功能多不如功能稳。我见过太多应用堆了一堆花哨功能,结果基础的新建、保存、搜索都做不好,用户用两次就卸载了。

另一个深刻的教训是关于数据迁移。任何涉及数据库Schema变更的版本升级,都必须做完整的迁移测试——从最老的版本一路升到最新版本,每一步都要验证数据完整性。我在这上面栽过跟头,一个字段的默认值没处理好,导致几千条笔记的创建时间变成了0,排序全乱了。

性能优化方面,我的建议是先测量再优化。不要凭感觉猜哪里慢,用Android Profiler看CPU和内存,用EXPLAIN QUERY PLAN看SQL执行计划,用StrictMode检测主线程IO。数据会告诉你真正的瓶颈在哪里。我一开始以为搜索慢是FTS的问题,测了才发现是Adapter的onBindViewHolder里做了字符串高亮计算,把高亮逻辑移到后台线程后,滚动流畅度提升了一个档次。

最后说一个容易被忽视的点:备份和导出。用户对记事本的信任建立在“数据不会丢”的基础上。我在设置里加了自动备份到应用私有目录的功能,每天一次,保留最近7天的备份。虽然简单,但用户知道有备份,用起来就安心很多。这个功能的实现成本很低,但用户感知价值很高,属于典型的“小投入大回报”。

如果你也在做类似的功能拓展,我的建议是从搜索和回收站这两个功能开始。它们的技术难度适中,但用户感知最明显,能快速验证你的架构设计是否合理。等这两个功能稳定了,再往上叠版本历史和富文本,循序渐进,稳扎稳打。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进