首页/新闻资讯/正文详情

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

发布时间:2026/9/26 18:02:19 来源:云帆数科 栏目:资讯中心
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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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.SQLiteDatabasejava.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 MapClass?, 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, ClassT clazz) { return queryOne(db, sql, null, clazz); } /** 带占位参数的查询单条 */ public static T T queryOne(SQLiteDatabase db, String sql, String[] args, ClassT clazz) { Cursor cursor db.rawQuery(sql, args); try { if (cursor.moveToFirst()) { return cursorToVo(cursor, clazz); } return null; } finally { cursor.close(); } } /** 查询列表 */ public static T ListT queryList(SQLiteDatabase db, String sql, ClassT clazz) { return queryList(db, sql, null, clazz); } /** 带占位参数的查询列表 */ public static T ListT queryList(SQLiteDatabase db, String sql, String[] args, ClassT clazz) { Cursor cursor db.rawQuery(sql, args); ListT 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, ClassT 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_nameVO 字段是userName那查询就写SELECT user_name AS userName FROM ...。这一点是整套方案能成立的前提后面排障会专门讲不一致时怎么定位。getColumnIndex返回 -1 的处理。如果 Cursor 里没有这个列直接continue不抛异常。这样 VO 里可以放一些「非数据库字段」的辅助属性比如public boolean selected;查询时不会被误伤。NULL 值处理。cursor.isNull(columnIndex)为 true 时跳过赋值字段保持 Java 默认值对象是 nullint 是 0。这里有个取舍如果你希望数据库 NULL 映射成空字符串可以在 String 分支里把cursor.getString的结果做一次null - 的转换。我倾向于保持 null因为「没填」和「填了空串」在业务上往往是两回事。基本类型与包装类。wrap()把int.class映射到Integer.class这样 VO 里写public int age;也能正常走Integer分支。注意field.set对基本类型字段会自动拆箱所以传Integer进去没问题。4. 验证请求查询后自动封装并打印 VO4.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); // 查询列表 ListUserVo 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{id1, userName张三, age28, score91.5, isViptrue, createTime1700000000000} 列表项: UserVo{id2, userName李四, age34, score78.0, isVipfalse, createTime1700000001000} 列表项: UserVo{id1, userName张三, age28, score91.5, isViptrue, createTime1700000000000}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 字段是intcursor.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如果列表有几千行可以优化成先建一个MapString, Integer缓存列名到索引的映射在queryList里只建一次。数据量小的时候不用管数据量大时这个优化能省不少。6. 继续把 AI 接进你的 Android 工作流这套工具类本身不复杂难的是边界情况多列名对不上、类型不匹配、NULL 处理、嵌套结构。写的时候如果拿不准某段反射逻辑的输出可以把代码贴到模型对话里让它帮你推演一遍比反复跑真机快。地址是 https://taotoken.net/api 对话入口在 https://taotoken.net/api 对应的模型对话页面。如果你打算长期在 Android 项目里用 AI 辅助写 DAO、生成测试数据、排查 Cursor 相关报错可以了解下 Coding Plan它更适合持续性的编码场景而不是一次性问答。接入文档和 API Key 管理分别在文档页和 console 里按需取用就行。最后留一个实用建议VO 字段命名尽量和数据库列名保持同一套规范比如都用下划线或都用驼峰从源头减少别名转换。工具类只是兜底规范才是省事的根本。

相关推荐

WorkBuddy+Flask+SQLite:个人建站与日更实战指南
WorkBuddy+Flask+SQLite:个人建站与日更实战指南

1. 为什么我选择 WorkBuddy Flask SQLite 这套组合1.1 从"想做个站"到"真的跑起来"之间差了什么很多人对建站的理解停留在两个极端:要么觉得必须学完整套前端后端才能动手,要么以为拖拽几下就能上线一个能用的产品。我刚开始也是这… · 2026/9/26 18:02:19

如何用Expect为每次PR加上真实浏览器验证:GitHub Actions部署与CI模式实战
如何用Expect为每次PR加上真实浏览器验证:GitHub Actions部署与CI模式实战

如何用Expect为每次PR加上真实浏览器验证:GitHub Actions部署与CI模式实战 【免费下载链接】expect Expect tests your agents code in a real browser 项目地址: https://gitcode.com/gh_mirrors/expect6/expect Expect 是一个面向 AI 编码 Agent 的真实浏览… · 2026/9/26 18:02:19

SpringBoot集成ONLYOFFICE:在线协同编辑与JWT回调实战指南
SpringBoot集成ONLYOFFICE:在线协同编辑与JWT回调实战指南

1. 项目概述:为什么要在SpringBoot里集成ONLYOFFICE先说个我踩过的坑。之前在做一个内部文档管理系统,需求很直白:业务部门要在网页里直接编辑Word/Excel,还要能多人同时改一份标书。一开始想的方案是前端用现成编辑器组件&#x… · 2026/9/26 18:02:19

个税APP年度汇算实操指南:从查收入到退税一次讲清
个税APP年度汇算实操指南:从查收入到退税一次讲清

三四月的个税话题,每年都会准时热起来。“你退税了吗”成了办公室里的固定开场白。不少人第一次打开个人所得税APP,直接冲进“综合所得年度汇算”,结果有人顺利退到钱,也有人对着页面上的“应补税额”发懵。同一个APP,… · 2026/9/26 18:36:17

Core Temp小白实操指南:30分钟看懂电脑真实温度
Core Temp小白实操指南:30分钟看懂电脑真实温度

1. 这不是“又一个温度监控软件介绍”,而是电脑小白真正能用起来的实操现场CORE TEMP——这三个字母在硬件发烧友圈里,几乎等同于“温度准不准”的默认标尺。但对绝大多数普通用户来说,它更像一个藏在系统托盘里的神秘图标:右键点… · 2026/9/26 18:36:17

SolidWorks 2025 安装全流程避坑指南:从下载到启动一次搞定
SolidWorks 2025 安装全流程避坑指南:从下载到启动一次搞定

/* 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 18:36:17

Android Studio推箱子Java小游戏源码解析与实战指南
Android Studio推箱子Java小游戏源码解析与实战指南

简介:这份资源是基于Android Studio开发的推箱子小游戏完整项目,面向计算机相关专业在校学生、教师及企业员工,尤其适合作为毕业设计、课程设计、作业或项目初期立项演示的参考素材,也适合Java与Android入门者进阶练手。压缩包共5… · 2026/9/26 18:36:17

Android Studio推箱子Java小游戏实战:从工程搭建到关卡加载
Android Studio推箱子Java小游戏实战:从工程搭建到关卡加载

简介:这是一份基于Android Studio开发的推箱子小游戏完整项目源码,面向计算机相关专业在校学生、教师及企业员工,尤其适合作为毕业设计、课程设计、作业或项目初期立项演示的参考素材,也适合Java与Android初学者进阶练手。压缩包共… · 2026/9/26 18:36:17

从零到上线:多Agent协作与全栈开发实战
从零到上线:多Agent协作与全栈开发实战

从零到上线:一个真实项目教你 多 Agent 协作与全栈开发说实话,第一次接触多 Agent 协作这个概念时,我内心是有点抵触的。当时觉得这玩意儿不过是把几个 prompt 拼在一起,套上一个"智能体协作"的壳子,本质还是… · 2026/9/26 18:35:57

数据库课后习题答案别硬背:当测试用例集刷,效率翻倍
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍

简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21

OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置

/* 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 0:00:40

向下兼容与向上兼容:接口设计中的兼容性策略与工程实践
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践

一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码