Android应用之自定义Cursor:从AbstractCursor到SQLite查询的完整实现

📅 发布时间:2026/10/4 10:57:22
Android应用之自定义Cursor:从AbstractCursor到SQLite查询的完整实现
1. 为什么要在 Android 里自定义 Cursor从 SQLite 查询到列表展示的真实痛点做 Android 数据展示的同学大概率都写过SimpleCursorAdapter绑定ListView或RecyclerView的代码。标准流程很顺SQLiteDatabase.query()拿到一个Cursor直接丢给 Adapter列表就出来了。但真实项目里数据往往没这么听话。我遇到过的典型场景有三类。第一类是跨表合并比如一个记账 App收入表和支出表分开存但首页要按时间倒序混排展示SQL 里写UNION ALL虽然能解决可一旦涉及分页、字段类型不一致、或者需要运行时动态拼装SQL 就会变得又长又难维护。第二类是二次加工SQLite 查出来的原始数据是时间戳、状态码、金额分值展示前要格式化、要按业务规则过滤掉某些行、要给某几列做脱敏这些逻辑塞进 SQL 里可读性极差。第三类是数据源根本不是数据库比如从网络接口、内存缓存、或者多个ContentProvider聚合来的数据但你仍然想复用Cursor那套「列名 行游标 观察者通知」的机制直接和SimpleCursorAdapter绑定。这时候继承AbstractCursor自己写一个 Cursor就是最干净的解法。它让你保留 Cursor 的全部对外契约——getCount()、moveToFirst()、getString(int column)、registerContentObserver()——但底层数据完全由你掌控。你可以用ArrayListArrayListString装数据可以在fillWindow()里控制每次只加载 10 条来降低大列表的内存压力也可以在onMove()里做行级过滤。这篇文章就围绕这个落地场景展开先讲清楚AbstractCursor的骨架怎么搭再给出 SQLite 建表和查询的可复制代码然后把它和SimpleCursorAdapter绑起来最后用日志和断点验证moveToFirst()、getCount()的真实行为。全程代码可直接抄进项目跑。需要说明的是Cursor 本身只是数据访问层和网络请求、模型调用没有直接关系。但如果你在 App 里同时集成了大模型能力做数据摘要或智能分类模型调用的 Key 管理可以走 TaoToken 的 API Keys 页面 统一配置和本文的 Cursor 逻辑互不干扰各管各的。2. 继承 AbstractCursor 的完整骨架列名、fillWindow 与 onMove 三件套AbstractCursor是 Android 框架里一个抽象类它帮你维护了mPos当前行位置、观察者列表、以及moveToFirst()/moveToNext()这些移动方法的骨架。你要做的是填三个核心部分列名数组、数据源、取值方法。先看类定义。数据源我强烈建议用ArrayListArrayListString而不是二维数组。原因很实际Cursor在跨进程传递比如通过ContentProvider返回给别的 App时需要序列化数组在Parcel写入时容易出问题而ArrayList是Serializable的稳得多。public class MyCursor extends AbstractCursor { private static final String TAG MyCursor; private static final int MAX_SHOW_NUM 10; private final String[] columnNames; // 列名构造时必须传入 private final ArrayListArrayListString allDatas; // 全量数据 private ArrayListArrayListString currentDatas; // 当前窗口数据 private ArrayListString oneLineData; // 当前行数据 private int allDataCnt; // 总行数 private int currentPosition; // 当前窗口起始位置 public MyCursor(String[] columnNames, ArrayListArrayListString allDatas) { this.columnNames columnNames; this.allDatas allDatas; this.allDataCnt allDatas.size(); } Override public String[] getColumnNames() { return columnNames; } Override public int getCount() { return allDataCnt; } }getColumnNames()必须在构造完成后就能返回有效数组因为SimpleCursorAdapter在bindView时会用列名去getColumnIndex()定位。getCount()返回总行数注意这里返回的是全量行数不是窗口内行数否则列表滚动条长度会算错。接下来是fillWindow()。这个方法在 Cursor 需要刷新窗口时被调用比如列表滚到末尾继续下滑、或者滚回顶部。它的作用是根据传入的position从全量数据里截取一段放进currentDatas然后调用super.fillWindow()让父类完成窗口登记。Override public void fillWindow(int position, CursorWindow window) { if (position 0 || position allDataCnt) { return; } if (position 0) { position - 1; // 回退一行保证边界行不丢 } currentPosition position; int currentShowCnt Math.min(MAX_SHOW_NUM, allDataCnt - position); if (currentDatas null) { currentDatas new ArrayList(); } else { currentDatas.clear(); } for (int i 0; i currentShowCnt; i) { currentDatas.add(allDatas.get(position i)); } Log.d(TAG, fillWindow end, position (position currentShowCnt - 1)); super.fillWindow(position, window); }这里有个坑我踩过position回退一行是为了处理「从下往上滚」的情况如果不回退窗口边界那一行会重复或丢失。MAX_SHOW_NUM设成 10 只是示例你可以按屏幕能显示的行数乘以 2 来设太大失去分页意义太小会频繁触发fillWindow。然后是onMove()。它在 Cursor 移动时被调用负责把currentDatas里对应偏移的那一行取出来赋给oneLineData后续getString()就从oneLineData里取。Override public boolean onMove(int oldPosition, int newPosition) { if (newPosition 0 || newPosition getCount()) { oneLineData null; return false; } int index newPosition - currentPosition; if (currentDatas null || index 0 || index currentDatas.size()) { return false; } oneLineData currentDatas.get(index); return super.onMove(oldPosition, newPosition); }取值方法里getString()最简单直接返回oneLineData.get(column)。但getInt()、getLong()要小心数据在ArrayListString里存的是字符串跨进程后可能变成CharSequence所以要先转Object再转Number不能直接强转Integer。Override public String getString(int column) { if (oneLineData null) { return null; } return oneLineData.get(column); } Override public int getInt(int column) { Object value getString(column); if (value null) { return 0; } try { return ((Number) value).intValue(); } catch (ClassCastException e) { try { return Integer.parseInt(value.toString()); } catch (NumberFormatException e2) { Log.e(TAG, Cannot parse int: value at column column); return 0; } } }到这里一个能跑的自定义 Cursor 骨架就齐了。它对外表现得和 SQLite 返回的 Cursor 一模一样但数据完全由你组装。3. 可复制配置SQLite 建表、查询与 SimpleCursorAdapter 绑定光有 Cursor 骨架还不够得让它和真实数据源接上。这一节给出从建表到绑定的完整可复制代码你可以直接放进Activity或Fragment里跑。先建表。假设我们做一个「节目录制列表」字段有编号、名称、总时长毫秒、状态码。public class DbHelper extends SQLiteOpenHelper { public static final String DB_NAME recording.db; public static final int DB_VERSION 1; public static final String TABLE recording; public DbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE TABLE ( _id INTEGER PRIMARY KEY AUTOINCREMENT, program_num TEXT, program_name TEXT, total_time INTEGER, status INTEGER)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(DROP TABLE IF EXISTS TABLE); onCreate(db); } }注意_id列必须有SimpleCursorAdapter依赖它作为行标识缺了会直接抛IllegalArgumentException。查询用标准query()投影数组要和后面 Adapter 的from数组顺序一致。public static final String[] PROJECTION { _id, program_num, program_name, total_time, status }; public Cursor queryAll() { SQLiteDatabase db getReadableDatabase(); return db.query(TABLE, PROJECTION, null, null, null, null, total_time DESC); }现在假设业务要求状态为 0 的记录不展示且总时长要格式化成HH:mm:ss。如果直接在 SQL 里过滤status ! 0可以写但格式化没法在 SQL 里做。这时候就用自定义 Cursor 把查询结果二次加工。public MyCursor buildFilteredCursor(Cursor raw) { ArrayListArrayListString data new ArrayList(); while (raw.moveToNext()) { int status raw.getInt(raw.getColumnIndexOrThrow(status)); if (status 0) { continue; // 过滤掉无效记录 } ArrayListString row new ArrayList(); row.add(String.valueOf(raw.getLong(raw.getColumnIndexOrThrow(_id)))); row.add(raw.getString(raw.getColumnIndexOrThrow(program_num))); row.add(raw.getString(raw.getColumnIndexOrThrow(program_name))); row.add(String.valueOf(raw.getLong(raw.getColumnIndexOrThrow(total_time)))); row.add(String.valueOf(status)); data.add(row); } raw.close(); return new MyCursor(PROJECTION, data); }绑定到SimpleCursorAdapterfrom是列名数组to是布局里的控件 id 数组两者顺序一一对应。String[] from {program_num, program_name, total_time}; int[] to {R.id.program_num, R.id.program_name, R.id.total_time}; MyCursor cursor buildFilteredCursor(queryAll()); SimpleCursorAdapter adapter new SimpleCursorAdapter( this, R.layout.recording_list_item, cursor, from, to, SimpleCursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); ListView listView findViewById(R.id.list_recording_entry); listView.setAdapter(adapter);如果你需要更精细地控制某一列的显示比如把total_time的毫秒格式化成时间字符串用setViewBinder就行。adapter.setViewBinder(new SimpleCursorAdapter.ViewBinder() { Override public boolean setViewValue(View view, Cursor cursor, int columnIndex) { if (view.getId() R.id.total_time) { long ms cursor.getLong(columnIndex); ((TextView) view).setText(formatDuration(ms)); return true; // 返回 true 表示已处理Adapter 不再默认绑定 } return false; } });formatDuration自己实现即可核心是String.format(%02d:%02d:%02d, h, m, s)。这里返回true很关键返回false的话 Adapter 还会用默认逻辑再覆盖一次你的文本。4. 验证请求与成功结果用日志和断点确认 moveToFirst/getCount 行为代码写完不能只看编译通过得验证 Cursor 的移动和取值真的符合预期。最直接的办法是打日志加断点。先在onMove()和fillWindow()里加日志前面骨架已经加了然后在 Activity 里主动调用几个关键方法观察输出。MyCursor cursor buildFilteredCursor(queryAll()); Log.d(CursorTest, getCount cursor.getCount()); boolean moved cursor.moveToFirst(); Log.d(CursorTest, moveToFirst moved , position cursor.getPosition()); if (moved) { Log.d(CursorTest, row0 name cursor.getString(cursor.getColumnIndexOrThrow(program_name))); Log.d(CursorTest, row0 time cursor.getLong(cursor.getColumnIndexOrThrow(total_time))); } while (cursor.moveToNext()) { Log.d(CursorTest, pos cursor.getPosition() name cursor.getString(cursor.getColumnIndexOrThrow(program_name))); }预期日志应该长这样getCount等于过滤后的行数moveToFirst返回trueposition为 0fillWindow在第一次移动时被触发一次输出fillWindow end, position9假设总数据超过 10 条。while循环会依次打印每一行position从 1 递增到getCount()-1。断点建议打在三个位置fillWindow()的super.fillWindow()调用前看currentDatas.size()是不是 10onMove()的oneLineData currentDatas.get(index)这一行看index是否等于newPosition - currentPositiongetString()入口看oneLineData是否为 null。实测下来最容易出问题的是getCount()返回了窗口内行数而不是全量行数。表现是列表只能显示 10 条滚动条很短滑到底就没了。原因就是getCount()里写成了currentDatas.size()。记住getCount()永远返回全量窗口只影响fillWindow和onMove的取值范围。另一个验证点是moveToPosition()。你可以手动调cursor.moveToPosition(15)看日志里fillWindow是否被触发、currentPosition是否更新到 14 附近、getString是否返回第 15 行的数据。如果返回了错误行多半是onMove里index计算时没减去currentPosition。5. 本篇常见错排查401、local proxy failed、reading choices 与 OAuth 报错对照虽然本文聚焦 Cursor但很多同学在同一个项目里会同时接入模型能力做数据摘要报错容易混淆。这里把几类高频错误对照清楚避免你排查时走错方向。第一类java.lang.IllegalArgumentException: column _id does not exist。这是SimpleCursorAdapter的经典报错和网络无关。原因是你传给 Adapter 的 Cursor 里没有_id列。解决建表时加_id INTEGER PRIMARY KEY AUTOINCREMENT查询投影数组里带上_id自定义 Cursor 的columnNames里也要有_id。第二类CursorIndexOutOfBoundsException: Index 3 requested, size 3。取值时列索引越界。检查getColumnIndexOrThrow()用的列名是否在columnNames里以及oneLineData的 size 是否等于columnNames.length。自定义 Cursor 里每行数据必须严格按列名顺序填充少一个都会越界。第三类local proxy failed或401 Unauthorized。这类报错出现在你调用模型 API 时和 Cursor 无关。401通常是 Key 无效或没带Authorization头local proxy failed是本地代理配置问题。如果你用 TaoToken 的模型对话 做调试Key 在 API Keys 页面 生成Base URL 用https://taotoken.net/api不要额外加路径后缀。第四类reading choices相关报错。这通常出现在解析模型返回的 JSON 时choices数组为空或字段名拼错。检查请求体里model字段是否和实际可用模型 ID 一致以及响应解析是否用了正确的字段路径。第五类OAuth 报错。如果你在 Claude Code 或 Codex 这类工具里配置认证OAuth 流程失败多半是回调地址或 token 过期。这类工具的配置三件套是 Base URL、Key、Model ID缺一不可。具体接入步骤可以看 接入文档。排查顺序建议先确认报错来自 Cursor 层还是网络层。看堆栈里有没有AbstractCursor、SimpleCursorAdapter、SQLiteDatabase这些类名有就是本文范围出现okhttp、retrofit、HttpURLConnection就是网络层去查 Key 和 Base URL。6. 从 Cursor 到长期编码把数据层和模型层分开管理自定义 Cursor 解决的是数据展示层的灵活性问题它让 SQLite 查询结果、内存数据、跨表合并数据都能以统一契约喂给 Adapter。这套机制在 Android 里稳定用了十几年至今没有更好的替代品——RecyclerView.Adapter配合CursorAdapter依然是大量存量项目的标配。但一个完整的 App 往往不止数据展示。你可能还要在列表上方加一个「智能摘要」按钮把当前列表数据发给模型生成一句话总结或者在后台用 Agent 做数据清洗。这些模型调用和 Cursor 逻辑应该彻底解耦Cursor 只管数据组装和游标移动模型调用走独立的网络层Key 和 Base URL 统一在 TaoToken 控制台 管理。如果你长期做 Android 编码并且频繁接入模型能力可以考虑用 Coding Plan 把额度集中管理避免每个项目单独配 Key 导致混乱。Claude Code 的接入配置可以参考 ClaudeCodeAnthropic 文档里面把 Base URL、Key、Model ID 三件套写得很清楚。回到 Cursor 本身最后给一个实用技巧如果你的数据量真的很大比如上万行不要在buildFilteredCursor里一次性把全量数据读进ArrayList而是在fillWindow里按需从数据库分页查。这样内存占用能控制在窗口大小级别列表滚动依然流畅。具体做法是把allDatas换成一个能按 position 查询的接口fillWindow里调它拿 10 条getCount()返回数据库里的总数。这个改造留给你动手试试核心骨架不变只换数据源实现。