1. 为什么 CursorAdapter 死认 _id 这一列
如果你写过 Android 里的 SQLite 建表语句,大概率见过这种写法:CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT)。跑起来也没报错,自己写 SQL 查数据一切正常。可一旦把 Cursor 交给 CursorAdapter 或者它的子类(SimpleCursorAdapter、CursorTreeAdapter 等)去绑定 ListView,程序就直接崩了,日志里甩出一句IllegalArgumentException: column '_id' does not exist。这个坑我踩过不止一次,后来翻源码才彻底搞明白:Android 对数据库表有一个约定,每张表都应该至少有_id这列,而且名字必须是下划线开头的_id,不能是id。
这个约定不是随便定的,它来自 CursorAdapter 的内部实现。CursorAdapter 在初始化的时候,会调用c.getColumnIndexOrThrow("_id")去拿_id列的索引,注意是getColumnIndexOrThrow而不是getColumnIndex。前者在列不存在时直接抛异常,后者只是返回 -1。也就是说,只要 Cursor 不为空,CursorAdapter 就强制要求 Cursor 里必须能查到_id这一列,否则连构造都过不去。
那为什么 Android 要这么设计?因为 ListView 需要给每一行一个稳定的身份标识。当你调用notifyDataSetChanged、做数据更新、或者使用AdapterView.OnItemClickListener拿到getItemId(position)时,系统需要一个能唯一标识一行的列。_id就是这个被选中的列名。它和 SQLite 的ROWID有天然联系:如果一张表声明了INTEGER PRIMARY KEY,这一列就会成为ROWID的别名,而_id正是 Android 生态里约定俗成的叫法。
所以问题的本质不是「SQLite 要求有 _id」,而是「Android 的 Adapter 体系要求 Cursor 里必须有 _id」。这两者要分清楚。你完全可以在数据库里用id做主键,但在查询时用AS _id把它别名成_id,这样 CursorAdapter 就能正常工作。这也是很多老项目里常见的兼容写法。
理解了这个前提,后面的事情就顺了:建表时尽量直接叫_id,查询时确保 projection 里包含_id,绑定的时候就不会再出问题。下面我会从建表 SQL、CursorAdapter 源码、可复制的配置、验证步骤到常见报错,一步步带你把这个约定落地到实际项目里。
2. TaoToken 前置:把模型接进你的 Android 开发流
在动手改代码之前,先说一个能明显提升效率的做法:把大模型接进你的开发流程,用来帮你生成建表 SQL、审查 Cursor 查询的 projection、甚至直接解释 CursorAdapter 的源码逻辑。我平时用的是 TaoToken 这个平台,它提供统一的 API 入口,兼容常见的模型调用方式,配置起来不复杂。
TaoToken 的官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。你需要在控制台创建一个 API Key,然后就可以用这个 Key 去调用模型。对于 Android 开发场景,我一般会用它来做三件事:一是让模型根据表结构生成规范的建表语句,二是检查我的查询 projection 是否漏了_id,三是遇到getColumnIndexOrThrow抛异常时,直接把堆栈贴给模型让它帮我定位。
如果你打算长期在编码和 Agent 场景里用,可以看一下 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它更适合需要持续调用、做代码补全和自动化任务的开发者。模型对话入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
这里要强调一点:TaoToken 是帮你调用模型的工具,不是替代 Android Studio 的编辑器。你的建表、查询、绑定逻辑还是要在项目里写,模型只是加速你写对、写快。比如你可以把下面这段建表 SQL 贴给模型,让它帮你检查是否符合_id约定:
CREATE TABLE note ( _id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, created_at INTEGER );模型会告诉你_id已经满足 CursorAdapter 的要求,INTEGER PRIMARY KEY AUTOINCREMENT会让_id成为ROWID的别名,查询效率也更好。如果你写的是id INTEGER PRIMARY KEY,模型会提醒你查询时需要AS _id别名,或者干脆改列名。这种即时反馈能省掉不少调试时间。
配置 Key 的时候,建议把 Key 放在local.properties或者环境变量里,不要硬编码进代码提交到仓库。Android 项目里可以用BuildConfig注入,或者用 Gradle 的buildConfigField。这一步虽然和_id没有直接关系,但属于接入模型时的基本安全习惯,顺手做了就好。
3. 可复制配置:建表 SQL 与 CursorAdapter 绑定
这一节给你可以直接复制到项目里的配置。先说建表。Android 里建表一般写在SQLiteOpenHelper的onCreate里,推荐直接用_id作为主键列名:
public class NoteDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; private static final int DB_VERSION = 1; public static final String TABLE_NOTE = "note"; public static final String COL_ID = "_id"; public static final String COL_TITLE = "title"; public static final String COL_CONTENT = "content"; public static final String COL_CREATED_AT = "created_at"; private static final String SQL_CREATE_NOTE = "CREATE TABLE " + TABLE_NOTE + " (" + COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + COL_TITLE + " TEXT NOT NULL, " + COL_CONTENT + " TEXT, " + COL_CREATED_AT + " INTEGER" + ");"; public NoteDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE_NOTE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTE); onCreate(db); } }注意_id INTEGER PRIMARY KEY AUTOINCREMENT这一行。INTEGER PRIMARY KEY让_id成为ROWID的别名,AUTOINCREMENT保证自增且不复用已删除的 ID。如果你不需要严格自增,去掉AUTOINCREMENT也可以,性能会略好一点。
接下来是查询。很多人出问题不是建表没_id,而是查询时 projection 没带上_id。比如你只查title和content,Cursor 里就没有_id,CursorAdapter 一样会崩。正确写法是把_id显式列进 projection:
public Cursor queryAllNotes() { SQLiteDatabase db = getReadableDatabase(); String[] projection = { NoteDbHelper.COL_ID, NoteDbHelper.COL_TITLE, NoteDbHelper.COL_CONTENT, NoteDbHelper.COL_CREATED_AT }; return db.query( NoteDbHelper.TABLE_NOTE, projection, null, null, null, null, NoteDbHelper.COL_CREATED_AT + " DESC" ); }如果你实在不想改列名,比如历史表用的是id,那就在 projection 里用别名:
String[] projection = { "id AS _id", "title", "content" };这样 Cursor 里就有了_id列,CursorAdapter 能正常拿到索引。但要注意,AS _id只是查询层面的别名,数据库表里并没有真的多一列,getColumnIndexOrThrow("_id")查的是 Cursor 的列名,所以别名是有效的。
然后是 Adapter 的绑定。用SimpleCursorAdapter的时候,from数组里的列名要和 projection 对应:
String[] from = { NoteDbHelper.COL_TITLE, NoteDbHelper.COL_CONTENT }; int[] to = { R.id.tv_title, R.id.tv_content }; SimpleCursorAdapter adapter = new SimpleCursorAdapter( this, R.layout.item_note, cursor, from, to, 0 ); listView.setAdapter(adapter);这里from里不需要写_id,因为_id是给 Adapter 内部用的,不是给界面显示的。但 projection 里必须有它。这个区分很关键:_id是「绑定必需」,不是「显示必需」。
如果你用的是CursorLoader,在onCreateLoader里也要保证 projection 包含_id:
@Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { String[] projection = { NoteDbHelper.COL_ID, NoteDbHelper.COL_TITLE, NoteDbHelper.COL_CONTENT }; return new CursorLoader( this, NoteContract.CONTENT_URI, projection, null, null, NoteDbHelper.COL_CREATED_AT + " DESC" ); }用ContentProvider的时候,query方法里同样要确保返回的 Cursor 包含_id。很多ContentProvider的query实现会直接return qb.query(db, projection, ...),如果调用方传的 projection 没有_id,返回的 Cursor 就没有,Adapter 就会崩。所以约定是双向的:建表有_id,查询带_id。
4. 验证请求:从源码到运行结果
配置写完了,怎么验证真的生效?我一般分三步:先看源码确认逻辑,再跑一个最小查询,最后看日志和界面。
第一步,确认 CursorAdapter 的源码行为。在 Android SDK 里找到CursorAdapter.java,看init方法:
protected void init(Context context, Cursor c, boolean autoRequery) { boolean cursorPresent = c != null; mAutoRequery = autoRequery; mCursor = c; mDataValid = cursorPresent; mContext = context; mRowIDColumn = cursorPresent ? c.getColumnIndexOrThrow("_id") : -1; mChangeObserver = new ChangeObserver(); if (cursorPresent) { c.registerContentObserver(mChangeObserver); c.registerDataSetObserver(mDataSetObserver); } }关键就是c.getColumnIndexOrThrow("_id")。只要c不为 null,这行就会执行。如果 Cursor 里没有_id,直接抛IllegalArgumentException。再看changeCursor:
public void changeCursor(Cursor cursor) { if (cursor == mCursor) { return; } if (mCursor != null) { mCursor.unregisterContentObserver(mChangeObserver); mCursor.unregisterDataSetObserver(mDataSetObserver); mCursor.close(); } mCursor = cursor; if (cursor != null) { cursor.registerContentObserver(mChangeObserver); cursor.registerDataSetObserver(mDataSetObserver); mRowIDColumn = cursor.getColumnIndexOrThrow("_id"); mDataValid = true; notifyDataSetChanged(); } else { mRowIDColumn = -1; mDataValid = false; notifyDataSetInvalidated(); } }changeCursor里同样有getColumnIndexOrThrow("_id")。所以不管你是构造时传 Cursor,还是后续换 Cursor,只要 Cursor 非空,_id就必须存在。这就是「每张表都该有 _id」的源码依据。
第二步,跑一个最小验证。在Activity里插入一条数据,然后查询并打印列名:
NoteDbHelper helper = new NoteDbHelper(this); SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(NoteDbHelper.COL_TITLE, "测试标题"); values.put(NoteDbHelper.COL_CONTENT, "测试内容"); values.put(NoteDbHelper.COL_CREATED_AT, System.currentTimeMillis()); long rowId = db.insert(NoteDbHelper.TABLE_NOTE, null, values); Log.d("NoteDb", "insert rowId = " + rowId); Cursor cursor = helper.queryAllNotes(); Log.d("NoteDb", "column count = " + cursor.getColumnCount()); for (int i = 0; i < cursor.getColumnCount(); i++) { Log.d("NoteDb", "column " + i + " = " + cursor.getColumnName(i)); } int idIndex = cursor.getColumnIndex("_id"); Log.d("NoteDb", "_id index = " + idIndex);运行后看 Logcat,如果输出里能看到column 0 = _id,并且_id index不是 -1,说明查询没问题。如果_id index = -1,那就是 projection 漏了_id,回去补上。
第三步,绑定到 ListView 看界面。把上面的 cursor 传给SimpleCursorAdapter,设置给 ListView。如果没崩,并且列表正常显示,说明整条链路通了。你可以再调用一次adapter.changeCursor(newCursor),传入一个同样包含_id的新 Cursor,看是否正常刷新。这一步能验证changeCursor里的getColumnIndexOrThrow也不会出问题。
如果你用 TaoToken 的模型对话来辅助验证,可以把 Logcat 里的列名输出贴给模型,问它「这个 Cursor 能否安全传给 CursorAdapter」。模型会检查是否有_id,并告诉你还缺什么。模型对话入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,适合这种即时的代码问答。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节把两类问题放在一起说:一类是 Android 里_id相关的运行时报错,另一类是你接入模型时可能遇到的请求错误。两类问题的排查思路其实相通:先看报错原文,再定位是哪一层出的问题。
先说_id相关的。最常见的报错是:
java.lang.IllegalArgumentException: column '_id' does not exist这个报错的堆栈一般会指向CursorAdapter.init或者CursorAdapter.changeCursor。排查顺序是:第一,看建表 SQL 里主键列名是不是_id,如果是id,要么改列名,要么查询时AS _id。第二,看查询的 projection 数组里有没有_id,很多人只写了要显示的列,漏了_id。第三,看ContentProvider的query方法有没有对 projection 做处理,有些实现会自己拼 projection,把_id弄丢了。第四,看CursorLoader的 projection 参数,同样要包含_id。
还有一个容易忽略的场景:Cursor为空。如果查询结果为空,CursorAdapter构造时传的 Cursor 可能为 null,这时候mRowIDColumn = -1,不会抛异常。但如果你传的是一个非空但列不对的 Cursor,就会抛。所以「Cursor 为空」和「Cursor 没有 _id」是两回事,前者不崩,后者崩。
再说接入模型时的报错。如果你在调用 API 时遇到401,一般是 API Key 不对或者没带上。检查Authorization头是不是Bearer <你的Key>,Key 有没有多余空格,有没有过期。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,可以去那里重新生成一个。
遇到local proxy failed,通常是本地网络配置或者代理设置的问题。检查你的请求地址是不是https://taotoken.net/api,有没有被本地工具改写。如果你在 Android 项目里用 OkHttp 调用,检查有没有配置错误的Proxy。这个报错和_id无关,但排查思路一样:先确认请求地址和认证信息,再看网络层。
遇到reading choices相关的报错,一般是响应解析出了问题。模型返回的 JSON 结构和你的解析代码不匹配,比如你期望choices[0].message.content,但实际返回的结构不同。这时候把原始响应打印出来看,别急着改解析代码。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有响应格式说明。
遇到OAuth相关的报错,一般是认证方式用错了。如果你用的是 API Key,就不需要走 OAuth 流程。检查你的代码里有没有混用两种认证方式。有些 SDK 会默认走 OAuth,你需要显式配置成 API Key 模式。
这里要提醒一句:不管哪类报错,都不要把 Key 硬编码在代码里然后提交到公开仓库。Android 项目里可以用local.properties加BuildConfig,或者用环境变量。这是基本的安全习惯。
如果你在 Claude Code 或者类似的编码工具里接入,配置一般涉及三件套:Base URL、API Key、Model ID。Base URL 用https://taotoken.net/api,API Key 用你生成的,Model ID 按文档里支持的填。这三件套写全了,认证和路由就不会出问题。Claude Code 相关的接入说明在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite ,可以对照着配。
6. 把约定变成习惯:建表、查询、绑定三处对齐
回到最开始的问题:为什么每张表都该有_id?因为 Android 的 CursorAdapter 体系在源码层面就写死了getColumnIndexOrThrow("_id"),这不是可选项,是硬性要求。你可以在数据库里用别的列名做主键,但查询时必须让 Cursor 里有_id,否则 Adapter 直接崩。
落地这个约定,其实就三处要对齐。建表时,主键列直接叫_id,用INTEGER PRIMARY KEY AUTOINCREMENT,让它成为ROWID的别名。查询时,projection 数组里显式带上_id,别只写要显示的列。绑定时,from数组不需要_id,但传给 Adapter 的 Cursor 必须有。这三处对齐了,IllegalArgumentException: column '_id' does not exist就不会再出现。
如果你在维护老项目,表里用的是id,不想改表结构,那就用id AS _id的别名写法,成本最低。如果是新项目,直接叫_id,省掉别名的麻烦。用CursorLoader和ContentProvider的时候,同样要保证 projection 里有_id,因为最终传给 Adapter 的还是 Cursor。
我自己的习惯是,在SQLiteOpenHelper里把列名定义成常量,建表和查询都引用同一个常量,这样不会出现建表叫_id、查询写id的低级错误。另外,写完查询后顺手打印一下cursor.getColumnNames(),确认_id在里面,这个习惯帮我省了很多调试时间。
如果你想让模型帮你检查建表 SQL 和 projection 是否一致,可以把两段代码一起贴给模型,让它对比列名。模型对话在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。长期做 Android 开发、需要频繁生成和审查 SQL 的话,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 会更合适。API Key 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 管理,接入细节看 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
最后留一个可以直接用的检查清单:建表 SQL 里主键是不是_id;查询 projection 里有没有_id;ContentProvider有没有弄丢_id;CursorLoader的 projection 有没有_id;传给 Adapter 的 Cursor 非空时getColumnIndex("_id")是不是不等于 -1。这五条都过了,CursorAdapter 就能正常工作。