☰
Android记事本APP开发实战:SQLite增删改查与源码包设计
2026/10/13 2:25:55 网站建设 项目流程

做记事本类Android项目,我前后折腾过不下三版。老实说,功能看起来就是“增删改查 + SQLite数据库”,连需求文档都能写满一张纸,但真把一个可交付的备忘录APP源码包完整跑起来,里面藏的坑比想象中多得多:表结构怎么设计才方便后期限扩充、数据库Helper该不该单例、游标什么时候关闭、列表刷新时机怎么卡、日期字段用什么格式存、整个源码包目录怎么组织才能让别人打开Android Studio就能跑。

这篇就把我手里这套“基于Android Studio的记事本备忘录APP开发全套源码包”从设计到落地的完整过程拆开讲,SQLite数据库层的增删改查实现、界面层和数据库怎么联动、源码包结构怎么复用、以及我实测踩过的几个典型问题,都写清楚。适合刚学Android需要练手项目的新人,也想给准备做毕业设计、课设或者给GitHub开源仓库做源码整理的开发者做个参考。

1. 项目思路与整体设计

1.1 为什么记事本是最合适的SQLite练手载体

很多人在学Android数据库时会直接堆一个复杂的业务系统,比如订单管理、图书管理,结果数据库表设计还没理清楚,界面先把人折腾废了。记事本备忘录是一个恰到好处的载体:单表单对象、字段少(标题、内容、创建时间、更新时间)、操作逻辑清晰(增删改查),但它又能把Android开发里的关键环节全部串起来——Activity生命周期对数据刷新影响、RecyclerView适配、空数据页状态、对话框交互、数据库升级、资源文件管理,全都在一个不大不小的项目里覆盖到了。

我选SQLite而不是Room或者GreenDAO的理由也很直接:SQLite是Android原生内置的数据库,不需要额外引入依赖库,SQL语句自己写一遍,数据库底层工作原理就清楚了。Room封装的太好,新手往往只知道注解注解注解,数据库文件长什么样都不知道。做源码包的话,用裸SQLite也最容易让接手的人看懂改懂。

这套源码包在设计上遵循了一个很朴素的原则:能用原生组件不引框架,能画清楚就不过度设计。主界面就是一个RecyclerView,长按弹菜单,点击进编辑页,新建按钮跳新的Activity,保存后刷新列表。不搞Fragment嵌套,不搞ViewModel和数据绑定的全家桶,每个类都有单一职责,初学者拿过来能顺着代码一步步读懂,有经验的朋友也能快速改造。

1.2 数据模型与表结构设计

备忘录的核心数据模型很简单,就是一个Note对象,我给它设计了五个字段,分别是主键id、标题、内容、创建时间createTime、更新时间updateTime。id用的是Long型自增主键,标题和内容用String,时间用Long型的毫秒值保存。

public class Note { private Long id; private String title; private String content; private Long createTime; private Long updateTime; public Note() { } public Note(Long id, String title, String content, Long createTime, Long updateTime) { this.id = id; this.title = title; this.content = content; this.createTime = createTime; this.updateTime = updateTime; } // 省略 getter/setter }

有人问为什么时间不用String类型直接存“2025-03-14 21:33:12”?我早期版本确实这样干过,后来发现两个痛点:第一,SQLite里字符串比较大小是按字典序走的,如果在查询时用WHERE update_time > '某个字符串'做时间过滤,格式稍微不统一就会出问题;第二,UI展示需要的时间格式跟数据库存储格式往往不一致,用Long毫秒值存,展示层想格式化成什么样子随心所欲,计算时间差也方便。

建表语句我放在了数据库Helper里,命名为note,SQL如下:

CREATE TABLE note ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, create_time INTEGER NOT NULL, update_time INTEGER NOT NULL );

这里有两个细节值得注意。title我加了NOT NULL约束,这样即使用户不填标题,保存时也能在逻辑层做判断提示,而不是等到数据库报错。content允许为空,因为有些用户就喜欢记一个空内容的备忘,只留一个标题也说得过去。update_time单独设置字段,用于排序和后续可能做的“最近编辑”展示,createTime一旦创建就不允许再改。

1.3 界面交互方案选型

交互方案我对比过三种:第一种是主界面直接显示List,旁边放悬浮新增按钮;第二种是底部导航栏,分“全部笔记”和“收藏”两个页签;第三种是带搜索框的复杂方案。这套源码包选了第一种,原因很务实:增删改查的演示重点是数据库操作,界面交互保持简洁,不把学习成本浪费在非核心模块上。

主界面布局由三部分组成:顶部的标题栏、中间的RecyclerView区域、右下角的FloatingActionButton。列表项note_item.xml只有两个控件,一个显示标题的TextView,一个显示更新时间的TextView,点击后跳编辑页。长按弹出AlertDialog,提供“编辑”和“删除”两个选项,这样把入口统一收拢,用户不需要记住列表项上哪个图标是删除、哪个是编辑。

新增笔记和编辑笔记共用一个Activity,只在启动时通过Intent传参数区分。新建时传-1,编辑时传笔记id,Activity读取id后判断是走“插入”还是“更新”流程。这个设计能省掉一半界面代码,对新手也友好,一个Activity写好了,两条路径都通了。

2. 工程搭建与环境准备

2.1 Android Studio版本与SDK配置建议

整套源码包我是在Android Studio较新的版本下开发的,但为了保证任何人打开都能直接跑,build配置我往下兼容了不少。建议的编译SDK版本是33或34,minSdk设置成21,也就是Android 5.0起步。覆盖了当前主流设备的系统版本,同时minSdk设得低一些,代码里就需要避开太新的API,好在这个项目基本用不到什么特殊接口,普通写法就够了。

经验:minSdk别一上来就设28甚至30。很多新手机没问题,但如果你要跑老机型的模拟器,或者别人的手机系统版本不高,minSdk太高就直接装不上。设到21,兼容和现代感都有。

依赖方面保持极简,实际上核心功能全部用Android原生SDK就够了,我只加了一个AndroidX的appcompat和material库。RecyclerView依赖的是androidx.recyclerview:recyclerview,Material库提供FloatingActionButton和AlertDialog。不加其他第三方库,这种源码包的好处是:依赖越少,出诡异编译问题的概率越低,别人下载下来不需要等Gradle长时间拉一堆库。

工程结构按功能分包,顶层目录如下:

app/src/main/java/com/example/notepad/ ├── activity/ │ ├── MainActivity.java │ └── EditNoteActivity.java ├── adapter/ │ └── NoteAdapter.java ├── database/ │ ├── NoteDbHelper.java │ └── NoteDao.java ├── model/ │ └── Note.java └── utils/ └── TimeUtils.java

这个分包方式不是标准答案,但对我来说清晰了五年。activity放界面层,adapter放列表适配,database放数据库相关的Helper和DAO,model放实体,utils放通用工具。性能更好的分层架构方案很多,但面对一个练手或者交作业级别的项目,分层太多反而是负担。

2.2 工程迁移与导入的几个前提检查

源码包发出去以后最常收到的反馈是“我怎么打不开”“一堆红叉”。排查下来一半以上是环境问题。拿到这套源码包后,建议你先检查三样东西:

  1. Gradle JDK版本。新版Android Studio默认自带JDK17,但老项目用的Gradle版本如果太旧,可能要求JDK8或11。如果报Unsupported Java的错,优先调Android Studio里的Gradle JDK设置,而不是换IDE。
  2. Gradle版本与Android Gradle Plugin版本匹配。我用的AGP版本在gradle-wrapper.properties里能看,新人最常犯的错是直接把Android Studio自动生成的新项目Gradle文件覆盖过来,导致依赖版本不匹配。
  3. SDK Location。Android Studio打开后如果提示SDK找不到,检查local.properties文件里的sdk.dir路径是否正确指向本机SDK目录。

这三步确认完,工程基本就能丝滑导入。遇到过很多朋友卡在环境上,其实项目代码本身没毛病。

2.3 权限声明与AndroidManifest配置

备忘录APP不需要联网权限,不需要读取存储卡权限,在Android 6.0及以上的动态权限申请环节也能省掉。Manifest里面的配置非常干净:

<application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Notepad"> <activity android:name=".activity.EditNoteActivity" android:windowSoftInputMode="adjustResize" /> <activity android:name=".activity.MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>

windowSoftInputMode="adjustResize"这个属性容易被忽略,但实际体验影响巨大。没有它,EditText唤起软键盘时会把整个布局顶上去,保存按钮就被挤到键盘后面。设置为adjustResize后,系统会在键盘弹出时调整窗口大小,布局能自适应,用户才能一边打字一边看到保存按钮。

3. 数据库层的完整实现

3.1 SQLiteOpenHelper子类设计

数据库访问层我写成两个类,按职责拆开:NoteDbHelper负责数据库文件的创建、升级和连接管理,NoteDao负责与笔记表相关的增删改查SQL操作。这样做是让数据库连接的管理和业务操作互不干扰,测试和维护都好下手。

NoteDbHelper的完整实现如下:

public class NoteDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "notepad.db"; private static final int DB_VERSION = 1; private static volatile NoteDbHelper instance; private NoteDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } public static NoteDbHelper getInstance(Context context) { if (instance == null) { synchronized (NoteDbHelper.class) { if (instance == null) { instance = new NoteDbHelper(context.getApplicationContext()); } } } return instance; } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS note (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "title TEXT NOT NULL," + "content TEXT," + "create_time INTEGER NOT NULL," + "update_time INTEGER NOT NULL)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { // 此处做示例升级逻辑:比如新增 category 字段 db.execSQL("ALTER TABLE note ADD COLUMN category TEXT DEFAULT 'default'"); } } }

关于单例的双重检查锁,多说一句。SQLiteOpenHelper的构造函数本身不昂贵,真正耗时的是第一次getWritableDatabase时创建数据库文件。但如果不加单例,在MainActivity和EditNoteActivity之间频繁切换时,每次都new一个Helper实例,会导致多个数据库连接同时存在,某些场景下容易出现SQLiteDatabase object was closed的诡异报错。单例模式结合getApplicationContext还能避免Activity造成的内存泄漏,属于写好一次、省心一整年的操作。

3.2 增删改查SQL语句的封装细节

NoteDao相当于数据访问对象,所有跟note表打交道的SQL操作都收敛在这里。我实际封装了五个方法:insertNote、updateNote、deleteNote、queryAllNotes、queryNoteById。其中增删改都是返回布尔值,查询返回List。

public class NoteDao { private NoteDbHelper helper; public NoteDao() { helper = NoteDbHelper.getInstance(MyApplication.getContext()); } public boolean insertNote(Note note) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", note.getTitle()); values.put("content", note.getContent()); values.put("create_time", note.getCreateTime()); values.put("update_time", note.getUpdateTime()); long rowId = db.insert("note", null, values); return rowId != -1; } public boolean updateNote(Note note) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", note.getTitle()); values.put("content", note.getContent()); values.put("update_time", note.getUpdateTime()); int rows = db.update("note", values, "id = ?", new String[]{String.valueOf(note.getId())}); return rows > 0; } public boolean deleteNote(Long id) { SQLiteDatabase db = helper.getWritableDatabase(); int rows = db.delete("note", "id = ?", new String[]{String.valueOf(id)}); return rows > 0; } public List<Note> queryAllNotes() { List<Note> noteList = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query("note", null, null, null, null, null, "update_time DESC"); if (cursor != null) { while (cursor.moveToNext()) { Note note = new Note(); note.setId(cursor.getLong(cursor.getColumnIndexOrThrow("id"))); note.setTitle(cursor.getString(cursor.getColumnIndexOrThrow("title"))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow("content"))); note.setCreateTime(cursor.getLong(cursor.getColumnIndexOrThrow("create_time"))); note.setUpdateTime(cursor.getLong(cursor.getColumnIndexOrThrow("update_time"))); noteList.add(note); } cursor.close(); } return noteList; } public Note queryNoteById(Long id) { SQLiteDatabase db = helper.getReadableDatabase(); Note note = null; Cursor cursor = db.query("note", null, "id = ?", new String[]{String.valueOf(id)}, null, null, null); if (cursor != null && cursor.moveToFirst()) { note = new Note(); note.setId(cursor.getLong(cursor.getColumnIndexOrThrow("id"))); note.setTitle(cursor.getString(cursor.getColumnIndexOrThrow("title"))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow("content"))); note.setCreateTime(cursor.getLong(cursor.getColumnIndexOrThrow("create_time"))); note.setUpdateTime(cursor.getLong(cursor.getColumnIndexOrThrow("update_time"))); cursor.close(); } return note; } }

我在queryAllNotes里用了update_time DESC作为排序规则。这是产品层面的取舍:用户打开备忘录,第一时间应该看到最近更新的笔记,而不是创建最早的笔记。用updateTime排序能保证你改完一条旧笔记,它立刻出现在列表最上方,操作反馈非常直观。

Cursor获取列索引用的是getColumnIndexOrThrow而不是getColumnIndex,区别在于前者在列名不存在时会直接抛异常,方便在开发期就发现SQL和实体字段不一致的问题,而不是返回一个-1索引,然后getString(-1)抛个让人摸不着头脑的崩溃。

3.3 事务处理与批量删除的扩展点

基础增删改查做完之后,我特意在源码包里留了一个事务批处理的演示方法,源码包的意义要大于能跑就行。虽然当前界面上没有批量删除入口,但这个方法被注释掉了,写在NoteDao里,方便需要扩展的人参考。

public boolean deleteNotesInTransaction(List<Long> ids) { SQLiteDatabase db = helper.getWritableDatabase(); boolean result = false; db.beginTransaction(); try { for (Long id : ids) { db.delete("note", "id = ?", new String[]{String.valueOf(id)}); } db.setTransactionSuccessful(); result = true; } catch (Exception e) { e.printStackTrace(); } finally { db.endTransaction(); } return result; }

事务的好处不需要我多说,多个删除操作要么全部成功要么全部失败。这里真正的教训是:不要嵌套事务,也不要忘记在finally里调用endTransaction。源码包附带的注释里我写过一句话——你永远不希望用户点了删除五条笔记,结果删了前三条后手机没电,数据库留在半路状态。

实操心得:SQLite的事务开销很低,批量操作改成事务后耗时能下降一个量级。早期我循环一万条insert不加事务,跑了五十多秒;加上事务后,同一台测试机不到三秒。这差距是肉眼可见的。

4. 界面层与数据库的联动实现

4.1 主界面列表适配与数据刷新

MainActivity的onCreate里先初始化RecyclerView,设置LinearLayoutManager,再创建NoteAdapter,最后调用loadNotesData()从数据库查询数据并填充列表。这套流程是Android列表界面的固定套路,但有几个细节值得抠。

private void loadNotesData() { noteList = noteDao.queryAllNotes(); if (noteAdapter == null) { noteAdapter = new NoteAdapter(this, noteList); recyclerView.setAdapter(noteAdapter); } else { noteAdapter.setNoteList(noteList); noteAdapter.notifyDataSetChanged(); } handleEmptyView(); }

这里我特意让加载数据的方法既承担首次初始化也承担刷新功能,而不是刷新时重新new一个Adapter。如果每次刷新都重新new Adapter,表面上看起来没毛病,但列表滚动位置会回到顶部,用户体验就是“我刚滑到列表中间编辑了一条笔记,返回后列表跳到了第一条”,非常割裂。用同一个Adapter实例加notifyDataSetChanged,能把滚动位置保留在原地。

Adapter这边,我把点击事件跟长按事件都暴露出去,通过接口回调到Activity处理,不在Adapter内部弹窗。这样Adpater保持纯粹,只负责把数据渲染到View上,交互逻辑留在Activity里,代码读起来顺。

4.2 空数据状态的友好处理

空数据这块,很多新手会忽略,觉得数据库没数据就不管了。但一个备忘录APP如果刚安装后打开是白屏,用户第一反应是软件坏了。我在主界面的布局里放了一个空的TextView和一张简单图标,平时GONE隐藏,只有当noteList为空时visible显示出来。

private void handleEmptyView() { if (noteList == null || noteList.isEmpty()) { recyclerView.setVisibility(View.GONE); tvEmptyTip.setVisibility(View.VISIBLE); } else { recyclerView.setVisibility(View.VISIBLE); tvEmptyTip.setVisibility(View.GONE); } }

这条逻辑成本极低,但对整体印象的加成很明显。以前给一些项目做代码审查,经常看到有人把空状态做成一张没有文字提示的空白图,用户不知道是加载失败还是真的没数据。一个“还没有笔记,点右下角新建一条吧”的文案,加上轻量图标,比任何复杂的引导页都实在。

4.3 新增与编辑页面的数据回显

EditNoteActivity是整个项目里交互细节最密集的地方。新建和编辑两种模式共用同一个布局:EditText标题、多行EditText内容、保存按钮。区别只在onCreate里从Intent读取noteId,如果noteId不等于-1,说明是编辑模式,需要先调queryNoteById把数据回显到输入框。

long noteId = getIntent().getLongExtra("note_id", -1); if (noteId != -1) { Note note = noteDao.queryNoteById(noteId); if (note != null) { etTitle.setText(note.getTitle()); etContent.setText(note.getContent()); } }

回显有个坑:如果你在setText之后,光标的默认位置是在文本开头。用户想改标题末尾的字,点进去发现光标在前面,每次都要手动点一下末尾,这体验就不太好。解决办法是把选择位置设置到文本末尾:

etTitle.setSelection(etTitle.getText().length());

保存按钮的逻辑同样区分模式。新建模式下,new一个Note对象,设置当前时间为createTime和updateTime,调insertNote;编辑模式下,读取原Note对象,保留createTime不变,更新title、content和updateTime,调updateNote。时间字段的区分处理是数据质量的关键——如果编辑时把createTime也改了,后续如果做“按创建时间排序”或者“查看创建日期”,数据就全乱了。

4.4 长按菜单删除与二次确认

删除在交互上必须做二次确认,否则用户误触就丢数据。我在OnLongClickListener里弹了一个AlertDialog,选项是编辑和删除。选择删除时,再弹出一个Dialog提示“确定要删除这条笔记吗”,确认才真正调deleteNote。

@Override public void onLongClick(Note note) { String[] items = {"编辑", "删除"}; new AlertDialog.Builder(MainActivity.this) .setTitle(note.getTitle()) .setItems(items, (dialog, which) -> { if (which == 0) { Intent intent = new Intent(MainActivity.this, EditNoteActivity.class); intent.putExtra("note_id", note.getId()); startActivity(intent); } else if (which == 1) { confirmDelete(note); } }) .show(); } private void confirmDelete(Note note) { new AlertDialog.Builder(MainActivity.this) .setMessage("确定要删除这条笔记吗?") .setPositiveButton("删除", (dialog, which) -> { noteDao.deleteNote(note.getId()); loadNotesData(); }) .setNegativeButton("取消", null) .show(); }

为什么不用直接调删除接口的Snackbar带撤销功能?我承认Snackbar的Undo体验更现代,但这套源码包的定位更偏基础和稳定,AlertDialog是每个Android初学者都能秒懂的标准交互。你要扩展Undo,代码里留了接口,替换成本不高。

4.5 生命周期对数据同步的影响

MainActivity的onResume里调用loadNotesData(),是我反复考量后的选择。一开始我把刷新放在EditNoteActivity的finish之后,通过startActivityForResult返回结果再刷新,代码看起来严谨,但分支特别多:按返回键退出时要不要刷新?保存成功刷新?没保存但改了内容呢?

换成onResume刷新是省心方案:不管用户怎么退出编辑页,MainActivity从后台回到前台、从编辑页返回、甚至从最近任务列表切回来,都会执行刷新。数据永远跟数据库同步。代价是如果数据量极大,每次onResume全量查询会有那么一点性能开销。但备忘录场景下,用户笔记撑死几百条,一次全量查询延迟在毫秒级别,这点代价换来的是代码逻辑大幅简化,划算。

5. 源码包结构、集成方式与数据库升级

5.1 源码包的完整结构说明

整套源码包拿到手以后,目录结构会是这样:

Notepad/ ├── README.md ├── app/ │ ├── build.gradle │ └── src/main/ │ ├── AndroidManifest.xml │ ├── java/com/example/notepad/ │ │ ├── activity/ ... │ │ ├── adapter/ ... │ │ ├── database/ ... │ │ ├── model/ ... │ │ └── utils/ ... │ └── res/ │ ├── layout/ ... │ ├── values/ │ └── drawable/ ... ├── build.gradle ├── gradle.properties └── gradle/wrapper/...

初学者拿到项目后,不要上来就盯着MainActivity一行行读,我建议的阅读顺序是:model/Note.java先看数据结构,database/NoteDbHelper.java看表结构和升级逻辑,database/NoteDao.java看SQL操作实现,再到activity里面看调用关系。跟着数据流的走向去理解代码,比按文件列表漫无目的地划拉效率高得多。

README.md里我写了完整的运行环境要求、导入步骤、功能说明和常见问题,包括Android Studio版本建议、模拟器设置、数据库文件存放位置等。这一块很多源码包都不重视,但从实际反馈来看,一个好README能挡掉至少七成的无效提问。

5.2 把源码包功能集成进自己项目的两种方式

很多朋友下载这套源码包不是为了直接用,而是想移植到自己项目里。我把常用的两种集成方式都写在源码包的文档里。

第一种是整工程替换:如果你是新起步,直接基于这套源码包改包名、改应用名、改logo,然后把新增功能的代码添进去。改包名注意不要只改目录名,要连Java文件顶部的package声明和import一起替换,最稳妥的方式是用Android Studio的Refactor -> Rename功能。

第二种是拷贝核心代码到已有项目:把model包、database包、utils包整体复制过去,再复制note_item.xml和编辑页面布局,最后在AndroidManifest里注册EditNoteActivity。这种方式的坑在于资源文件名不能冲突,如果你原来的项目已经有note_item.xml,就需要重命名。另外MyApplication这个类要不要带过去?如果项目里没有自定义Application,就把它带过去并在Manifest里声明;如果已经有了,把初始化的代码合并进去。

无论哪种方式,都强烈建议先把数据库表结构想清楚再改。源码包里的note表字段是比较普适的,但如果你要加“收藏”字段、加“分类”字段,最好在集成阶段就一起设计,不要等上线了才通过onUpgrade打补丁。

5.3 数据库升级策略与数据保全

数据库升级是源码包里我专门写了一个示例的重要原因。上面NoteDbHelper的onUpgrade里,example是新增category列。实际中的升级流程注意两点:

  1. 一定不要用drop table再重建。这样最简单,但用户已有数据全没了。生产环境这么做会被骂到怀疑人生。正确的升级写法是先判断oldVersion,小于对应版本就ALTER TABLE加列,或者建新表做数据迁移。
  2. 每次修改表结构,都要把DB_VERSION + 1。DB_VERSION不增加,onUpgrade根本不会被触发,改再多的表结构也没用,这是无数人踩过的低级坑。

数据保全的另外一个层面是备份。这套源码包没有做数据导出,但我在README里提供了adb调试期间导出数据库的命令:

adb exec-out run-as com.example.notepad cat /data/data/com.example.notepad/databases/notepad.db > notepad_backup.db

调试阶段数据库一旦写坏,恢复成本很低,多备份一条路总归没错。

6. 常见问题与排查实录

6.1 SQLiteDatabase对象未关闭导致的泄漏

这是所有SQLite初学者最容易踩的坑,也是代码审查里必被抓的问题。此前有朋友拿他写的版本给我看,主界面每次刷新都new一个新的NoteDao,每个方法内部打开数据库后不关闭,跑一个上午,Logcat里开始疯狂输出leak日志,后面直接数据库崩溃。

正确的做法就是我前面说的:让NoteDbHelper保持单例,getWritableDatabase返回同一个SQLiteDatabase实例,用完调用close()或依赖事务自动关闭。但这里有个细节:如果你用单例Helper,每次在Dao方法里主动close数据库,也会有问题,因为其他地方可能正在用同一个实例。推荐的写法是,查询方法里的Cursor要确保关闭,数据库本身的连接交给SQLiteOpenHelper统一管理,实际项目中通常不需要频繁关闭。

我写代码时给团队立过一个规矩:谁写new SQLiteDatabase谁负责close,如果用的是helper.getWritableDatabase,就不要轻易调用close。这个约定到目前为止没出过大乱子。

6.2 增删改后列表不刷新

新手最常见的第二个问题:数据库数据明明变了,界面却还停留在旧状态。这个问题有75%出在“保存成功后直接按返回键而不重新查询数据”,还有20%是“用了noteAdapter.notifyItemInserted但位置算错”,剩下的5%是“数据更新到另一个数据库文件了”。

这套源码包用onResume刷新的方案,直接绕开了大部分刷新时机问题。需要强调的是,如果你要扩展成搜索功能,不能在onResume里直接全量刷新然后重新过滤,那会很度消耗性能。搜索功能的正确姿势是单独写一个带关键词参数的查询方法,比如:

public List<Note> queryNotesByKeyword(String keyword) { SQLiteDatabase db = helper.getReadableDatabase(); String where = "title LIKE ? OR content LIKE ?"; String[] args = {"%" + keyword + "%", "%" + keyword + "%"}; return queryNoteList(db, where, args); }

6.3 中文乱码与编码问题

SQLite本身支持UTF-8,Android的Java字符串默认也是UTF-16,两者之间转换由系统自动处理,正常场景没有乱码问题。真正导致乱码的往往出现在导出数据或外部编辑数据库文件时:用Windows记事本打开db文件里的中文直接乱码,用navicat等工具导入SQL文件时字符集没选UTF-8,页面代码里没有统一设置。

源码包里所有Activity和资源文件都明确指定UTF-8编码,布局文件里设置EditText的inputType为text,不做强限制。如果你在集成时遇到了乱码,先检查IDE的文件编码设置,其次是导出工具字符集,代码里基本不会有大坑。

6.4 数据库版本不一致引发崩溃

一次实际反馈里,用户下了源码包跑起来,打开APP直接闪退,Logcat显示no such column: category。排查后发现,他之前跑过一版旧代码,数据库文件已经存在,表结构是旧的。新的代码把DB_VERSION升到了2,理论上该走onUpgrade,但实际情况是他的新代码里建表语句直接写了category字段,旧的数据库文件里没有这一列,而SQLiteOpenHelper认为版本号没变就不会升级。

这个问题坑过很多人。我的建议是:调试阶段如果数据库结构还在频繁调整,可以直接卸载APP重装,或者用adb uninstall com.example.notepad清掉数据;等结构稳定了再统一走onUpgrade。生产环境则必须在发布前反复测试升级路径,确保从老版本的任意状态都能平滑过渡,绝不能指望用户卸载重装。

写在最后的一点个人经验

做完这套源码包再回头想,记事本APP的难点从来不是那几行SQL,而是数据库连接管理、界面刷新时机、空状态处理、升级策略这些边边角角。这些东西教程里很少详细讲,但正是它们在决定一个项目能不能真正交付。

如果你打算拿这套源码包做二次开发,我会建议下一个版本优先做两件事:一是给数据库加一个回收站表,把删除操作改成软删除,data_flag字段区分是否已删除,这样用户体验直接上一个档次;二是引入搜索功能,把小规模的LIKE查询加上,搜索框在现有界面里加一个入口非常容易。这两个方向都不需要我们大改架构,卡在现有代码上扩展就行。

我实际测试这套源码包时跑了旧手机和新手机两套测试环境,数据库操作在几十条笔记量级下都是即点即响应,完全不需要引入复杂的异步框架。如果将来笔记数量可能涨到几千甚至上万条,则需要考虑把查询放到子线程或者用Room配合Flow。但那是后话,基础扎实了,迁移成本并不高。

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

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

立即咨询