1. 从 NotePad 到「带时间戳 + 可搜索」的本地笔记:问题到底出在哪
如果你手上有 Android 官方 NotePad 示例工程,或者自己照着写过一版,大概率会遇到两个很具体的体验问题:列表里只有标题,看不出这条笔记是刚记的还是上周写的;笔记一多,想找某条内容只能靠手指一条条往下翻。这两个问题本质上都落在同一个地方——本地数据层没有把「时间」和「查询」当回事。
NotePad 默认的noteslist_item.xml只放了一个TextView显示标题,NoteList里的PROJECTION也只取了_ID和COLUMN_NAME_TITLE。数据库里其实早就定义了COLUMN_NAME_CREATE_DATE和COLUMN_NAME_MODIFICATION_DATE两个字段,但插入时没写值,列表里也没读出来,等于白留了两个列。查询功能更是完全没有,菜单里连个搜索入口都没有。
这篇要解决的就是这两件事:给每条笔记补上创建时间和修改时间,并在列表项下方显示出来;再加一个基于SearchView的查询页,按标题或内容做模糊匹配。适合已经跑通 NotePad 基础增删改、想继续把本地数据层做扎实的 Android 初学者,也适合在维护老示例工程、需要快速补功能的同学。
我试过在真机上直接改这套代码,最容易踩的坑不是 SQL 写错,而是时间格式化时区没设对,导致显示出来的时间和手机系统时间差 8 小时;另一个坑是SimpleCursorAdapter的from数组和to数组长度不一致,直接崩在IllegalArgumentException。下面按「建表升级 → 写入时间戳 → 列表显示 → SearchView 查询 → 排障」的顺序走一遍,代码都能直接复制。
核心检索词先明确:NotePad 时间戳显示、SQLite 建表升级、SearchView 关键字查询、SimpleCursorAdapter 多列绑定。这几个词会贯穿全文,你按这个思路改完,列表能显示时间、搜索能按标题和内容过滤。
2. 前置准备:SQLite 表结构、字段与 NotePadProvider 改造点
动手之前先把数据层看清楚。NotePad 的NotePadProvider里有一个DatabaseHelper,onCreate里执行建表语句,onUpgrade里做版本迁移。默认建表大致是这样:
// NotePadProvider.java 中的建表语句(默认版本) private static final String CREATE_TABLE = "CREATE TABLE " + NotePad.Notes.TABLE_NAME + " (" + NotePad.Notes._ID + " INTEGER PRIMARY KEY," + NotePad.Notes.COLUMN_NAME_TITLE + " TEXT," + NotePad.Notes.COLUMN_NAME_NOTE + " TEXT," + NotePad.Notes.COLUMN_NAME_CREATE_DATE + " INTEGER," + NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE + " INTEGER" + ");";注意这里两个日期字段的类型是INTEGER,官方示例原本打算存毫秒时间戳。但很多教程(包括你手上这份 excerpt)改成了存格式化字符串yyyy/MM/dd HH:mm:ss,然后字段类型还是INTEGER。SQLite 是弱类型,存字符串进去不会报错,但排序会按字符串字典序走,2024/09/01和2024/10/01比较时没问题,跨年也还行,因为格式固定。真正的问题是:如果你后面想按时间范围查询,字符串比较就不如整数时间戳灵活。
我的建议是分两种情况处理:
| 方案 | 字段类型 | 存储内容 | 优点 | 缺点 |
|---|---|---|---|---|
| A 字符串格式化 | TEXT | 2024/09/23 14:30:00 | 列表直接显示,不用再格式化 | 范围查询、排序不够灵活 |
| B 毫秒时间戳 | INTEGER | 1727073000000 | 排序、范围查询方便 | 显示前要再格式化一次 |
如果你只是想让列表显示时间、搜索按关键字过滤,方案 A 最省事,和 excerpt 的思路一致。如果你后面还想做「按时间倒序」「查最近七天」,建议用方案 B,显示时再格式化。下面我按方案 A 写,因为改动最小,同时把方案 B 的兼容写法也标出来。
数据库版本号要改。DatabaseHelper里通常有:
private static final int DATABASE_VERSION = 1;如果你已经装过旧版本 App,直接改字段类型不会触发onUpgrade,得把版本号加一,并在onUpgrade里补上迁移逻辑。全新安装的话,onCreate直接建新表即可。
// 升级版本号 private static final int DATABASE_VERSION = 2; @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单粗暴:旧版本直接重建表(会丢数据,仅示例用) // 生产环境请用 ALTER TABLE 或数据迁移 if (oldVersion < 2) { db.execSQL("DROP TABLE IF EXISTS " + NotePad.Notes.TABLE_NAME); onCreate(db); } }注意:
DROP TABLE会清空已有笔记。如果你不想丢数据,用ALTER TABLE notes ADD COLUMN ...逐列加,或者把旧数据读出来再写回新表。示例工程里为了演示方便才直接重建。
字段常量确认一下,NotePad.Notes里应该有:
public static final String COLUMN_NAME_CREATE_DATE = "created"; public static final String COLUMN_NAME_MODIFICATION_DATE = "modified";如果你的工程里字段名不一样,后面所有 SQL 和PROJECTION都要跟着改,别照抄字段名。
3. 可复制配置:时间戳写入、列表绑定与 SearchView 查询回调
这一节是核心,分三块:写入时间戳、列表显示时间、SearchView 查询。每块都给完整代码。
3.1 插入和更新时写入时间戳
在NotePadProvider的insert和update方法里,补上时间字段。关键是时区要设成GMT+08:00,否则真机上可能显示 UTC 时间。
// NotePadProvider.java 的 insert 方法内 @Override public Uri insert(Uri uri, ContentValues values) { // ... 前面的 uri 匹配逻辑 // 获取当前时间,格式化为北京时间 Long now = Long.valueOf(System.currentTimeMillis()); Date date = new Date(now); SimpleDateFormat format = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); format.setTimeZone(TimeZone.getTimeZone("GMT+08:00")); String formatDate = format.format(date); // 如果调用方没有传创建时间,就补上 if (values.containsKey(NotePad.Notes.COLUMN_NAME_CREATE_DATE) == false) { values.put(NotePad.Notes.COLUMN_NAME_CREATE_DATE, formatDate); } // 修改时间同理 if (values.containsKey(NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE) == false) { values.put(NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE, formatDate); } // ... 继续执行 db.insert }update方法里只需要补修改时间,创建时间不动:
@Override public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { // 每次更新都刷新修改时间 Long now = Long.valueOf(System.currentTimeMillis()); Date date = new Date(now); SimpleDateFormat format = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); format.setTimeZone(TimeZone.getTimeZone("GMT+08:00")); values.put(NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE, format.format(date)); // ... 继续执行 db.update }如果你用方案 B(存毫秒),把上面formatDate换成String.valueOf(now),字段类型改成INTEGER,显示时再格式化。
3.2 列表项布局加时间 TextView
noteslist_item.xml里加一个TextView,放在标题下面:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="8dp"> <TextView android:id="@android:id/text1" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="18sp" /> <TextView android:id="@+id/text1_date" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="14sp" android:paddingLeft="5dip" android:textColor="#888888" /> </LinearLayout>注意标题的 id 用的是@android:id/text1,因为SimpleCursorAdapter默认绑到android.R.id.text1。时间用自定义 idtext1_date。
3.3 NoteList 里绑定两列数据
NoteList.java的PROJECTION要加上修改时间列,from和to数组要一一对应:
private static final String[] PROJECTION = new String[] { NotePad.Notes._ID, // 0 NotePad.Notes.COLUMN_NAME_TITLE, // 1 NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE // 2 }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setDefaultKeyMode(DEFAULT_KEYS_SHORTCUT); Intent intent = getIntent(); if (intent.getData() == null) { intent.setData(NotePad.Notes.CONTENT_URI); } getListView().setOnCreateContextMenuListener(this); Cursor cursor = managedQuery( getIntent().getData(), PROJECTION, null, null, NotePad.Notes.DEFAULT_SORT_ORDER ); String[] dataColumns = { NotePad.Notes.COLUMN_NAME_TITLE, NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE }; int[] viewIDs = { android.R.id.text1, R.id.text1_date }; SimpleCursorAdapter adapter = new SimpleCursorAdapter( this, R.layout.noteslist_item, cursor, dataColumns, viewIDs ); setListAdapter(adapter); }dataColumns和viewIDs长度必须一致,顺序也要对应:标题绑text1,时间绑text1_date。少一个就崩。
3.4 菜单加搜索入口
在list_options_menu.xml里加一项:
<item android:id="@+id/menu_search" android:icon="@android:drawable/ic_menu_search" android:title="Search" app:showAsAction="always" />然后在NoteList的onOptionsItemSelected里加 case:
case R.id.menu_search: Intent searchIntent = new Intent(this, NoteSearch.class); this.startActivity(searchIntent); return true;3.5 NoteSearch 布局与查询回调
新建NoteSearch.java和note_search.xml。布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <SearchView android:id="@+id/search_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:iconifiedByDefault="false" /> <ListView android:id="@+id/list_view" android:layout_width="match_parent" android:layout_height="wrap_content" /> </LinearLayout>NoteSearch.java完整实现:
public class NoteSearch extends Activity implements SearchView.OnQueryTextListener { ListView listView; SQLiteDatabase sqLiteDatabase; SearchView searchView; private static final String[] PROJECTION = new String[] { NotePad.Notes._ID, NotePad.Notes.COLUMN_NAME_TITLE, NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.note_search); searchView = findViewById(R.id.search_view); listView = findViewById(R.id.list_view); sqLiteDatabase = new NotePadProvider.DatabaseHelper(this).getReadableDatabase(); searchView.setOnQueryTextListener(this); } @Override public boolean onQueryTextSubmit(String query) { return false; } @Override public boolean onQueryTextChange(String newText) { String selection = NotePad.Notes.COLUMN_NAME_TITLE + " LIKE ? OR " + NotePad.Notes.COLUMN_NAME_NOTE + " LIKE ?"; String[] selectionArgs = { "%" + newText + "%", "%" + newText + "%" }; Cursor cursor = sqLiteDatabase.query( NotePad.Notes.TABLE_NAME, PROJECTION, selection, selectionArgs, null, null, NotePad.Notes.DEFAULT_SORT_ORDER ); String[] dataColumns = { NotePad.Notes.COLUMN_NAME_TITLE, NotePad.Notes.COLUMN_NAME_MODIFICATION_DATE }; int[] viewIDs = { android.R.id.text1, R.id.text1_date }; SimpleCursorAdapter adapter = new SimpleCursorAdapter( this, R.layout.noteslist_item, cursor, dataColumns, viewIDs ); listView.setAdapter(adapter); return true; } }这里onQueryTextChange每输入一个字符就查一次,适合笔记量不大的场景。如果笔记上千条,建议改成onQueryTextSubmit里查,或者加 300ms 防抖。
4. 验证请求:插入、更新、模糊查询三步实测
代码写完别急着说「应该没问题」,按下面三步在真机或模拟器上跑一遍,每步都有明确的预期结果。
第一步:插入一条新笔记,验证创建时间和修改时间都写进去了。
打开 App,点菜单新建笔记,标题填「测试时间戳」,内容随便写。保存后回到列表,应该看到这条笔记下面显示类似2024/09/23 14:30:00的时间。如果显示空白,说明PROJECTION没取到时间列,或者SimpleCursorAdapter的viewIDs没绑对。
想直接看数据库的话,用 Android Studio 的 App Inspection → Database Inspector,打开notes表,看created和modified两列是不是都有值。命令行方式:
adb shell run-as 你的包名 cd databases sqlite3 note_pad.db SELECT _id, title, created, modified FROM notes;预期输出类似:
1|测试时间戳|2024/09/23 14:30:00|2024/09/23 14:30:00第二步:编辑这条笔记,验证修改时间刷新、创建时间不变。
点进笔记改一下内容再保存,回到列表,时间应该变成当前时间。再查数据库:
1|测试时间戳改|2024/09/23 14:30:00|2024/09/23 14:35:12created保持原值,modified更新,说明update方法里的时间写入生效了。如果两个都变了,检查是不是在update里误写了COLUMN_NAME_CREATE_DATE。
第三步:搜索关键字,验证标题和内容都能匹配。
再建两条笔记,一条标题「购物清单」,内容「牛奶 鸡蛋」;一条标题「工作记录」,内容「购物清单已整理」。打开搜索页,输入「购物」,预期两条都出现——一条是标题命中,一条是内容命中。输入「牛奶」,只出现第一条。输入「xyz」,列表为空。
如果搜索时列表不刷新,检查onQueryTextChange有没有return true,以及listView.setAdapter有没有在每次查询后调用。如果一输入就崩,看 Logcat 是不是IllegalArgumentException: column '_id' does not exist,那是PROJECTION里漏了_ID,SimpleCursorAdapter强制要求游标里有_id列。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 之外的本地坑
这一节把改 NotePad 时最容易撞上的报错和现象列出来,对照 Logcat 定位。
报错一:java.lang.IllegalArgumentException: column '_id' does not exist
原因:PROJECTION数组里没包含NotePad.Notes._ID。SimpleCursorAdapter内部要求游标必须有_id列,否则直接抛异常。解决:确保PROJECTION第一项是NotePad.Notes._ID,查询时也带上。
报错二:java.lang.IllegalArgumentException: from[] and to[] must be the same length
原因:dataColumns和viewIDs长度不一致。比如dataColumns有 2 个,viewIDs只写了 1 个。解决:一一对应,标题对android.R.id.text1,时间对R.id.text1_date。
报错三:时间显示差 8 小时
原因:SimpleDateFormat没设时区,默认用 UTC。解决:format.setTimeZone(TimeZone.getTimeZone("GMT+08:00"))。注意GMT+08:00写法,别写成GMT+8,部分机型解析不一致。
报错四:搜索时no such column: note
原因:NotePad.Notes.COLUMN_NAME_NOTE常量值和数据库实际列名不一致。默认是note,如果你改过建表语句,要同步改常量。解决:查NotePad.Notes里的常量定义,和CREATE TABLE里的列名对齐。
报错五:onUpgrade没触发,新字段没加上
原因:DATABASE_VERSION没改,或者 App 没卸载重装。解决:版本号加一,卸载旧 App 重装,或者手动在onUpgrade里加ALTER TABLE。
报错六:搜索页NullPointerException在sqLiteDatabase.query
原因:DatabaseHelper初始化失败,或者getReadableDatabase()返回 null。解决:确认NotePadProvider.DatabaseHelper的构造函数能正常拿到 Context,别在onCreate之前调用。
报错七:local proxy failed/reading choices/401/OAuth
这几个通常出现在你后续想把笔记数据同步到云端、或者接入大模型做摘要时。本地 SQLite 改造本身不会产生这些错误。如果你在接入外部 API 时遇到401,先检查 Key 是否有效、请求头Authorization格式对不对;遇到local proxy failed,检查本地网络配置和 Base URL 是否可达;遇到reading choices,多半是响应体解析时字段名对不上;遇到OAuth相关报错,检查回调地址和 token 是否过期。这些和本篇的 SQLite 时间戳、SearchView 查询是两回事,别混在一起排查。
排查顺序建议:先看 Logcat 第一条异常,定位到具体行号;再用 Database Inspector 看数据有没有写进去;最后用adb shell直接跑 SQL 验证查询条件。三步下来基本能锁定问题。
6. 把本地数据层做扎实之后,还能往哪走
时间戳和关键字查询只是 NotePad 本地数据层的最小闭环。改完这套之后,你会发现几个自然的延伸点:列表按修改时间倒序排列,只需要把DEFAULT_SORT_ORDER改成COLUMN_NAME_MODIFICATION_DATE + " DESC";搜索加防抖,在onQueryTextChange里用Handler.postDelayed延迟 300ms 再查;时间显示改成「刚刚」「5 分钟前」这种相对格式,写个工具方法把字符串解析回Date再算差值。
如果你后面想让笔记支持云同步、或者接入模型做自动摘要和标签,本地这层的时间戳和查询就是基础。同步时需要知道哪些笔记改过,靠的就是modified字段;做增量拉取时,按modified > 上次同步时间过滤即可。接入外部服务时,Base URL、Key、Model ID 三件套要配齐,请求头发Authorization: Bearer <你的Key>,响应体解析注意字段名和类型。这些属于另一条链路,等本地数据层稳定了再往上叠。
回到眼前,最实用的建议是:先把DATABASE_VERSION和onUpgrade处理好,别等线上有数据了才想起迁移;再把PROJECTION、dataColumns、viewIDs三处对齐,能省掉大部分崩溃。剩下的就是多插几条笔记、多搜几次,用真实数据把边界情况跑出来。