1. 这个报错到底在说什么
java.lang.IllegalArgumentException: column '_id' does not exist是 Android 里用CursorAdapter或SimpleCursorAdapter绑数据时最经典的运行时崩溃之一。它跟你的布局、Activity 生命周期、权限都没关系,问题只出在一件事上:你交给 Adapter 的那个Cursor,结果集里没有一列叫_id。
CursorAdapter的官方文档写得很直白:The Cursor must include a column named "_id" or this class will not work。也就是说,不管你的表主键叫id、uid还是_id1,只要 Cursor 投影出来的列名里没有精确的_id,Adapter 在内部调用cursor.getColumnIndexOrThrow("_id")时就会直接抛异常。
这个报错适合谁看:正在用SQLiteOpenHelper+SimpleCursorAdapter做列表展示、或者用ContentResolver.query读联系人/媒体库,然后列表一跑就崩的 Android 开发者。下面我会从建表、查询投影、Adapter 构造三层逐层拆,给出可直接复制的 SQL 和代码骨架,并演示怎么用 TaoToken 统一 Key 通道接 AI 辅助定位列名问题。
2. 先理解 CursorAdapter 对 _id 的硬性要求
2.1 为什么偏偏是 _id
CursorAdapter继承自BaseAdapter,它内部要靠一个稳定的行标识来做两件事:一是getItemId(position)返回每行的 id,二是配合ListView/RecyclerView的稳定 id 机制。Android 框架约定这个标识列名固定为_id,所以它在构造和swapCursor时都会去校验这一列是否存在。
关键点在于:校验的是Cursor 结果集的列名,不是数据库表的列名。很多人表里明明有主键,却因为rawQuery里写了SELECT *或者投影列表漏了主键,导致 Cursor 里没有_id,照样崩。
2.2 两种典型触发路径
第一种是SimpleCursorAdapter直接绑 SQLite 查询结果。表结构里主键叫_id1,查询写SELECT * FROM chi,Cursor 列名就是_id1, name1, bir,没有_id,崩。
第二种是ContentResolver.query读系统数据。比如读联系人时投影写成Phone.CONTACT_ID, Phone.DISPLAY_NAME, Phone.NUMBER,漏了Phone._ID,Cursor 里就没有_id,同样崩。这两种路径的根因完全一样,只是数据来源不同。
3. 用 TaoToken 统一通道接 AI 辅助排查
定位这类问题,最烦的是列名对不对只能靠打印 Cursor 的getColumnNames()一个个看。我习惯把 Cursor 的列名数组、建表 SQL、Adapter 构造参数一起丢给模型,让它帮我比对哪里缺了_id。为了让多个项目、多个工具共用一套 Key,我用 TaoToken 做统一入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,API 地址是 https://taotoken.net/api 。
接入方式很直接,把 base_url 指向 TaoToken 的 API 地址,用平台签发的 Key 即可。下面是一个用 curl 验证通道是否通的例子,把列名排查的 prompt 直接发过去:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [ {"role": "user", "content": "我的 Cursor 列名是 [_id1, name1, bir],SimpleCursorAdapter 报 column _id does not exist,怎么改查询?"} ] }'拿到 Key 的位置在控制台的 API Keys 页面:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。如果你只是想先在网页里对话验证模型能不能正确回答列名问题,可以直接用模型对话入口:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期做 Android 编码、想让 AI 持续读你的工程上下文,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
注意:TaoToken 在这里的角色是统一的模型调用通道,帮你把列名排查、SQL 改写这类问题快速问出去,它不替代 Android Studio,也不碰你的本地数据库。
4. 可复制的建表 SQL 与查询投影
4.1 建表时就把主键命名为 _id
最省事的做法是从源头对齐约定。建表时主键直接叫_id,后面所有查询天然满足要求:
CREATE TABLE chi ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name1 TEXT NOT NULL, bir TEXT );如果你接手的是老表,主键已经叫_id1且不方便改结构,那就别动表,改查询投影,用AS起别名。
4.2 rawQuery 里用 AS 补出 _id
老表结构是_id1, name1, bir,查询改成:
public Cursor getAll() { SQLiteDatabase db = getReadableDatabase(); return db.rawQuery("SELECT _id1 AS _id, name1, bir FROM chi", null); }这里_id1 AS _id是关键,Cursor 结果集的列名会变成_id, name1, bir,Adapter 校验就能过。注意别名必须精确是_id,写成_id带空格或者ID都不行。
4.3 ContentResolver 查询补上 Phone._ID
读联系人时,投影数组里显式加上Phone._ID:
Cursor cursor = context.getContentResolver().query( Phone.CONTENT_URI, new String[] { Phone._ID, Phone.CONTACT_ID, Phone.DISPLAY_NAME, Phone.NUMBER }, null, null, null );Phone._ID就是那一列_id,加上它之后 Cursor 列名里就有了 Adapter 要的标识列。
4.4 SimpleCursorAdapter 的 from 数组也要对上
查询改完还没结束,SimpleCursorAdapter构造里的from数组必须包含_id,否则绑定阶段照样出问题:
String[] from = new String[] { "_id", "name1", "bir" }; int[] to = new int[] { R.id.tv_id, R.id.tv_name, R.id.tv_bir }; SimpleCursorAdapter adapter = new SimpleCursorAdapter( this, R.layout.item_chi, cursor, from, to, 0 ); listView.setAdapter(adapter);from里的_id对应 Cursor 的列名,to对应布局里的控件 id,两边数量必须一致。
5. 验证请求与成功结果
5.1 先打印 Cursor 列名确认
在构造 Adapter 之前插一行日志,把真实列名打出来,这是最直接的验证手段:
Cursor cursor = getAll(); String[] names = cursor.getColumnNames(); Log.d("CursorCheck", "columns = " + Arrays.toString(names));如果日志输出columns = [_id, name1, bir],说明投影已经正确。如果输出[_id1, name1, bir],那就是AS别名没生效或者查询没改到。
5.2 用 getColumnIndexOrThrow 主动校验
想更早暴露问题,可以手动调一次:
int idx = cursor.getColumnIndexOrThrow("_id"); Log.d("CursorCheck", "_id index = " + idx);这行不抛异常,就说明 Adapter 那一关能过。抛了,就回到第 4 节检查投影。
5.3 跑起来看列表
列名正确后,SimpleCursorAdapter正常绑定,ListView会显示数据,不再出现IllegalArgumentException。如果列表空白但没崩,那多半是to里的控件 id 写错,跟_id无关,单独查布局。
6. 本篇常见错排查
6.1 表里有 _id 还是报错
检查是不是用了SELECT name1, bir FROM chi这种显式投影,把_id漏掉了。SELECT *通常没事,但显式列清单很容易漏。用 5.1 的日志确认。
6.2 别名写了但没生效
SELECT _id1 AS _id里别名前后别有奇怪字符,也别用中文引号。SQLite 对别名大小写不敏感,但_id和_ID在 Cursor 列名里是原样返回的,Adapter 找的是小写_id,所以别名统一写小写最稳。
6.3 swapCursor 之后又崩
swapCursor换进来的新 Cursor 同样要满足_id要求。如果你在Loader回调里换了查询,记得新查询也要带_id。
6.4 用 RecyclerView 配 CursorAdapter
CursorAdapter也能配RecyclerView,但校验逻辑一样。别以为换了控件就绕过了_id要求,根因不变。
6.5 排查时把上下文丢给 AI
把建表 SQL、rawQuery语句、getColumnNames()输出、Adapter 构造代码四段一起发给模型,让它比对。通过 TaoToken 的接入文档可以看具体调用格式:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你在用 Claude Code 做 Android 工程,接入配置参考:https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite 。
7. 把 Key 和通道固定下来
列名问题解决后,建议把 AI 排查这条链路也固定成习惯:统一用 TaoToken 的 Key,base_url 固定指向 https://taotoken.net/api ,这样换模型、换项目都不用重新配。需要新建或轮换 Key 时去控制台:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。日常快速验证模型回答质量用模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期编码和 Agent 场景用 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
回到这个报错本身,记住一句话就够了:Adapter 要的是 Cursor 结果集里的_id列,不是表里的主键名。查询投影补上AS _id,from数组带上_id,日志确认列名,三步走完基本不会再踩。