☰
Android记事本APP开发实战:SQLite存储与增删改查完整实现
2026/10/14 18:13:51 网站建设 项目流程

记事本备忘录APP这个题目,放在Android开发里算得上是一道“经典中的经典”。别觉得它简单,我自己前前后后帮人改过的记事本项目至少十几个了,每次都能发现新的问题。这玩意儿往往是最适合拿来练手的项目,因为它麻雀虽小,但五脏俱全——要管界面、要管数据、要管交互流程,还要处理各种边界情况。用Android Studio和SQLite来实现一套完整的记事本APP,表面上是增删改查,实际上是把Android开发最核心的几个知识点全串起来了。

这套源码包我做的是完整可运行的版本,包含数据库设计、CRUD操作、列表展示、编辑页面、时间戳管理这些模块。不管你是刚学Android不久的新手,还是想要一份干净代码做课程设计的在校生,又或者是想快速搭建个轻量备忘录工具的在职开发者,这套实现方案都有值得参考的地方。

1. 需求拆解与技术选型思路

1.1 记事本APP的核心痛点是什么

记事本APP最核心的需求其实就三个字:存得住。用户写下一条笔记,最怕的就是哪天打开APP发现东西没了。所以整个项目的技术选型,第一要务就是围绕“数据可靠”来做决策。

去掉花里胡哨的功能,记事本的本质需求其实非常清晰:

  • 记录文本内容:用户输入的文字要能保存下来
  • 查看已有笔记:以列表形式展示所有已保存的笔记
  • 修改笔记内容:对已有笔记进行编辑
  • 删除不需要的笔记:清理无用数据
  • 时间信息:知道每条笔记是什么时候创建、什么时候改的

这五条需求,翻译成技术语言,就是一张数据表加上四个操作函数:插入、查询、更新、删除。数据库类的增删改查虽然听着基础,但是要把每个操作都做到严谨,还得处理各种边界情况,这里面的细节相当多。

1.2 为什么选择SQLite而不是其他存储方案

Android系统提供了SharedPreferences、文件存储、SQLite数据库、Room数据库这几种常见的数据持久化方式,还有云存储方案。记事本这场景,我实测下来SQLite是最合适的。

直接说结论,几类方案都踩过坑之后我总结的对比:

存储方案优点缺点本项目适配度
SharedPreferences使用简单,K-V结构只适合存配置类小数据,存列表数据需要序列化整个集合极低
文件存储可存任意格式自己解析内容,没有结构化查询能力中
SQLite轻量级嵌入式数据库,结构化查询,无需额外服务需要手写SQL语句高
Room官方ORM框架,编译期校验框架封装程度高,需理解注解与数据映射中高

SharedPreferences存记事本这种多条数据的场景,越用越难受。你得把整个List序列化成一个长字符串塞进去,改一条就得重写全部,数据一多性能就崩了。

文件存储倒是能一条笔记一个文件,但如果要按时间排序、模糊搜索这类操作,就得自己写逻辑去遍历文件,效率低且容易漏。

SQLite的本质就是把你本地的数据组织成“行和列”的形式,你可以用标准的SQL语句进行查询过滤排序。这就是典型的“杀鸡用牛刀但刚好合适”——SQLite本身就是嵌入式的,不需要单独跑服务进程,对Android应用来说损耗很小。

Room从技术角度讲是更现代的选择,但对想通过项目理解数据存取底层原理的学习者来说,直接上手SQLite能帮你把“数据到底是怎么存进去的”这件事看得更明白。

1.3 功能边界与版本规划

我把这个记事本项目的功能范围定在“单表、双页面、四操作”这个最小的闭环上:

  • 单表:核心数据表duji_notes,字段包含id、标题、正文内容、创建时间、更新时间
  • 双页面:主列表页(显示全部笔记)+ 编辑页(新建/修改共用)
  • 四操作:新增笔记、查看/编辑笔记、删除笔记、查询全部笔记

这套设计刻意不做多余的东西,比如图片上传、语音笔记这些一概不加。原因很简单:先把主链路跑通,让数据流变得清晰可见,之后再在稳定核心的基础上做扩展,这样对学习和实际使用都有价值。

开发环境我用的是当前主流的稳定配置:Android Studio 2023.2版本以上,JDK 17,targetSdk 33,minSdk 21(覆盖Android 5.0到最新的设备范围)。编程语言用Java,这是出于更高的兼容性和更直观贴合SQLite操作语法的考虑,后面我会具体解释。

2. 数据库层的设计细节与实现

2.1 数据表结构的字段规划

数据库表结构是整个项目的基石,这块设计得好不好,直接决定后面代码是舒服还是别扭。我这个项目的数据库类定义在主包下的db目录里,核心表结构如下:

CREATE TABLE note_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, updated_time INTEGER NOT NULL, created_time INTEGER NOT NULL DEFAULT (strftime('%s','now')) );

为什么用这五个字段,用途分别是什么:

  • id:自增主键。每个笔记的唯一标识,编辑和删除的时候靠它定位具体哪一条记录
  • title:笔记标题。列表页要展示标题,所以必须单独设一列
  • content:笔记正文内容。TEXT类型支持长文本
  • updated_time:更新时间,用Unix时间戳存储
  • created_time:创建时间

两个关键设计决策我解释一下:

第一,时间字段为什么用INTEGER存Unix时间戳,而不是用TEXT直接存”2024-01-15 10:30:00”。存时间戳的好处是排序非常方便,按更新时间倒序就是一条ORDER BY updated_time DESC的SQL语句,不用处理字符串比较的问题。展示的时候再用DateFormat转换成人能看懂的格式就行,这是从性能到可维护性都更优的做法。

第二,title为什么NOT NULL。实际开发中我遇到过创建笔记时只写了标题没写入内容的情况,如果内容字段允许为空,列表页就要处理大量空数据。所以标题设为必填项,正文内容允许为空。

2.2 SQLiteOpenHelper的创建与版本管理

SQLiteOpenHelper是Android封装的数据库辅助类,它处理了“数据库什么时候创建”“数据库版本升级时怎么办”这两个关键问题。我的实现里有一个DataBaseHelper类,完整代码结构如下:

public class DataBaseHelper extends SQLiteOpenHelper { private static final String DB_NAME = "notes.db"; private static final int DB_VERSION = 1; public static final String TABLE_NOTE = "note_items"; public static final String COLUMN_ID = "id"; public static final String COLUMN_TITLE = "title"; public static final String COLUMN_CONTENT = "content"; public static final String COLUMN_UPDATED_TIME = "updated_time"; public static final String COLUMN_CREATED_TIME = "created_time"; private static final String CREATE_TABLE_NOTE = "CREATE TABLE " + TABLE_NOTE + " (" + COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + COLUMN_TITLE + " TEXT NOT NULL, " + COLUMN_CONTENT + " TEXT, " + COLUMN_UPDATED_TIME + " INTEGER NOT NULL, " + COLUMN_CREATED_TIME + " INTEGER NOT NULL DEFAULT (strftime('%s','now'))" + ")"; public DataBaseHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_TABLE_NOTE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单的处理方式是删除重建,但会丢失数据 // 生产环境应该做表结构迁移 db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTE); onCreate(db); } }

这里特别要说的是DB_VERSION这个常量。它的作用是数据库的版本号,每次你修改了CREATE TABLE的语句,或者新增了表,就必须把DB_VERSION加1,系统会检测到版本变化并调用onUpgrade方法。

onUpgrade里直接DROP TABLE再重新创建,这只适合开发阶段用。如果是一个正在线上运行的产品,用户手机里已经有了数据,你DROP掉就是删库跑路了。正规的做法是用ALTER语句添加缺失的列、创建新表,甚至做数据迁移。我在这个项目里保留了简单的升级方式,是因为它是学习项目,后续讲扩展的时候会深入说这个迁移问题。

还要提醒一个很常见的坑:SQLiteOpenHelper的构造函数里,如果数据库文件不存在,系统会走super的onCreate逻辑创建库和表。但如果数据库文件存在,并且版本号没变,那onCreate就不会执行。很多新手在模拟器上改了表结构,结果发现运行后还是旧表,就是因为忘了把版本号+1。

2.3 增删改查的完整实现与参数说明

数据库的增删改查,我用了一个NoteDao类(DAO,Data Access Object,数据访问对象)专门封装操作,这样Activity和Fragment就不直接跟SQL语句打交道了,代码分层更清晰。

先看新增笔记的方法:

public long insertNote(String title, String content) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(COLUMN_TITLE, title); values.put(COLUMN_CONTENT, content); values.put(COLUMN_UPDATED_TIME, System.currentTimeMillis() / 1000); // created_time有默认值,如果不传就自动使用当前时间 long rowId = db.insert(TABLE_NOTE, null, values); db.close(); return rowId; }

ContentValues本质上就是一个键值对集合,你往里面放数据,数据库会把它们匹配到对应的列名上。使用db.insert方法而不是手动拼SQL字符串,原因是insert方法内部做了SQL语句的构造和参数化绑定,几个大坑都能避免掉。

什么坑?比如内容里有英文的单引号',如果你用拼接SQL的方式写:INSERT INTO note_items (content) VALUES('I'm a student'),这条SQL语法就是错的,因为单引号把SQL截断了。insert方法会自动处理这些特殊字符。

再看删除笔记,这里有个小陷阱必须说:

public int deleteNoteById(long id) { SQLiteDatabase db = dbHelper.getWritableDatabase(); int rows = db.delete(TABLE_NOTE, COLUMN_ID + " = ?", new String[]{String.valueOf(id)}); db.close(); return rows; }

delete方法的第二个和第三个参数构成一个“条件语句”,中间的?是占位符,第三个参数里的字符串数组会按顺序替换掉这些占位符。你要格外注意,这里的数组中的每个元素都会被当成字符串处理,所以即使id是long类型,也必须转成String类型传进去。

这里我踩过一个很经典的坑,之前为了图省事,用过这样的写法:

db.delete(TABLE_NOTE, COLUMN_ID + " = " + id, null);

这写法也不是完全不行,单条记录试验基本能成。但只要id值是从不可信来源拿到的,你就在本地数据库上开了一个SQL注入的口子。用参数绑定?占位符,把这些全规避了。

查询全部笔记,按更新时间倒序排列:

public List<NoteEntity> queryAllNotes() { List<NoteEntity> noteList = new ArrayList<>(); SQLiteDatabase db = dbHelper.getReadableDatabase(); // orderBy参数直接传排序规则,不用拼接 Cursor cursor = db.query( TABLE_NOTE, null, // 查询所有列 null, // 无筛选条件 null, null, null, COLUMN_UPDATED_TIME + " DESC" // 按更新时间倒序 ); while (cursor.moveToNext()) { NoteEntity entity = new NoteEntity(); entity.setId(cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_ID))); entity.setTitle(cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_TITLE))); entity.setContent(cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_CONTENT))); entity.setUpdatedTime(cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_UPDATED_TIME))); entity.setCreatedTime(cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_CREATED_TIME))); noteList.add(entity); } // 必须关闭cursor和数据库 cursor.close(); db.close(); return noteList; }

query方法有七个参数,很多人记不住,其实本质上就是一句话:我要查哪张表(table),查哪几列(columns),满足什么条件(selection),条件里的参数是什么(selectionArgs),按什么分组(groupBy),按什么过滤(having),按什么排序(orderBy)。

Cursor游标这个对象特别强调一点,程序对Cursor用完必须调用close()方法。Cursor里保存的是数据读取状态,如果不关,长时间运行会导致内存泄漏,尤其在循环遍历完以后,这个问题很隐蔽。

更新笔记的实现逻辑也不复杂:

public int updateNote(long id, String newTitle, String newContent) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(COLUMN_TITLE, newTitle); values.put(COLUMN_CONTENT, newContent); values.put(COLUMN_UPDATED_TIME, System.currentTimeMillis() / 1000); int rows = db.update(TABLE_NOTE, values, COLUMN_ID + " = ?", new String[]{String.valueOf(id)}); db.close(); return rows; }

更新的核心逻辑就是:传入新的标题内容,重置更新时间,用id定位到要修改的那一行。返回的rows代表受影响的行数,如果返回0,说明这个id对应的记录不存在了,前端页面需要做出相应处理。

2.4 getReadableDatabase和getWritableDatabase的区别

这两个方法经常被放在一起说,但它们的区别值得单独讲。从名字上理解,一个是拿读数据库的实例,一个是拿写数据库的实例。

在绝大多数情况下,SQLiteOpenHelper内部对这两种模式的处理是一样的——都是返回同一个可读写的数据库实例。特殊的地方在于:当磁盘空间满了或者数据库只读时,getReadableDatabase可能返回一个只读实例,而getWritableDatabase会直接抛出异常。

所以我的使用习惯是:对于只需查询的场景,用getReadableDatabase,语义上更清晰;对于需要写操作的场景,用getWritableDatabase。两者混用也不会出错,但养成区分语义的编码习惯总是好的。

3. 项目搭建与UI层实现

3.1 用ListView还是RecyclerView

列表页是记事本的主界面,用户打开APP第一眼看到的就是它。Android里有ListView和RecyclerView这两种列表控件,我最终选用的是ListView。

为什么这么选?我承认RecyclerView性能更好、设计更现代,但ListView对新手极其友好,代码量少得多,而且数据量在几十条笔记的场景下,ListView的性能瓶颈根本不会暴露。搞清楚适配器模式和数据绑定流程,比盲目追新框架更重要。

后面要扩展成RecyclerView也非常容易,核心的适配器逻辑是一模一样的,无非是把ViewHolder写法改一下、LayoutManager设置一下,这个我后续会讲。

ListView的适配器写法是这样的:

public class NoteAdapter extends BaseAdapter { private Context context; private List<NoteEntity> noteList; private LayoutInflater inflater; public NoteAdapter(Context context, List<NoteEntity> noteList) { this.context = context; this.noteList = noteList; this.inflater = LayoutInflater.from(context); } @Override public int getCount() { return noteList == null ? 0 : noteList.size(); } @Override public Object getItem(int position) { return noteList.get(position); } @Override public long getItemId(int position) { return noteList.get(position).getId(); } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; // convertView的复用机制,是列表流畅度的关键 if (convertView == null) { convertView = inflater.inflate(R.layout.item_note, parent, false); holder = new ViewHolder(); holder.itemTitle = convertView.findViewById(R.id.tv_item_title); holder.itemContent = convertView.findViewById(R.id.tv_item_content); holder.itemTime = convertView.findViewById(R.id.tv_item_time); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } NoteEntity note = noteList.get(position); holder.itemTitle.setText(note.getTitle()); holder.itemContent.setText(note.getContent()); // 时间戳格式化,显示成更直观的时间 holder.itemTime.setText(formatDate(note.getUpdatedTime())); return convertView; } static class ViewHolder { TextView itemTitle; TextView itemContent; TextView itemTime; } }

getView方法是列表性能的核心。Android在滑动列表时,item的View会被反复创建和回收。convertView就是那个被回收的旧View,如果为空就创建新的,不为空就复用旧的,避免每次滑动都重新inflate布局。ViewHolder的作用是缓存item里每个控件的引用,避免每次都findViewById一遍。这两个小优化配合起来,列表就能滑得很流畅。

3.2 编辑页面的UI设计与输入处理

编辑页是记事本APP的核心操作区。我设计的编辑页有以下几个元素:标题输入框(EditText)、正文输入框(EditText)、保存按钮、删除按钮。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <EditText android:id="@+id/et_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入标题" android:textSize="18sp" android:textStyle="bold" /> <EditText android:id="@+id/et_content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:hint="请输入内容" android:textSize="15sp" android:gravity="top" android:inputType="textMultiLine" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="end"> <Button android:id="@+id/btn_save" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="保存" /> <Button android:id="@+id/btn_delete" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="删除" /> </LinearLayout> </LinearLayout>

布局文件里有几个细节要提醒。

正文EditText的layout_height设置为0dp,同时layout_weight设为1,这是Android布局里典型的“剩余空间填充”写法,作用是不管屏幕多大,正文输入框都会占满标题和按钮之外的剩余空间。如果你直接把高度设为wrap_content,小屏上内容一多,按钮会被挤到屏幕外面。

正文输入框一定要设置android:gravity="top",否则在开启多行输入时,光标会垂直居中,输入多行内容后视觉上非常奇怪。

android:inputType="textMultiLine"允许用户输入换行符,如果不加,键盘上的换行键会直接触发输入法动作而不会换行,这个对记事本来说是不可接受的。

3.3 页面跳转与数据传递机制

页面间跳转和数据传递,我的方案是Intent + Bundle。主列表页MainActivity和编辑页EditActivity之间的跳转有两个场景:

场景一:新建笔记。用户点右下角的加号按钮,跳转到EditActivity,不携带任何数据。页面标题栏显示“新建笔记”,保存时调用的是insertNote方法。

场景二:点击已有笔记。用户在列表里点击某一项,跳转到EditActivity,同时通过Intent携带noteId、title、content这几个数据。页面标题栏显示“编辑笔记”,保存时调用updateNote方法。

Intent传数据的代码也很直接:

// 跳转到编辑页(新建) Intent intent = new Intent(MainActivity.this, EditActivity.class); startActivity(intent); // 跳转到编辑页(编辑已有笔记) Intent intent = new Intent(MainActivity.this, EditActivity.class); intent.putExtra("note_id", noteEntity.getId()); intent.putExtra("note_title", noteEntity.getTitle()); intent.putExtra("note_content", noteEntity.getContent()); startActivity(intent);

EditActivity里接收数据的方式:

long noteId = getIntent().getLongExtra("note_id", -1); String noteTitle = getIntent().getStringExtra("note_title"); String noteContent = getIntent().getStringExtra("note_content"); // noteId == -1 表示新建模式 if (noteId == -1) { // 新建模式 etTitle.setText(""); etContent.setText(""); } else { // 编辑模式 etTitle.setText(noteTitle); etContent.setText(noteContent); }

判断当前是新建还是编辑模式,我用的是noteId这个值。新建时没有传note_id,默认就取-1,因为正常数据库自增主键不会出现负数。

这里有个容易被忽视的细节:在编辑模式加载已有数据时,要把光标放到内容末尾,方便用户直接接着写。做法是:

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

如果不设置焦点,用户进入页面后还需要先点一下输入框才能打字,多了这一步就很影响体验。

4. 完整操作流程与核心功能实现

4.1 从新建笔记到成功保存的完整链路

整个项目的核心功能链路,我用一条实际操作的完整路径来说明。假设用户打开APP,看到列表页有一片空白,他点了右下角的“新建笔记”按钮。

第一步,浮空按钮的点击事件触发跳转,代码在MainActivity中:

fabAdd.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Intent intent = new Intent(MainActivity.this, EditActivity.class); startActivityForResult(intent, REQUEST_CODE_ADD_NOTE); } });

第二步,EditActivity启动后,onCreate方法里把所有view实例化和事件绑定做好。用户会看到标题栏显示的是“新建笔记”,输入框一个是空的,他就可以直接打字。

第三步,用户输入完标题和内容后点击“保存”。保存按钮的点击事件处理逻辑是:

btnSave.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { String title = etTitle.getText().toString().trim(); String content = etContent.getText().toString().trim(); if (TextUtils.isEmpty(title)) { Toast.makeText(EditActivity.this, "标题不能为空", Toast.LENGTH_SHORT).show(); return; } NoteDao noteDao = new NoteDao(EditActivity.this); if (noteId == -1) { // 新建模式,插入数据库 long newRowId = noteDao.insertNote(title, content); if (newRowId != -1) { Toast.makeText(EditActivity.this, "保存成功", Toast.LENGTH_SHORT).show(); setResult(RESULT_OK); finish(); } else { Toast.makeText(EditActivity.this, "保存失败", Toast.LENGTH_SHORT).show(); } } else { // 编辑模式,更新数据库 int rows = noteDao.updateNote(noteId, title, content); if (rows > 0) { Toast.makeText(EditActivity.this, "修改成功", Toast.LENGTH_SHORT).show(); setResult(RESULT_OK); finish(); } else { Toast.makeText(EditActivity.this, "修改失败", Toast.LENGTH_SHORT).show(); } } } });

第四步,保存成功后调用finish()关闭编辑页,通过setResult(RESULT_OK)通知列表页刷新数据。

这个流程中有一个关键设计:调用的是startActivityForResult而不是startActivity,目的是让MainActivity能接收到编辑页的保存结果,然后执行刷新操作。

MainActivity的刷新逻辑写在onActivityResult里:

@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode == RESULT_OK) { refreshNoteList(); // 重新从数据库查询,更新ListView数据源 } }

refreshNoteList方法做的事情很简单——重新查一次数据库,把新的数据列表设置给适配器,然后调用notifyDataSetChanged()让ListView刷新界面。

private void refreshNoteList() { NoteDao noteDao = new NoteDao(MainActivity.this); List<NoteEntity> newList = noteDao.queryAllNotes(); noteAdapter.setNoteList(newList); noteAdapter.notifyDataSetChanged(); }

4.2 删除操作的交互设计

删除操作在编辑页和列表页都可以触发。

编辑页的删除按钮,为了安全需要一个确认弹窗,不然用户误触就删数据了。我在项目里用的是AlertDialog:

btnDelete.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (noteId == -1) { Toast.makeText(EditActivity.this, "笔记尚未保存", Toast.LENGTH_SHORT).show(); return; } new AlertDialog.Builder(EditActivity.this) .setTitle("删除确认") .setMessage("确定要删除这条笔记吗?此操作不可恢复。") .setPositiveButton("删除", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { NoteDao noteDao = new NoteDao(EditActivity.this); int rows = noteDao.deleteNoteById(noteId); if (rows > 0) { Toast.makeText(EditActivity.this, "删除成功", Toast.LENGTH_SHORT).show(); setResult(RESULT_OK); finish(); } else { Toast.makeText(EditActivity.this, "删除失败", Toast.LENGTH_SHORT).show(); } } }) .setNegativeButton("取消", null) .show(); } });

列表页的长按删除,我用了ListView的setOnItemLongClickListener。长按某个条目时弹出确认对话框,确认后删除该条数据并刷新列表。

4.3 列表项的时间格式化处理

数据库里存的是Unix时间戳(秒),需要转换成用户看得懂的时间格式。我封装了一个工具方法:

public static String formatDate(long timestampSeconds) { // 注意时间戳单位是秒,Java的Date需要毫秒,所以乘1000 Date date = new Date(timestampSeconds * 1000); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); return sdf.format(date); }

这个格式化里最容易出的问题就是单位。SQLite里我存的是秒级时间戳,System.currentTimeMillis()返回的是毫秒级。如果搞混了,你会在界面上看到一个1970年1月的时间,非常显眼又很尴尬。

5. 实战排坑:高频问题的定位与解决

5.1 列表更新不显示的三种情况

我处理过最多的求助就是“明明数据库里有数据,但列表页显示空白”或“保存完回到列表页,笔记没变”。排查这类问题基本就三个方向:

第一种,适配器没有通知数据源发生变化。修改的List数据后必须调用notifyDataSetChanged(),这个方法是告诉ListView“数据变了,请重新绘制”,不调用它ListView不会自己感知变化。

第二种,获取数据库实例的Context不对。在适配器或者DAO里传入的Context,我建议统一传Activity的Context而不是getApplicationContext()。这两者在绝大多数场景可以互换,但在涉及主题资源、window相关操作时会有区别。

第三种,startActivityForResult用错成了startActivity,导致返回列表页时onActivityResult不执行,刷新逻辑根本不会跑。这个错误的表现非常迷惑:数据确实存到数据库了,但UI上看起来像没保存一样。

5.2 SQLite数据库锁与并发问题

这里分享一个我在多设备同步测试时遇到的情况。当同时打开多个编辑器实例,或者程序里几个线程同时操作数据库,会出现SQLiteDatabaseLockedException异常。原因是SQLite在写操作时会锁定整个数据库文件,另一个写操作必须等待前一个完成。

解决方案有两条路可以走:

第一,所有数据库操作都在主线程的同一实例上串行执行。这是最简单的做法,记事本项目数据量小,完全够用。

第二,引入事务处理。比如要批量插入多条笔记,用事务可以把多次写入打包成一批,中途出错还可以回滚。开启事务的核心逻辑:

SQLiteDatabase db = dbHelper.getWritableDatabase(); db.beginTransaction(); try { for (NoteEntity note : batchNotes) { ContentValues values = new ContentValues(); values.put(COLUMN_TITLE, note.getTitle()); values.put(COLUMN_CONTENT, note.getContent()); values.put(COLUMN_UPDATED_TIME, note.getUpdatedTime()); db.insert(TABLE_NOTE, null, values); } db.setTransactionSuccessful(); // 标记事务成功 } finally { db.endTransaction(); // 结束事务,异常时自动回滚 }

beginTransaction和endTransaction之间的语句,只有调用了setTransactionSuccessful才会真正提交,否则全部回滚。这个机制用在“批量导入、批量删除”这些场景,既保证一致又能提升性能。

5.3 软键盘遮挡编辑框的处理方法

编辑页的使用场景里,软键盘弹出来把下方按钮挡住,是高频尴尬体验。我的处理方式有两种:

一种是在AndroidManifest.xml中给EditActivity配置windowSoftInputMode:

<activity android:name=".EditActivity" android:windowSoftInputMode="adjustResize|stateHidden" />

adjustResize让布局可调整尺寸,软键盘弹出时会压缩布局空间,而不是遮挡内容。stateHidden让页面打开时键盘默认隐藏。

另一种是在编辑页根布局使用ScrollView包裹输入区域,这样即使键盘弹出来,用户也可以滚动到被挡住的位置手动查看。

两种方案里我更推荐第一种,因为adjustResize对用户来说更自然,键盘弹出时输入框会自动上移到可见区域。

5.4 数据库版本升级导致的数据丢失

前面提到过,开发阶段直接DROP后重建表,数据丢了也无所谓。但如果你做的项目要给别人用,一开始就有数据积累的用户一旦升级APP,你会面临所有数据一夜清零的问题。

要避免这个,就得用“表结构迁移”的方案。比如要给note_items表增加一个“标签”字段:

@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE " + TABLE_NOTE + " ADD COLUMN tag TEXT"); } }

每个版本新加的结构变更,都放在对应的if块里,这样任何版本的用户升级后都能平滑过渡到最新结构,数据一条都不会丢。

实际操作中的体会是,版本号一旦发布出去就不能改了,所以写升级逻辑的时候要用oldVersion的判断条件,而不是想当然地以为所有用户都在同一个版本上。

6. 项目扩展方向与关键教训总结

6.1 从记事本到完整备忘录的扩展路径

这套记事本源码包的扩展余地非常大。我梳理了几个直接可以动手的方向,按性价比从高到低排序:

第一,搜索功能。当前的queryAllNotes方法加一个带关键词的版本,用SQL的LIKE语句模糊匹配标题和内容。这是SQLite最擅长的操作,加一个搜索框就能搞定。

public List<NoteEntity> searchNotes(String keyword) { List<NoteEntity> result = new ArrayList<>(); SQLiteDatabase db = dbHelper.getReadableDatabase(); String selection = COLUMN_TITLE + " LIKE ? OR " + COLUMN_CONTENT + " LIKE ?"; String[] selectionArgs = new String[]{"%" + keyword + "%", "%" + keyword + "%"}; Cursor cursor = db.query(TABLE_NOTE, null, selection, selectionArgs, null, null, COLUMN_UPDATED_TIME + " DESC"); // 遍历cursor的逻辑和queryAllNotes里一致 return result; }

第二,导出与备份。把SQLite数据库文件直接拷贝到SD卡或应用外部存储目录,可以做一个“备份到文件”和“从文件恢复”的功能。本质上就是文件读写。

第三,分类与标签。给表加一个tag字段,列表页用TabLayout或者分类下拉框过滤,这就把单表应用升级成了多维度笔记管理工具。

第四,录音、图片、富文本。这些需要引入多媒体处理和更复杂的编辑器组件,适合把基础功能做扎实之后再挑战的方向。

6.2 开发记事本项目最容易记住的五条教训

第一个教训,数据库上下文不能只是“能用就行”。在DAO里保存Activity的Context引用,如果不小心页面销毁了但引用还在,就会造成ActivityLeak,内存泄漏的经典问题。

第二个教训,不要把数据库操作放在子线程盲区之外。虽然现在我在这套代码里用的是主线程同步操作,数据量小没问题。但一旦数据量上百条或者操作复杂,就一定要用子线程或者线程池。

第三个教训,Cursor一定要关。我在排查别人代码的时候,见过一个极其明显的症状:连续进入列表页再退出,几次之后应用直接卡死。原因就是每次查询都new一个Cursor,用完不关,内存暴涨。

第四个教训,字符串格式化时间时最好用Locale.getDefault(),否则在特定语言环境下,日期会出现“星期几显示错误”这类问题。

第五个教训,列表页刷新和编辑页跳转的数据传递模式,最好用startActivityForResult带状态返回,而不是在MainActivity的onResume里总是查一遍数据库。后者处理简单但效率低,大开大合不是好做法。

6.3 写给自己和后来者的一些心得

记事本这个项目,我前前后后做了很多次,每次做都会发现自己对某个知识的理解又深了一层。有一些特别想强调的沉淀:

SQLite看起来就是简单的SQL执行器,但它的魅力在于一切都透明可查。你打开那台模拟器,从Android Studio的Device Explorer里找到/data/data/你的包名/databases/,把notes.db导出到电脑,用SQLite查看器打开,表里的每一行都清清楚楚。这种极简的思维方式,是后来理解Room、理解GreenDAO的基石。

少依赖IDE生成的模板,多手写一遍。这个项目如果让我给建议,我会说:不要只在向导里点点点,就算是最简单的Adapter和DAO,也尽量一个个字母打出来。敲一遍和看一遍,在大脑里留下的路径完全不同。

还有一点,界面设计上别学那些臃肿的APP。记事本的UI,最好的评价就是“留白、克制、直接”。一个大按钮,两个输入框,一个列表,干净利落。这种设计哲学放之四海而皆准,功能越简单,越要把每个细节做到位。

如果大家后面要在这个基础上扩展功能,我建议首选做搜索和备份,这俩是记事本用户最常问的需求。做完这两个功能,你对SQLite的理解基本就过关了。

提示:本套代码包完整工程文件已按模块整理好,包含数据库封装、Adapter适配器、Activity页面、布局文件和资源文件,导入Android Studio后可直接编译运行,适合作为课程设计、毕业设计或自学的参考项目使用。

我在最终检查这份代码包的时候,把早期的简单版本跟现在的完整版本对照看了一遍,前后代码量差距确实大。但核心的思想没变过:用最简单可靠的方式,把一件小事做到极致。记事本APP看起来普通,能把它做扎实的人,基本的数据存储和界面交互功夫就到家了,后面再去学别的框架、做复杂的应用,都有底气得多。

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

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

立即咨询