首页 / 资讯中心 / 文章详情

Android数据库表约定:为什么每张表都该有_id这列,TaoToken带你从CursorAdapter源码看透

Android数据库表约定:为什么每张表都该有_id这列,TaoToken带你从CursorAdapter源码看透 ★ FEATURED ARTICLE
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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。你需要在控制台创建一个 API Key然后就可以用这个 Key 去调用模型。对于 Android 开发场景我一般会用它来做三件事一是让模型根据表结构生成规范的建表语句二是检查我的查询 projection 是否漏了_id三是遇到getColumnIndexOrThrow抛异常时直接把堆栈贴给模型让它帮我定位。如果你打算长期在编码和 Agent 场景里用可以看一下 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它更适合需要持续调用、做代码补全和自动化任务的开发者。模型对话入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite API Keys 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。这里要强调一点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和contentCursor 里就没有_idCursorAdapter 一样会崩。正确写法是把_id显式列进 projectionpublic 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 包含_idOverride public LoaderCursor 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。再看changeCursorpublic 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_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 适合这种即时的代码问答。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 你的KeyKey 有没有多余空格有没有过期。API Keys 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 可以去那里重新生成一个。遇到local proxy failed通常是本地网络配置或者代理设置的问题。检查你的请求地址是不是https://taotoken.net/api有没有被本地工具改写。如果你在 Android 项目里用 OkHttp 调用检查有没有配置错误的Proxy。这个报错和_id无关但排查思路一样先确认请求地址和认证信息再看网络层。遇到reading choices相关的报错一般是响应解析出了问题。模型返回的 JSON 结构和你的解析代码不匹配比如你期望choices[0].message.content但实际返回的结构不同。这时候把原始响应打印出来看别急着改解析代码。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有响应格式说明。遇到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/apiAPI Key 用你生成的Model ID 按文档里支持的填。这三件套写全了认证和路由就不会出问题。Claude Code 相关的接入说明在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 可以对照着配。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_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 。长期做 Android 开发、需要频繁生成和审查 SQL 的话Coding Plan 在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 会更合适。API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 管理接入细节看 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后留一个可以直接用的检查清单建表 SQL 里主键是不是_id查询 projection 里有没有_idContentProvider有没有弄丢_idCursorLoader的 projection 有没有_id传给 Adapter 的 Cursor 非空时getColumnIndex(_id)是不是不等于 -1。这五条都过了CursorAdapter 就能正常工作。
阅读完成 · 觉得有帮助?
咨询建站