1. 从一次「数据丢了」说起:Android SQLite 数据持久化到底解决什么问题
刚学 Android 那会儿,我写了个记账小 Demo,用 ArrayList 存数据,界面跑得好好的。结果手机一重启,所有记录全没了——那一刻才真正理解什么叫「内存数据不持久」。Android 里数据持久化主要有三条路:SharedPreferences 存键值对、文件存流、SQLite 存结构化数据。前两个适合小数据,一旦你要做「按条件查、按时间排序、批量更新、事务保证一致性」,SQLite 就是绕不开的那一个。
SQLite 是 Android 系统自带的关系型数据库,轻量、单文件、零配置。一个数据库就是一个.db文件,默认躺在data/data/<包名>/databases/目录下。它支持标准 SQL,支持事务,支持索引,对入门阶段理解「关系型数据库怎么用代码操作」非常友好。你不需要装 MySQL、不需要起服务,写几行 Java/Kotlin 就能建库建表。
这篇文章聚焦一个完整可跑的落地场景:用SQLiteOpenHelper建库建表,封装一套 DAO,把 insert / query / update / delete 四个操作逐个验证,再加一个事务回滚的对照实验。同时,调试期如果 Demo 里要调接口(比如把本地数据同步到某个服务),我会用 TaoToken 统一管理 Key 和 API 通道,避免 Key 散落在代码里。适合谁看:刚学完 Activity 和 ListView、想搞懂本地存储的 Android 入门同学;也适合想复习 SQLite 事务细节的开发者。
预期目标很明确:跟着做完,你能得到一个能运行、能用 Logcat 验证、能自己改表结构的本地持久化 Demo,并且知道每个报错大概出在哪。
2. 前置准备:TaoToken 统一 Key 与 API 通道,调试期接口不再乱
在动手写 SQLite 之前,先把「调试期接口调用」这件事理顺。很多入门 Demo 到后面会加一个「上传/同步」按钮,如果直接把 Key 硬编码在Constants.java里,一是容易泄露,二是换环境要改代码。我的做法是用 TaoToken 做统一入口:一个 Key、一个 Base URL,模型对话、编码辅助、接口调试都走同一条通道。
TaoToken 在这里扮演的是「统一 API 网关」的角色,不是数据库本身。SQLite 负责本地持久化,TaoToken 负责你在调试阶段需要调用的模型/接口能力。两者职责分开,代码结构才干净。
先拿到 Key。打开官网https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,注册后在控制台创建 API Key。控制台地址是https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite,Key 管理页在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite。创建完复制那串sk-开头的字符串,只显示一次,先存到本地密码管理器。
Base URL 统一用https://taotoken.net/api,注意这个地址不带任何查询参数,是纯 API 端点。模型 ID 按你实际要用的填,比如做代码补全可以用 coding 相关的模型,做对话验证用通用对话模型。这三个要素——Base URL、Key、Model ID——就是后面所有配置的「三件套」,缺一个都会报 401 或 model not found。
如果你用 Claude Code 做辅助编码,可以在它的配置里指向 TaoToken 的 Anthropic 兼容入口,文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite。想先验证 Key 是否可用,直接去模型对话页https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite发一句话,能返回就说明通道没问题。长期做编码或 Agent 任务的话,Coding Plan 页面在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite,按需选。
这里要强调一点:TaoToken 是合规的 API 服务入口,不要把它和任何网络代理工具混为一谈。我们只是用它来统一管理调试期的接口凭证,让 Key 不散落在各个文件里。
3. 可复制配置:SQLiteOpenHelper 建库建表 + DAO 封装 + TaoToken 三件套
这一节是核心,所有代码都可以直接复制。先建一个DbHelper继承SQLiteOpenHelper,负责建库建表。
public class DbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "study.db"; private static final int DB_VERSION = 1; public static final String TABLE_USER = "t_user"; public DbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { String sql = "CREATE TABLE " + TABLE_USER + " (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + "name TEXT NOT NULL, " + "age INTEGER DEFAULT 0, " + "score REAL DEFAULT 0.0, " + "created_at INTEGER)"; db.execSQL(sql); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TABLE_USER); onCreate(db); } }onCreate只在数据库第一次创建时调用,所以建表语句放这里。onUpgrade在版本号变大时触发,入门阶段直接删表重建最省事,生产环境要做数据迁移。
接着封装 DAO,把增删改查包起来。用ContentValues装数据,避免手拼 SQL 字符串。
public class UserDao { private final DbHelper helper; public UserDao(Context context) { this.helper = new DbHelper(context); } public long insert(String name, int age, double score) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("name", name); cv.put("age", age); cv.put("score", score); cv.put("created_at", System.currentTimeMillis()); long rowId = db.insert(DbHelper.TABLE_USER, null, cv); db.close(); return rowId; } public List<String> queryAll() { List<String> result = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query(DbHelper.TABLE_USER, null, null, null, null, null, "score DESC"); while (cursor.moveToNext()) { String name = cursor.getString(cursor.getColumnIndexOrThrow("name")); int age = cursor.getInt(cursor.getColumnIndexOrThrow("age")); double score = cursor.getDouble(cursor.getColumnIndexOrThrow("score")); result.add(name + " / " + age + " / " + score); } cursor.close(); db.close(); return result; } public int updateScore(String name, double newScore) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("score", newScore); int rows = db.update(DbHelper.TABLE_USER, cv, "name = ?", new String[]{name}); db.close(); return rows; } public int deleteByName(String name) { SQLiteDatabase db = helper.getWritableDatabase(); int rows = db.delete(DbHelper.TABLE_USER, "name = ?", new String[]{name}); db.close(); return rows; } }注意query的第三个参数是 where 子句,第四个是参数数组,用?占位能防注入。getColumnIndexOrThrow比getColumnIndex更安全,列名写错会直接抛异常而不是返回 -1。
TaoToken 的三件套配置,如果你用local.properties或BuildConfig管理,可以这样写。以settings风格的 JSON 为例,放在项目根目录的调试配置里:
{ "taotoken": { "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model_id": "你的模型ID" } }如果是 TOML 风格(比如某些 CLI 工具用),对应写法:
[taotoken] base_url = "https://taotoken.net/api" api_key = "sk-你的Key" model_id = "你的模型ID"三件套必须齐全:Base URL 决定请求打到哪,Key 决定身份,Model ID 决定用哪个模型。少任何一个,请求都会失败。Android 里读取时建议用BuildConfig字段注入,不要把 Key 提交到 Git。
4. 验证请求:Logcat 跑通增删改查与事务回滚
代码写完,跑起来验证。在 Activity 里调用 DAO,用 Logcat 看结果。
UserDao dao = new UserDao(this); long id1 = dao.insert("张三", 20, 88.5); long id2 = dao.insert("李四", 22, 91.0); long id3 = dao.insert("王五", 19, 76.5); Log.d("SQLITE_DEMO", "insert ids: " + id1 + "," + id2 + "," + id3); List<String> all = dao.queryAll(); for (String s : all) { Log.d("SQLITE_DEMO", "query: " + s); } int updated = dao.updateScore("张三", 95.0); Log.d("SQLITE_DEMO", "updated rows: " + updated); int deleted = dao.deleteByName("王五"); Log.d("SQLITE_DEMO", "deleted rows: " + deleted);预期 Logcat 输出:insert 返回三个自增 id(1、2、3),query 按 score 降序返回「李四 / 22 / 91.0」「张三 / 20 / 88.5」「王五 / 19 / 76.5」,update 返回 1,delete 返回 1。如果 query 顺序不对,检查orderBy参数是不是写成了"score DESC"。
事务验证是重点。SQLite 默认每条语句自动提交,事务能把多条操作打包,要么全成功要么全回滚。写一个对照实验:
SQLiteDatabase db = helper.getWritableDatabase(); db.beginTransaction(); try { ContentValues cv = new ContentValues(); cv.put("name", "事务A"); cv.put("age", 30); cv.put("score", 60.0); db.insert(DbHelper.TABLE_USER, null, cv); cv.clear(); cv.put("name", "事务B"); cv.put("age", 31); cv.put("score", 61.0); db.insert(DbHelper.TABLE_USER, null, cv); if (true) { throw new RuntimeException("模拟异常,触发回滚"); } db.setTransactionSuccessful(); } catch (Exception e) { Log.e("SQLITE_DEMO", "事务异常: " + e.getMessage()); } finally { db.endTransaction(); db.close(); }跑完再 query 一次,你会发现「事务A」和「事务B」都没进去——因为异常在setTransactionSuccessful()之前抛出,endTransaction()检测到没标记成功就回滚了。把throw那行注释掉再跑,两条记录都会出现。这就是事务的原子性,转账场景必须用。
TaoToken 的验证请求可以单独做:在模型对话页发一句「你好」,或者用 curl 测:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{"model":"你的模型ID","messages":[{"role":"user","content":"ping"}]}'返回 200 且有 choices 字段,说明 Key 和通道都正常。这一步和 SQLite 无关,但调试期接口通了,后面加同步功能就不会卡在鉴权上。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
入门阶段最容易撞的几个坑,我按真实报错对照说。
401 Unauthorized:Key 错了、过期了、或者没带Bearer前缀。检查Authorization头是不是Bearer sk-xxx,中间有空格。如果 Key 是从网页复制的,注意别把首尾空格带进去。TaoToken 的 Key 在 API Keys 页面重新生成即可。
local proxy failed / connection refused:这类报错通常是 Base URL 写错,或者本地网络环境有问题。确认 Base URL 是https://taotoken.net/api,不要多加/v1之外的路径,也不要带 UTM 参数到 API 请求里。如果你在 Android 模拟器里请求,localhost指向的是模拟器自己,要用10.0.2.2映射宿主机——但 TaoToken 是公网地址,直接用域名即可。
reading choices 报错 / choices 字段为空:一般是请求体 JSON 格式不对,或者 Model ID 写错。检查messages是不是数组,model字段是不是和平台一致。返回体里如果没有choices,先打印完整响应看error字段说了什么。
OAuth 相关报错:如果你用 Claude Code 或某些 CLI 工具接入,报 OAuth 失败通常是认证方式选错了。这类工具应该用 API Key 模式而不是 OAuth 模式,配置里填 Base URL + Key + Model ID 三件套。文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite有对应说明。
SQLite 这边的常见错:no such table说明onCreate没执行或表名写错,卸载重装 App 能强制重建;UNIQUE constraint failed说明插了重复的唯一键;CursorWindowAllocationException通常是 Cursor 没关或者一次查太多数据,记得cursor.close()。
还有一个隐蔽的:getWritableDatabase()在主线程调用可能触发StrictMode警告,入门 Demo 无所谓,但要知道生产环境应该放子线程。事务里如果忘了setTransactionSuccessful(),数据不会提交,这个坑我踩过,查了半天以为 insert 失败,其实是回滚了。
6. 继续往下走:把 Demo 变成你自己的项目
到这里,一个能跑通增删改查和事务回滚的 SQLite Demo 就完成了。你可以在这个基础上做几件事:把queryAll改成带分页的limit查询,试试rawQuery预编译防注入,或者给name加个索引看查询速度变化。表结构改了就升DB_VERSION,观察onUpgrade触发时机。
调试期接口这块,TaoToken 的 Key 统一管理能帮你省掉「这个文件改完那个文件忘了改」的麻烦。需要验证模型通道就去模型对话页https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite,要管 Key 去 API Keys 页https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite,接入细节查文档https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite。长期做编码任务的话,Coding Plan 在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。
最后留个练习:给t_user表加一个email列,写一个onUpgrade用ALTER TABLE而不是删表重建,然后插一条数据验证旧数据还在。这个练完,SQLite 的版本管理你就真懂了。