news 2026/9/26 18:02:13

SQLite 工具类实战:用反射把 Cursor 封装到 VO 对象

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQLite 工具类实战:用反射把 Cursor 封装到 VO 对象

1. 从 Cursor 到 VO:那段被 getColumnIndex 支配的日子

写 Android 本地存储的同学大概率都经历过这个阶段:DAO 层里一个查询方法,Cursor 拿到手之后,先moveToNext(),然后一行一行cursor.getColumnIndex("user_name")、cursor.getString(idx)、vo.userName = ...,字段一多,一个方法能写三四十行,改个表结构还得回来同步改索引。这种代码不是不会写,是写多了真的会烦。

这篇要聊的就是怎么把这段重复劳动干掉:用反射把 Cursor 的列值自动塞进 VO 对象的字段里。核心思路很朴素——Cursor 的列名(或者 SQL 里起的别名)和 VO 的字段名保持一致,工具类遍历 VO 的字段,按名字去 Cursor 里找对应列,再按字段类型做一次转换,最后field.set()写进去。整个过程不需要你手写任何 setter 调用。

适合谁看:正在写 Android 原生 SQLite、或者用 Java 做轻量本地数据层、又不想引入 Room 这类框架的同学。如果你已经在用 Room 或者 GreenDAO,这篇的收益不大;但如果你手上是SQLiteOpenHelper+ 手写 SQL 的组合,这套工具类能立刻减少一大半样板代码。

我试过在一个老项目里把十几个 DAO 方法统一换成这套封装,代码量大概砍掉了六成,而且后面加字段只需要改 VO 和建表语句,查询方法基本不用动。下面把工具类骨架、类型匹配、空值处理、以及一段能直接跑的验证代码都摊开讲。

2. 前置准备:TaoToken 与开发环境

2.1 为什么这里会提到 TaoToken

写这类工具类的时候,我习惯让 AI 帮忙做两件事:一是把反射逻辑里容易漏的边界情况列出来(比如 Cursor 里没有对应列、字段是嵌套对象、值为 null),二是生成一批测试用的 VO 和建表 SQL。这时候一个稳定的模型调用入口就挺省事。

TaoToken 提供的是统一的模型 API 接入,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。它本身不绑定编辑器,你可以在 Android Studio 里用插件、也可以在命令行里调,用来做代码补全、生成测试数据、解释反射报错都行。如果你只是想验证某段反射代码的输出,可以直接用模型对话页面贴代码问;如果是长期写 Android 项目、想让 AI 持续参与编码,可以看下 Coding Plan。

需要说清楚的是:TaoToken 在这里的角色是「帮你写和调这段工具类的辅助工具」,不是替代 SQLite 本身,也不是替代 Android Studio。工具类的运行完全不依赖它。

2.2 环境与依赖

这套代码不依赖任何第三方库,纯 Java + Android SDK 自带类:

  • Android SDK 里的android.database.Cursor、android.database.sqlite.SQLiteDatabase
  • java.lang.reflect.Field、Constructor
  • 集合用LinkedList或ArrayList都行

VO 的写法有个约定:字段用public,不写 getter/setter。原因很直接——反射直接读clazz.getFields()拿到的就是 public 字段,省掉一层方法查找;而且 Android 上方法数和方法调用开销都是要算的,VO 这种纯数据载体没必要包一层。如果你团队规范强制要求私有字段 + getter,那这套代码要改成遍历getDeclaredFields()并配合 setter,后面排障部分会说怎么改。

3. 可复制配置:反射封装工具类完整骨架

3.1 工具类整体结构

先给完整代码,再逐段解释。类名叫CursorReflectHelper,放在util包下。

package com.example.db.util; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class CursorReflectHelper { // 基本类型包装类映射,用于把 int.class 这类映射到 Integer.class private static final Map<Class<?>, Class<?>> BASIC_MAP = new HashMap<>(); static { BASIC_MAP.put(int.class, Integer.class); BASIC_MAP.put(long.class, Long.class); BASIC_MAP.put(float.class, Float.class); BASIC_MAP.put(double.class, Double.class); BASIC_MAP.put(boolean.class, Boolean.class); BASIC_MAP.put(byte.class, Byte.class); BASIC_MAP.put(short.class, Short.class); BASIC_MAP.put(char.class, Character.class); } /** 查询单条,返回 VO 实例;无数据返回 null */ public static <T> T queryOne(SQLiteDatabase db, String sql, Class<T> clazz) { return queryOne(db, sql, null, clazz); } /** 带占位参数的查询单条 */ public static <T> T queryOne(SQLiteDatabase db, String sql, String[] args, Class<T> clazz) { Cursor cursor = db.rawQuery(sql, args); try { if (cursor.moveToFirst()) { return cursorToVo(cursor, clazz); } return null; } finally { cursor.close(); } } /** 查询列表 */ public static <T> List<T> queryList(SQLiteDatabase db, String sql, Class<T> clazz) { return queryList(db, sql, null, clazz); } /** 带占位参数的查询列表 */ public static <T> List<T> queryList(SQLiteDatabase db, String sql, String[] args, Class<T> clazz) { Cursor cursor = db.rawQuery(sql, args); List<T> result = new ArrayList<>(); try { while (cursor.moveToNext()) { T vo = cursorToVo(cursor, clazz); if (vo != null) { result.add(vo); } } } finally { cursor.close(); } return result; } /** 把当前 Cursor 行映射成 VO */ public static <T> T cursorToVo(Cursor cursor, Class<T> clazz) { try { T vo = clazz.newInstance(); fillFields(cursor, vo, clazz); return vo; } catch (Exception e) { e.printStackTrace(); return null; } } /** 遍历字段并赋值 */ private static void fillFields(Cursor cursor, Object target, Class<?> clazz) throws Exception { Field[] fields = clazz.getFields(); for (Field field : fields) { Class<?> fieldType = field.getType(); // 跳过静态字段 if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) { continue; } String columnName = field.getName(); int columnIndex = cursor.getColumnIndex(columnName); if (columnIndex == -1) { // Cursor 里没有这一列,跳过,保持字段默认值 continue; } if (cursor.isNull(columnIndex)) { // 数据库值为 NULL,跳过,保持字段默认值 continue; } Object value = readValue(cursor, columnIndex, fieldType); if (value != null) { field.setAccessible(true); field.set(target, value); } } } /** 按字段类型从 Cursor 读取并转换 */ private static Object readValue(Cursor cursor, int columnIndex, Class<?> fieldType) throws Exception { Class<?> boxed = wrap(fieldType); if (boxed == String.class) { return cursor.getString(columnIndex); } if (boxed == Integer.class) { return cursor.getInt(columnIndex); } if (boxed == Long.class) { return cursor.getLong(columnIndex); } if (boxed == Float.class) { return cursor.getFloat(columnIndex); } if (boxed == Double.class) { return cursor.getDouble(columnIndex); } if (boxed == Short.class) { return cursor.getShort(columnIndex); } if (boxed == Byte.class) { return (byte) cursor.getInt(columnIndex); } if (boxed == Boolean.class) { return cursor.getInt(columnIndex) != 0; } if (boxed == Character.class) { String s = cursor.getString(columnIndex); return (s == null || s.isEmpty()) ? null : s.charAt(0); } // 其他类型走 String 构造器兜底 String raw = cursor.getString(columnIndex); if (raw == null) { return null; } Constructor<?> cons = boxed.getConstructor(String.class); return cons.newInstance(raw); } /** 基本类型转包装类 */ private static Class<?> wrap(Class<?> type) { Class<?> boxed = BASIC_MAP.get(type); return boxed == null ? type : boxed; } }

3.2 关键设计点说明

字段名与列名对齐。fillFields里用field.getName()直接当列名去getColumnIndex。这意味着你的建表语句里列名要么和 VO 字段名完全一致,要么在 SQL 里用AS起别名。比如表里是user_name,VO 字段是userName,那查询就写SELECT user_name AS userName FROM ...。这一点是整套方案能成立的前提,后面排障会专门讲不一致时怎么定位。

getColumnIndex返回 -1 的处理。如果 Cursor 里没有这个列,直接continue,不抛异常。这样 VO 里可以放一些「非数据库字段」的辅助属性,比如public boolean selected;,查询时不会被误伤。

NULL 值处理。cursor.isNull(columnIndex)为 true 时跳过赋值,字段保持 Java 默认值(对象是 null,int 是 0)。这里有个取舍:如果你希望数据库 NULL 映射成空字符串,可以在 String 分支里把cursor.getString的结果做一次null -> ""的转换。我倾向于保持 null,因为「没填」和「填了空串」在业务上往往是两回事。

基本类型与包装类。wrap()把int.class映射到Integer.class,这样 VO 里写public int age;也能正常走Integer分支。注意field.set对基本类型字段会自动拆箱,所以传Integer进去没问题。

4. 验证请求:查询后自动封装并打印 VO

4.1 建表与 VO 定义

先建一张测试表,字段故意设计成几种常见类型:

public class DbHelper extends SQLiteOpenHelper { public DbHelper(Context context) { super(context, "demo.db", null, 1); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE tb_user (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "userName TEXT, " + "age INTEGER, " + "score REAL, " + "isVip INTEGER, " + "createTime INTEGER)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS tb_user"); onCreate(db); } }

对应的 VO,字段名和列名对齐:

public class UserVo { public int id; public String userName; public int age; public float score; public boolean isVip; public long createTime; @Override public String toString() { return "UserVo{id=" + id + ", userName='" + userName + "', age=" + age + ", score=" + score + ", isVip=" + isVip + ", createTime=" + createTime + '}'; } }

4.2 插入数据并查询验证

在 Activity 或测试类里跑这段:

DbHelper helper = new DbHelper(this); SQLiteDatabase db = helper.getWritableDatabase(); // 插入两条测试数据 db.execSQL("INSERT INTO tb_user (userName, age, score, isVip, createTime) " + "VALUES (?, ?, ?, ?, ?)", new Object[]{"张三", 28, 91.5f, 1, 1700000000000L}); db.execSQL("INSERT INTO tb_user (userName, age, score, isVip, createTime) " + "VALUES (?, ?, ?, ?, ?)", new Object[]{"李四", 34, 78.0f, 0, 1700000001000L}); // 查询单条 UserVo one = CursorReflectHelper.queryOne(db, "SELECT * FROM tb_user WHERE userName = ?", new String[]{"张三"}, UserVo.class); Log.d("CursorTest", "单条结果: " + one); // 查询列表 List<UserVo> list = CursorReflectHelper.queryList(db, "SELECT * FROM tb_user ORDER BY age DESC", UserVo.class); for (UserVo vo : list) { Log.d("CursorTest", "列表项: " + vo); } db.close();

预期输出:

单条结果: UserVo{id=1, userName='张三', age=28, score=91.5, isVip=true, createTime=1700000000000} 列表项: UserVo{id=2, userName='李四', age=34, score=78.0, isVip=false, createTime=1700000001000} 列表项: UserVo{id=1, userName='张三', age=28, score=91.5, isVip=true, createTime=1700000000000}

isVip在数据库里存的是 0/1,映射到boolean字段后自动变成 false/true,这是readValue里cursor.getInt(columnIndex) != 0那行的效果。score是 REAL 类型,对应float字段,走的是getFloat分支。

4.3 用别名处理字段名不一致

如果 VO 字段叫userName,但表里列名是name,查询时起别名即可:

UserVo vo = CursorReflectHelper.queryOne(db, "SELECT id, name AS userName, age FROM tb_user WHERE id = ?", new String[]{"1"}, UserVo.class);

注意别名必须和 VO 字段名大小写完全一致。getColumnIndex在 Android 的 SQLite 实现里是大小写不敏感的,但为了可读性和跨平台一致,建议严格对齐。

5. 本篇常见错排查

5.1 字段全是默认值,一个都没填上

最常见的原因就是列名和字段名对不上。排查方法:在fillFields里加一行日志,把cursor.getColumnNames()和clazz.getFields()的名字都打出来对比。

for (String name : cursor.getColumnNames()) { Log.d("CursorReflect", "cursor column: " + name); } for (Field f : clazz.getFields()) { Log.d("CursorReflect", "vo field: " + f.getName()); }

两边名字对不上的,要么改 SQL 加别名,要么改 VO 字段名。另外注意SELECT *在联表查询时可能返回重复列名,getColumnIndex只会拿到第一个匹配,这种情况必须显式列出字段并起别名。

5.2 报 NoSuchMethodException: ()

clazz.newInstance()要求 VO 有一个无参构造器。如果你给 VO 写了带参构造器又没补默认构造器,就会挂在这里。解决办法:显式写一个public UserVo() {}。

5.3 数字类型转换异常

如果数据库里age列存的是字符串"abc",而 VO 字段是int,cursor.getInt会返回 0 而不是抛异常,这种静默失败比较隐蔽。如果字段是Integer且列里是空串,getInt同样返回 0。要严格校验的话,可以在readValue里先getString再手动Integer.parseInt,捕获NumberFormatException并打日志。代价是性能略降,按需选择。

5.4 嵌套 VO 递归死循环

原始版本里有个递归分支:字段不是基本类型时,递归调用setValues2Fields。这个设计在字段是另一个 VO 时有用,但如果两个 VO 互相引用,就会无限递归。我上面给的版本去掉了这个递归分支,只处理基本类型和 String,遇到复杂类型走 String 构造器兜底。如果你确实需要嵌套映射,建议加一个深度参数限制递归层数,比如最多 3 层。

5.5 Cursor 忘记关闭导致泄漏

工具类里所有queryOne/queryList都在finally里cursor.close(),这是必须的。如果你自己在外层又包了一层 Cursor 操作,注意不要重复关闭,也不要让 Cursor 逃逸出方法作用域。Android 的 Cursor 不关会报CursorWindowAllocationException,尤其在列表查询数据量大时。

5.6 性能上的注意点

反射本身比直接调用 setter 慢,但在 SQLite 查询这个场景下,IO 和 Cursor 遍历才是大头,反射开销占比很小。真正要避免的是在循环里反复getColumnIndex——我上面的版本每个字段每行都调了一次getColumnIndex,如果列表有几千行,可以优化成先建一个Map<String, Integer>缓存列名到索引的映射,在queryList里只建一次。数据量小的时候不用管,数据量大时这个优化能省不少。

6. 继续把 AI 接进你的 Android 工作流

这套工具类本身不复杂,难的是边界情况多:列名对不上、类型不匹配、NULL 处理、嵌套结构。写的时候如果拿不准某段反射逻辑的输出,可以把代码贴到模型对话里让它帮你推演一遍,比反复跑真机快。地址是 https://taotoken.net/api ,对话入口在 https://taotoken.net/api 对应的模型对话页面。

如果你打算长期在 Android 项目里用 AI 辅助写 DAO、生成测试数据、排查 Cursor 相关报错,可以了解下 Coding Plan,它更适合持续性的编码场景,而不是一次性问答。接入文档和 API Key 管理分别在文档页和 console 里,按需取用就行。

最后留一个实用建议:VO 字段命名尽量和数据库列名保持同一套规范(比如都用下划线或都用驼峰),从源头减少别名转换。工具类只是兜底,规范才是省事的根本。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 18:02:10

2024一人公司必备AI工具:从电商详情页到图文创作全自动化实战指南

2024 一人公司必备 AI 工具&#xff1a;从电商详情页到图文创作的全自动化实战指南 在“一人公司”&#xff08;One Person Company, OPC&#xff09;的浪潮下&#xff0c;独立创作者和微型创业者面临的最大挑战不再是寻找商机&#xff0c;而是**生产力瓶颈**。 当你需要同时兼…

作者头像 李华
网站建设 2026/9/26 18:01:40

给AI编码代理装上“辅助轮”:Trellis框架的规范约束与工程实践

最近在折腾AI编程代理的时候&#xff0c;我越来越觉得一个事儿不对劲&#xff1a;Cursor、Copilot这些工具&#xff0c;单点补全确实香&#xff0c;但一旦让代理去跑一个跨多文件的完整任务&#xff0c;经常会出现“自以为懂了&#xff0c;结果跑偏”的情况。上下文一多&#x…

作者头像 李华
网站建设 2026/9/26 18:01:27

二手交易网站源码全栈解析:从论文到可运行系统

简介&#xff1a;这份资源是二手交易网站项目的论文与源码合集&#xff0c;面向电子商务、网络开发方向的学习者与研究者&#xff0c;尤其适合需要完成课程设计、毕业设计或想深入理解二手电商运作机制的中高级开发者。压缩包为zip格式&#xff0c;整体约53.32MB&#xff0c;文…

作者头像 李华
网站建设 2026/9/26 18:00:42

10 个最佳 Golang 库:用 TaoToken 统一 Key 打通 AI 编码工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 17:59:17

TaoToken 配置 vscode 插件开发:五分钟上手 settings.json 骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华