1. Android 本地存储为什么绕不开 SQLite做 Android 应用只要涉及「数据要留在手机上、下次打开还在」SQLite 基本是默认答案。它是 Android 系统内置的轻量级关系型数据库不需要单独装服务一个 App 一个 db 文件支持事务、索引、SQL 语法适合存用户配置、聊天记录、离线缓存、待办清单这类结构化数据。适合谁刚接触 Android 存储的新手、想把手写 SQL 封装成可维护代码的开发者以及准备在本地数据之上再接入 AI 能力的同学。我见过太多项目把 SQL 语句散落在 Activity 里改一个字段要全局搜字符串升级表结构时直接崩在用户手机上。这篇就按「建库 → 建表 → 增删改查 → 事务 → 版本升级」的主线走一遍交付一份可以直接复制的 SQLiteOpenHelper 骨架和 DBManager 封装。后半段再补上很多同学会忽略的一环本地数据写好了如果还想调用大模型做摘要、分类、问答Key 和通道怎么统一配置。这里用 TaoToken 的 API 通道做示例把 config.toml 片段和验证命令一起给出来让数据持久化和 AI 调用在同一个项目里跑通。核心检索词先摆清楚Android SQLite 使用本质是「用 SQLiteOpenHelper 管理数据库生命周期 用 SQLiteDatabase 执行增删改查」。能做什么本地持久化、事务批量写入、Cursor 查询、版本迁移。适合谁所有需要离线数据的 Android 项目。2. 建库建表SQLiteOpenHelper 骨架SQLiteOpenHelper 是官方给的抽象类负责两件事数据库第一次创建时调 onCreate版本号变大时调 onUpgrade。你只要继承它把建表 SQL 写进去剩下的打开、加锁、版本比对它都帮你做了。先定义数据模型。一个 Person 类字段和表列一一对应package com.scott.db; public class Person { public int _id; public String name; public int age; public String info; public Person() {} public Person(String name, int age, String info) { this.name name; this.age age; this.info info; } }接着是 DBHelper。注意版本号常量以后加字段就靠它触发 onUpgradepackage com.scott.db; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME person.db; private static final int DB_VERSION 1; public static final String TABLE person; public static final String COL_ID _id; public static final String COL_NAME name; public static final String COL_AGE age; public static final String COL_INFO info; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { String sql CREATE TABLE TABLE ( COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, COL_NAME TEXT NOT NULL, COL_AGE INTEGER, COL_INFO TEXT); db.execSQL(sql); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单粗暴的迁移先删后建生产环境请按版本逐级 ALTER db.execSQL(DROP TABLE IF EXISTS TABLE); onCreate(db); } }这里有个细节值得说清楚主键列名用_id不是随便起的。SimpleCursorAdapter 要求结果集里必须有_id列否则直接抛异常。这是 SQLite 在 Android 里的约定建表时就按规范来后面省很多事。3. DBManager 封装增删改查与事务有了 Helper再包一层 DBManager把业务方法集中管理。构造方法里拿到 SQLiteDatabase 实例整个应用共用一份。package com.scott.db; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import java.util.ArrayList; import java.util.List; public class DBManager { private DBHelper helper; private SQLiteDatabase db; public DBManager(Context context) { helper new DBHelper(context); // 放在 Activity 的 onCreate 里实例化确保 context 已初始化 db helper.getWritableDatabase(); } public void add(ListPerson persons) { db.beginTransaction(); try { for (Person p : persons) { db.execSQL(INSERT INTO DBHelper.TABLE VALUES(null, ?, ?, ?), new Object[]{p.name, p.age, p.info}); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } } public void updateAge(Person person) { ContentValues cv new ContentValues(); cv.put(DBHelper.COL_AGE, person.age); db.update(DBHelper.TABLE, cv, DBHelper.COL_NAME ?, new String[]{person.name}); } public void deleteOldPerson(Person person) { db.delete(DBHelper.TABLE, DBHelper.COL_AGE ?, new String[]{String.valueOf(person.age)}); } public ListPerson query() { ArrayListPerson persons new ArrayList(); Cursor c queryTheCursor(); while (c.moveToNext()) { Person p new Person(); p._id c.getInt(c.getColumnIndex(DBHelper.COL_ID)); p.name c.getString(c.getColumnIndex(DBHelper.COL_NAME)); p.age c.getInt(c.getColumnIndex(DBHelper.COL_AGE)); p.info c.getString(c.getColumnIndex(DBHelper.COL_INFO)); persons.add(p); } c.close(); return persons; } public Cursor queryTheCursor() { return db.rawQuery(SELECT * FROM DBHelper.TABLE, null); } public void closeDB() { db.close(); } }几个关键点展开说。批量插入用事务beginTransaction到setTransactionSuccessful之间是原子操作中途出错会整体回滚比一条条 insert 快一个数量级。查询完记得c.close()Cursor 不关会泄漏。closeDB在应用最后一个 Activity 销毁时调用这一步最容易被忘但数据库连接不释放长期运行会出问题。关于getWritableDatabase()和getReadableDatabase()的选择看源码会发现getReadableDatabase()内部先尝试调getWritableDatabase()只有在磁盘满等异常时才降级为只读打开。所以正常情况下两者返回的是同一个实例。直接用getWritableDatabase()拿全局实例是可行的如果你确实担心只读降级场景可以 try-catch 后再降级获取。4. TaoToken 前置统一 Key 与 API 通道本地数据跑通后很多场景会想接一层 AI 能力比如把 SQLite 里的记录做摘要、分类、生成周报。这时候如果每个模型都单独配 Key、单独改 base_url项目会变得很难维护。TaoToken 的思路是提供一个统一的 API 通道把模型调用收敛到一个入口Key 和地址集中管理。先到官网注册并进入控制台创建 API Key官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content控制台创建 Keyhttps://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewriteAPI 基础地址统一用https://taotoken.net/api拿到 Key 之后不要硬编码在 Java 里。推荐放在项目根目录的 config.toml或者用 local.properties BuildConfig 注入。config.toml 片段示例# config.toml [taotoken] base_url https://taotoken.net/api api_key sk-你的Key model claude-sonnet-4-20250514 timeout_seconds 60如果你用的是支持 TOML 配置的客户端或脚本直接读这个文件即可。Android 项目里更常见的做法是把 Key 写进 local.properties再在 build.gradle 里读出来塞进 BuildConfig避免提交到仓库。注意Key 属于敏感凭证不要写进版本控制也不要打包进 APK 的明文资源里。生产环境建议走服务端转发。5. 可复制配置与验证请求配置写好后第一步是验证通道是否通。用 curl 发一个最小请求确认 Key 和地址都对curl -X POST https://taotoken.net/api/v1/messages \ -H Content-Type: application/json \ -H x-api-key: sk-你的Key \ -H anthropic-version: 2023-06-01 \ -d { model: claude-sonnet-4-20250514, max_tokens: 128, messages: [ {role: user, content: 用一句话说明SQLite事务的作用} ] }返回体里能看到content数组说明通道正常。如果返回 401检查 Key 是否复制完整返回 404检查 base_url 有没有多写或少写/v1。在 Android 侧把 SQLite 查询结果拼成 prompt 再发请求逻辑上就是「读本地 → 组消息 → 调 API → 写回本地」。下面是一个用 OkHttp 发请求的骨架// 伪代码骨架重点看参数组织 String json { \model\:\ BuildConfig.TAO_MODEL \, \max_tokens\:256, \messages\:[{\role\:\user\,\content\:\ prompt \}] }; Request request new Request.Builder() .url(BuildConfig.TAO_BASE_URL /v1/messages) .addHeader(x-api-key, BuildConfig.TAO_API_KEY) .addHeader(anthropic-version, 2023-06-01) .addHeader(Content-Type, application/json) .post(RequestBody.create(json, MediaType.parse(application/json))) .build();prompt 里可以把 Person 表的记录序列化进去比如「以下是用户最近的记录请归纳成三条要点」。返回结果再通过 DBManager 的 update 方法写回某一列本地数据和 AI 结果就串起来了。想先在网页上试模型效果可以直接用模型对话页面模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite如果你是要长期在 IDE 里做编码、跑 Agent 任务建议看 Coding Plan额度模型更适合持续调用Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite接入细节和参数说明都在文档里接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite6. 本篇常见错排查报错一no such table: person多数是 DBHelper 的 onCreate 没执行或者数据库版本号没变但表结构改了。检查 DB_VERSION 是否递增onUpgrade 里有没有正确处理。另一个常见原因是 Context 传了 null导致 openOrCreateDatabase 失败。报错二CursorIndexOutOfBoundsExceptiongetColumnIndex返回 -1 时直接取值就会越界。用getColumnIndexOrThrow替代列名写错时能立刻定位。列名建议统一用常量别手写字符串。报错三SimpleCursorAdapter 不显示数据九成是结果集里没有_id列。解决办法有三种建表时主键就叫_id查询时用别名SELECT id AS _id FROM person或者在 CursorWrapper 里重写getColumnIndexOrThrow做映射。报错四事务没生效或数据没写进去检查setTransactionSuccessful()是否在endTransaction()之前调用。只有标记成功endTransaction 才会提交否则回滚。另外事务里不要做网络请求会长时间持锁。报错五API 返回 401 / 403Key 失效、复制时带了空格、或者请求头字段名写错。Anthropic 风格用x-api-keyOpenAI 风格用Authorization: Bearer别混用。base_url 确认是https://taotoken.net/api不要自己拼错路径。报错六Cursor 泄漏导致 ANR查询完必须c.close()或者用 try-with-resources。Activity 里可以用startManagingCursor交给系统按生命周期管理但新版本更推荐手动关闭或用 Room。7. 继续深入的方向SQLite 这套骨架跑通后下一步可以往三个方向走。一是把 DBManager 换成 Room用注解生成代码编译期校验 SQL减少手写错误。二是加索引对经常查询的列建 index数据量上万后差别很明显。三是把本地数据和 AI 调用做成流水线比如定时把新增记录批量送去摘要结果写回新表。如果你在接入 AI 通道时想省去逐个模型配 Key 的麻烦可以按上面的 config.toml 把 base_url 和 Key 统一到 TaoToken再配合 API Keys 页面管理多套凭证。编码场景长期用的话Coding Plan 的额度模型会比按次调用更划算。先把 SQLite 的增删改查和事务写扎实再叠 AI 能力整个链路会稳很多。
