ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Android数据库之SQLiteDatabase类:从建库到事务的完整实践

Android数据库之SQLiteDatabase类:从建库到事务的完整实践 1. 为什么 Android 本地存储绕不开 SQLiteDatabase 类做 Android 应用只要涉及「数据要留下来、下次打开还在」的需求本地存储就是绕不开的一环。SharedPreferences 适合存几个开关和配置项文件存储适合放图片和日志但一旦你要存的是「一批结构化的记录」——比如客户名单、订单流水、聊天消息、离线缓存列表——那 SQLiteDatabase 类基本就是标准答案。SQLiteDatabase 是 Android 系统内置的数据库操作核心类它封装了 SQLite 引擎让你不用引入任何第三方库就能在 App 里建库、建表、增删改查、跑事务。它最大的价值在于零依赖、随 App 打包、支持标准 SQL、支持事务回滚。对于中小型 App 或者需要离线优先的场景这套方案足够稳。这篇文章适合谁适合刚接触 Android 数据持久化、被onCreate/onUpgrade搞晕、或者写 DAO 时不知道insert返回 -1 是什么意思的开发者。我会从建库开始把SQLiteOpenHelper的建库升级逻辑讲清楚再给一套可以直接复制的 DAO 封装最后用事务回滚验证把「数据一致性」这件事落地。全程代码可跑参数可对照。先说清楚一个容易混淆的点SQLiteOpenHelper负责「库和表的生命周期」SQLiteDatabase负责「具体的数据操作」。很多人把两者混在一起写结果升级逻辑和业务逻辑缠成一团。正确的分工是Helper 只管建库、升级、拿数据库实例DAO 只管拿着SQLiteDatabase做 CRUD。下面按这个思路展开。2. 建库与升级SQLiteOpenHelper 的 onCreate 与 onUpgrade 实战2.1 先理解两个回调的触发时机SQLiteOpenHelper有两个必须重写的抽象方法onCreate(SQLiteDatabase db)和onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)。onCreate只在数据库文件第一次被创建时调用一次。注意是「数据库文件不存在」时不是「表不存在」时。所以建表语句写在这里App 第一次装上去打开数据库就会执行。onUpgrade在传入的版本号比数据库文件里记录的版本号大时调用。比如你第一版version 1第二版改成version 2用户升级 App 后打开数据库系统发现 2 1就回调onUpgrade。这里是你做表结构变更、加字段、加表的地方。我试过把建表语句写在onCreate外面手动调用结果新装用户正常、老用户升级后表结构没变排查半天才发现是版本号没动。所以记住改表结构必须同时改版本号否则onUpgrade根本不会触发。2.2 完整可复制的 Helper 代码下面这个CustomerDbHelper建了一张Customers表包含自增主键_id、姓名Name、地址Address并演示了从 v1 升级到 v2 时新增Phone字段的写法。package com.example.customerdb; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; public class CustomerDbHelper extends SQLiteOpenHelper { private static final String DB_NAME customer.db; private static final int DB_VERSION 2; public static final String TABLE_NAME Customers; public static final String COL_ID _id; public static final String COL_NAME Name; public static final String COL_ADDRESS Address; public static final String COL_PHONE Phone; private static final String SQL_CREATE_V1 CREATE TABLE TABLE_NAME ( COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, COL_NAME TEXT NOT NULL, COL_ADDRESS TEXT); private static final String SQL_CREATE_V2 CREATE TABLE TABLE_NAME ( COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, COL_NAME TEXT NOT NULL, COL_ADDRESS TEXT, COL_PHONE TEXT); public CustomerDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { // 首次创建直接建最新版本的表结构 db.execSQL(SQL_CREATE_V2); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 逐版本升级避免跨版本丢数据 if (oldVersion 2) { db.execSQL(ALTER TABLE TABLE_NAME ADD COLUMN COL_PHONE TEXT); } } }这里有个细节值得说onCreate里我直接建了 v2 的表结构而不是先建 v1 再升级。因为新装用户没有历史数据直接给最新结构最省事。而onUpgrade里用if (oldVersion 2)逐版本判断是为了将来升到 v3、v4 时能按顺序执行不会漏掉中间步骤。2.3 关于版本号与降级的坑SQLiteOpenHelper的构造函数第四个参数是版本号。如果你把版本号从 2 改回 1系统会认为要「降级」默认会抛SQLiteDowngradeFailedException。生产环境不要随便降版本号真要降级得重写onDowngrade并自己处理数据。另外getWritableDatabase()和getReadableDatabase()的区别也常被问。前者返回可写库磁盘满时会抛异常后者在磁盘满时可能返回只读库。日常 CRUD 直接用getWritableDatabase()就行它内部也会处理只读情况。3. DAO 封装insert/query/update/delete 与事务的可复制配置3.1 一套完整的 DAO 类把数据库操作集中到一个 DAO 类里业务层只调方法、不碰 SQL这是最省心的做法。下面这个CustomerDao覆盖了增删改查和事务。package com.example.customerdb; import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import java.util.ArrayList; import java.util.List; public class CustomerDao { private final CustomerDbHelper helper; public CustomerDao(CustomerDbHelper helper) { this.helper helper; } // 增 public long insert(String name, String address, String phone) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(CustomerDbHelper.COL_NAME, name); values.put(CustomerDbHelper.COL_ADDRESS, address); values.put(CustomerDbHelper.COL_PHONE, phone); return db.insert(CustomerDbHelper.TABLE_NAME, null, values); } // 删 public int delete(long id) { SQLiteDatabase db helper.getWritableDatabase(); return db.delete(CustomerDbHelper.TABLE_NAME, CustomerDbHelper.COL_ID ?, new String[]{String.valueOf(id)}); } // 改 public int update(long id, String newAddress) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(CustomerDbHelper.COL_ADDRESS, newAddress); return db.update(CustomerDbHelper.TABLE_NAME, values, CustomerDbHelper.COL_ID ?, new String[]{String.valueOf(id)}); } // 查单条 public Customer queryById(long id) { SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.query(CustomerDbHelper.TABLE_NAME, new String[]{CustomerDbHelper.COL_ID, CustomerDbHelper.COL_NAME, CustomerDbHelper.COL_ADDRESS, CustomerDbHelper.COL_PHONE}, CustomerDbHelper.COL_ID ?, new String[]{String.valueOf(id)}, null, null, null); Customer customer null; if (cursor ! null) { if (cursor.moveToFirst()) { customer readCustomer(cursor); } cursor.close(); } return customer; } // 查全部 public ListCustomer queryAll() { SQLiteDatabase db helper.getReadableDatabase(); ListCustomer list new ArrayList(); Cursor cursor db.query(CustomerDbHelper.TABLE_NAME, null, null, null, null, null, CustomerDbHelper.COL_ID DESC); if (cursor ! null) { while (cursor.moveToNext()) { list.add(readCustomer(cursor)); } cursor.close(); } return list; } private Customer readCustomer(Cursor cursor) { Customer c new Customer(); c.id cursor.getLong(cursor.getColumnIndexOrThrow(CustomerDbHelper.COL_ID)); c.name cursor.getString(cursor.getColumnIndexOrThrow(CustomerDbHelper.COL_NAME)); c.address cursor.getString(cursor.getColumnIndexOrThrow(CustomerDbHelper.COL_ADDRESS)); c.phone cursor.getString(cursor.getColumnIndexOrThrow(CustomerDbHelper.COL_PHONE)); return c; } }配套的实体类很简单public class Customer { public long id; public String name; public String address; public String phone; }3.2 事务处理beginTransaction 与 setTransactionSuccessful事务是保证「要么全成功、要么全回滚」的关键。比如批量插入 100 条客户数据中途失败不应该留下半截数据。标准写法是public void insertBatch(ListCustomer customers) { SQLiteDatabase db helper.getWritableDatabase(); db.beginTransaction(); try { for (Customer c : customers) { ContentValues values new ContentValues(); values.put(CustomerDbHelper.COL_NAME, c.name); values.put(CustomerDbHelper.COL_ADDRESS, c.address); values.put(CustomerDbHelper.COL_PHONE, c.phone); db.insert(CustomerDbHelper.TABLE_NAME, null, values); } db.setTransactionSuccessful(); // 标记成功不调用就会回滚 } finally { db.endTransaction(); // 必须放在 finally } }这里最容易踩的坑是setTransactionSuccessful()必须在endTransaction()之前调用而且endTransaction()一定要放finally。如果中间抛异常没调setTransactionSuccessfulendTransaction就会自动回滚。我见过有人把endTransaction写在 try 里异常一抛就永远不结束事务数据库直接锁死。3.3 用 JSON 描述表结构便于团队对齐多人协作时把表结构写成 JSON 放在仓库里比口头说「加个字段」靠谱得多。下面这份配置可以直接放进项目文档{ database: customer.db, version: 2, tables: [ { name: Customers, columns: [ { name: _id, type: INTEGER, constraint: PRIMARY KEY AUTOINCREMENT }, { name: Name, type: TEXT, constraint: NOT NULL }, { name: Address, type: TEXT, constraint: }, { name: Phone, type: TEXT, constraint: } ] } ] }这份 JSON 和上面的SQL_CREATE_V2完全对应改结构时两边一起改Code Review 时一眼能看出差异。4. 验证请求与成功结果跑一遍 CRUD 和事务回滚4.1 在 Activity 里验证 CRUD写个简单的测试入口把增删改查跑一遍看日志输出。CustomerDbHelper helper new CustomerDbHelper(this); CustomerDao dao new CustomerDao(helper); // 插入 long id dao.insert(张三, 北京市朝阳区, 13800000000); Log.d(DB_TEST, insert id id); // 查询单条 Customer c dao.queryById(id); Log.d(DB_TEST, query - c.name , c.address , c.phone); // 更新 int updated dao.update(id, 北京市海淀区); Log.d(DB_TEST, updated rows updated); // 再查 Customer c2 dao.queryById(id); Log.d(DB_TEST, after update - c2.address); // 删除 int deleted dao.delete(id); Log.d(DB_TEST, deleted rows deleted);预期日志insert id 1 query - 张三, 北京市朝阳区, 13800000000 updated rows 1 after update - 北京市海淀区 deleted rows 1insert返回的是新记录的行号失败返回 -1。update和delete返回受影响的行数返回 0 说明条件没匹配到记录。这几个返回值一定要判断不然数据没写进去你都不知道。4.2 验证事务回滚事务回滚怎么验证故意在批量插入中间抛异常看前面的数据有没有留下。public void insertBatchWithFailure(ListCustomer customers) { SQLiteDatabase db helper.getWritableDatabase(); db.beginTransaction(); try { for (int i 0; i customers.size(); i) { if (i 2) { throw new RuntimeException(模拟第3条插入失败); } ContentValues values new ContentValues(); values.put(CustomerDbHelper.COL_NAME, customers.get(i).name); db.insert(CustomerDbHelper.TABLE_NAME, null, values); } db.setTransactionSuccessful(); } catch (RuntimeException e) { Log.e(DB_TEST, 事务异常将回滚: e.getMessage()); } finally { db.endTransaction(); } }调用后查询queryAll()如果返回空列表说明前两条也被回滚了事务生效。如果返回两条说明回滚没生效检查是不是漏了beginTransaction或者setTransactionSuccessful被误调。实测下来这个验证步骤能帮你快速确认事务边界写对了没有。很多线上数据不一致的问题根源就是事务没包住或者提前提交了。4.3 用 Cursor 时的资源释放Cursor用完必须close()否则会泄漏。上面 DAO 里每个查询都在finally之外手动 close 了因为查询逻辑简单、没有异常分支。如果查询逻辑复杂建议用 try-finallyCursor cursor null; try { cursor db.query(...); // 处理 } finally { if (cursor ! null) { cursor.close(); } }Android 后来提供了CursorLoader和 Room但理解Cursor的手动管理仍然是基本功尤其是维护老项目时。5. 本篇常见错误排查401、local proxy failed、reading choices 与 OAuth这一节把开发过程中真实会撞到的报错列出来对照排查。报错一android.database.sqlite.SQLiteException: no such table: Customers原因通常是onCreate没执行或者表名拼错。检查两点数据库版本号是否变过导致走了onUpgrade而没建表TABLE_NAME常量在 SQL 和查询里是否一致。如果改过表名记得在onUpgrade里做迁移。报错二insert返回 -1insert失败返回 -1常见原因是NOT NULL约束被违反或者主键冲突。检查ContentValues里有没有漏掉必填字段。比如Name是NOT NULL你传了 null就会失败。报错三Cannot perform this operation because the connection pool has been closed这是数据库连接被提前关闭了。通常是你在某个地方调了db.close()但 DAO 还在用同一个 helper。SQLiteOpenHelper管理的连接是复用的不要手动 close交给系统管理。报错四local proxy failed或401 Unauthorized如果你在 App 里接入了远程 API比如用 TaoToken 统一通道调用大模型做数据清洗或语义分析遇到401一般是 Key 没带对或过期local proxy failed通常是本地网络配置或 Base URL 写错。排查顺序先确认 Base URL 是https://taotoken.net/api再确认请求头里Authorization: Bearer 你的Key格式正确最后确认 Key 在控制台里还有效。报错五reading choices解析失败调用模型接口返回的 JSON 里choices字段读不到多半是响应体不是预期结构比如返回了错误对象。打印完整响应体再解析别直接getJSONArray(choices)。报错六OAuth 回调失败如果用 OAuth 方式接入回调地址必须和控制台配置的一致redirect_uri差一个斜杠都会失败。检查 App 里配置的回调 scheme 和平台登记的是否完全匹配。排查这类问题的通用思路先看完整报错堆栈再确认配置三件套Base URL、Key、Model ID是否齐全最后用最小请求验证。不要一上来就改代码先确认配置。6. 从本地数据库到统一 API 通道TaoToken 接入说明本地 SQLite 解决了「数据存下来」的问题但很多场景还需要「数据用起来」——比如把本地客户记录做语义去重、批量生成摘要、或者让 Agent 读取本地数据做决策。这时候就需要一个稳定的模型调用通道。TaoToken 提供统一的 Key 和 API 通道把模型调用、Coding Plan、控制台管理收敛到一个入口。对于 Android 开发者来说最实用的场景是在 App 里做离线数据的智能处理或者用 Coding Plan 辅助写 DAO 和 SQL。接入步骤很直接。先到控制台创建 API Keyhttps://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite然后在代码里配置三件套。Base URL 固定为https://taotoken.net/api请求示例以 HTTP 调用为例// 伪代码示意实际用 OkHttp 或 Retrofit String baseUrl https://taotoken.net/api; String apiKey 你的Key; String modelId 你的Model ID; // 请求头 headers.put(Authorization, Bearer apiKey); headers.put(Content-Type, application/json);Model ID 在模型对话页面可以查到先验证模型能不能通https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite如果你要长期做编码和 Agent 相关的工作Coding Plan 更划算https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite接入文档在这里参数和错误码都有说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewriteAPI Key 管理页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite一个实用技巧把本地 SQLite 的查询结果序列化成 JSON再发给模型做处理比让模型直接读数据库安全得多。数据库连接不要暴露给外部服务本地数据本地管需要智能处理时再走 API 通道。最后留一个我踩过的坑onUpgrade里做ALTER TABLE时如果表数据量大升级会卡主线程。建议把升级逻辑放到子线程或者用WAL模式提升并发读写性能。开启方式是在onConfigure里调db.enableWriteAheadLogging()。这个细节在数据量上来之后差别很明显。
返回列表