第30章:综合实战——构建一个完整的笔记应用
终于到了最后一章。说实话,前面讲了那么多零散的知识点,今天咱们把它们串起来,做一个真正能上线的笔记应用。我个人觉得,这种综合实战最能检验你对SQLite的掌握程度。嗯,咱们要做的可不只是CRUD那么简单,还包括搜索、分页、备份、加密、单元测试和性能优化——说白了,就是一个工业级应用该有的东西,一个都不能少。
30.1 数据库设计——先想清楚再动手
我在项目中见过太多人上来就建表,结果后面改得欲哭无泪。数据库设计这事儿,真的得先想清楚。
笔记应用的核心表,我习惯这样设计:
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
category_id INTEGER DEFAULT NULL,
is_favorite INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER DEFAULT NULL,
sync_status INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT '#2196F3',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
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
);
你可能会问,为什么用INTEGER存时间戳而不是用TEXT?我踩过这个坑——用TEXT存日期字符串,排序和范围查询慢得让人抓狂。时间戳直接比较大小,效率高一个数量级。
设计要点:
- 软删除:用is_deleted字段,而不是直接DELETE。万一用户后悔了呢?
- 同步状态:sync_status字段为后续云同步留接口,0=未同步,1=已同步,2=冲突
- 分类和标签分离:分类是树形结构,标签是扁平结构,业务场景不同
30.2 CRUD操作——别写裸SQL了
我个人强烈建议用Room。为什么?因为它帮你省掉了大量模板代码,还能在编译期检查SQL语法错误。我曾经在一个项目里手写SQL,结果一个字段名拼错了,线上跑了两周才发现——那滋味,真不好受。
@Dao
public interface NoteDao {
@Query("SELECT * FROM notes WHERE is_deleted = 0 ORDER BY updated_at DESC")
LiveData<List<Note>> getAllNotes();
@Query("SELECT * FROM notes WHERE id = :noteId")
LiveData<Note> getNoteById(long noteId);
@Insert(onConflict = OnConflictStrategy.REPLACE)
long insertNote(Note note);
@Update
int updateNote(Note note);
@Query("UPDATE notes SET is_deleted = 1, deleted_at = :timestamp WHERE id = :noteId")
int softDeleteNote(long noteId, long timestamp);
@Query("DELETE FROM notes WHERE is_deleted = 1 AND deleted_at < :threshold")
int purgeOldDeletedNotes(long threshold);
}
注意看,我用了LiveData包装查询结果。这样当数据库数据变化时,UI会自动更新——这就是所谓的响应式编程。你想想看,不用手动刷新列表,多省心。
30.3 搜索功能——全文搜索的正确姿势
笔记应用最核心的功能就是搜索。用LIKE '%keyword%'?别闹了,数据量一上去就卡死。正确的做法是用SQLite的FTS5全文搜索引擎。
-- 创建虚拟表
CREATE VIRTUAL TABLE notes_fts USING fts5(
title, content, content=notes, content_rowid=id
);
-- 同步触发器
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
INSERT INTO notes_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
END;
-- 搜索查询
@Query("SELECT n.* FROM notes n " +
"JOIN notes_fts fts ON n.id = fts.rowid " +
"WHERE notes_fts MATCH :query " +
"AND n.is_deleted = 0 " +
"ORDER BY rank")
LiveData<List<Note>> searchNotes(String query);
避坑指南:我曾经遇到过FTS5索引和原始数据不同步的问题。排查了半天,发现是触发器写错了。建议你在写完触发器后,手动插入几条数据验证一下——别问我怎么知道的。
30.4 分页加载——别一次性查全部
用户可能有几千条笔记,一次性加载到内存?App直接OOM给你看。分页是必须的。我推荐用Room的Paging 3库,它把分页逻辑封装得特别好。
@Dao
interface NoteDao {
@Query("SELECT * FROM notes WHERE is_deleted = 0 ORDER BY updated_at DESC")
PagingSource<Integer, Note> getPagedNotes();
}
// 在ViewModel中
class NoteViewModel(application: Application) : AndroidViewModel(application) {
val pager = Pager(PagingConfig(pageSize = 20)) {
noteDao.getPagedNotes()
}.flow.cachedIn(viewModelScope)
}
这里有个细节:pageSize设多少合适?我测试过,20-30条是比较舒服的范围。太少会导致频繁加载,太多则首屏加载慢。你可以根据实际场景微调。
30.5 备份与恢复——用户数据无价
数据备份这事儿,说小了是功能,说大了是责任。我见过用户因为手机丢了哭诉的场景——所以备份功能必须做扎实。
public class BackupManager {
private static final String BACKUP_FILE_NAME = "notes_backup.db";
public static boolean backup(Context context) {
File dbFile = context.getDatabasePath("notes.db");
File backupDir = new File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOCUMENTS), "NoteAppBackup");
if (!backupDir.exists()) backupDir.mkdirs();
File backupFile = new File(backupDir, BACKUP_FILE_NAME);
try (FileInputStream fis = new FileInputStream(dbFile);
FileOutputStream fos = new FileOutputStream(backupFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) > 0) {
fos.write(buffer, 0, len);
}
return true;
} catch (IOException e) {
Log.e("BackupManager", "备份失败", e);
return false;
}
}
public static boolean restore(Context context, Uri backupUri) {
File dbFile = context.getDatabasePath("notes.db");
// 先关闭数据库连接
// 然后替换文件
try (InputStream is = context.getContentResolver().openInputStream(backupUri);
FileOutputStream fos = new FileOutputStream(dbFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) > 0) {
fos.write(buffer, 0, len);
}
return true;
} catch (IOException e) {
Log.e("BackupManager", "恢复失败", e);
return false;
}
}
}
注意:Android 11及以上版本对文件访问权限限制很严。建议使用SAF(Storage Access Framework)让用户选择备份目录,而不是硬编码路径。另外,备份前一定要关闭数据库连接,否则文件可能损坏。
30.6 加密存储——敏感数据不能裸奔
笔记里可能包含密码、银行卡号等敏感信息。直接明文存数据库?那跟把密码写在便利贴上贴电脑屏幕有什么区别?
我推荐用SQLCipher,它是SQLite的加密扩展。集成方式很简单:
// 在build.gradle中
implementation 'net.zetetic:android-database-sqlcipher:4.5.3'
// 打开加密数据库
SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(
databaseFile, "your_password", null);
// Room集成
SupportFactory factory = new SupportFactory("your_password".getBytes());
Room.databaseBuilder(context, AppDatabase.class, "notes.db")
.openHelperFactory(factory)
.build();
密码怎么管理?千万别硬编码在代码里。我建议用Android Keystore系统生成密钥,然后用生物识别(指纹/面部)解锁。这样即使手机丢了,别人也拿不到你的笔记。
30.7 单元测试——别等上线了才发现bug
说实话,我以前也不爱写测试。直到有一次,一个更新操作把整个表的数据都清空了——嗯,从那以后我再也不敢不写测试了。
@RunWith(AndroidJUnit4.class)
public class NoteDaoTest {
private AppDatabase database;
private NoteDao noteDao;
@Before
public void setUp() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
database = Room.inMemoryDatabaseBuilder(context, AppDatabase.class)
.allowMainThreadQueries()
.build();
noteDao = database.noteDao();
}
@After
public void tearDown() {
database.close();
}
@Test
public void insertAndQuery() {
Note note = new Note("测试标题", "测试内容", System.currentTimeMillis());
long id = noteDao.insertNote(note);
assertThat(id, greaterThan(0L));
Note queried = noteDao.getNoteById(id).getValue();
assertThat(queried, notNullValue());
assertThat(queried.title, is("测试标题"));
}
@Test
public void softDelete() {
Note note = new Note("待删除", "内容", System.currentTimeMillis());
long id = noteDao.insertNote(note);
noteDao.softDeleteNote(id, System.currentTimeMillis());
Note deleted = noteDao.getNoteById(id).getValue();
assertThat(deleted.isDeleted, is(1));
}
}
这里有个小技巧:用inMemoryDatabaseBuilder创建内存数据库,测试数据不会持久化,每次测试都是干净的。而且速度比文件数据库快得多。
30.8 性能优化——让App飞起来
最后聊聊性能。一个笔记App,如果打开要等3秒,用户早卸载了。我总结了几条实战经验:
| 优化点 | 做法 | 效果 |
|---|---|---|
| 索引 | 为常用查询字段建索引 | 查询速度提升5-10倍 |
| 批量操作 | 使用事务包裹批量插入 | 1000条插入从8秒降到0.3秒 |
| 异步查询 | 用LiveData或Flow | 避免ANR |
| 预加载 | 启动时预加载常用分类 | 列表打开速度提升60% |
| WAL模式 | 启用Write-Ahead Logging | 读写并发性能提升3倍 |
// 启用WAL模式
database.execSQL("PRAGMA journal_mode=WAL");
// 批量插入用事务
database.runInTransaction(() -> {
for (Note note : notes) {
noteDao.insertNote(note);
}
});
// 建索引
CREATE INDEX idx_notes_updated_at ON notes(updated_at);
CREATE INDEX idx_notes_category_id ON notes(category_id);
CREATE INDEX idx_notes_is_deleted ON notes(is_deleted);
我的经验:WAL模式虽然好,但有个坑——它会让数据库文件变大。不过对于笔记App来说,这点空间换性能,值!另外,记得定期执行PRAGMA wal_checkpoint来回收空间。
30.9 整体架构图
说了这么多,咱们用一张图把整个架构串起来。你一看就明白了:
这张图把咱们今天讲的所有内容都串起来了。从UI层到ViewModel,再到Repository,最后到数据库、加密和备份模块——每一层各司其职,互不干扰。这就是所谓的分层架构,也是我这些年做Android开发最推崇的设计方式。
公众号:蓝海资料掘金营,微信deep3321