---内置数据库SQLite的使用与TaoToken配置实践)
1. Android Studio 里 SQLite 到底能做什么适合谁上手SQLite 是 Android 系统自带的一个轻量级关系型数据库它不需要单独安装服务、不需要账号密码一个.db文件就是一个完整数据库。你在 Android Studio 里写一个 App只要涉及用户登录信息要记住离线缓存列表要能增删改查设置项要按条件筛选SQLite 基本都能扛下来。它适合谁适合刚学完 Activity 跳转、SharedPreference 存键值对准备进入结构化数据管理阶段的 Android 初学者也适合想给本地小工具加一层持久化、又不想引入 Room 或网络数据库的独立开发者。我在实际写登录 Demo 时踩过一个坑一开始用 SharedPreference 存手机号和密码单个用户没问题但一旦要按手机号查历史记录同名用户更新而不是重复插入键值对就完全不够用了。SQLite 的优势在这里体现得很明显——它支持标准 SQL、支持事务ACID而且 Android 通过SQLiteOpenHelper把建库、建表、升级版本这些事都封装好了你只需要继承它、重写onCreate和onUpgrade。这一篇我会沿着建库建表 → 增删改查 → adb 调试 → 环境配置这条线走前半段交付一个可直接复制的UserDBHelper模板和登录页逻辑后半段把开发环境里的统一 Key/API 通道配置讲清楚让你在调试数据库读写的同时也能顺手把接口调用的环境准备好。核心检索词就是 Android Studio SQLite 数据库的创建与增删改查全文围绕它展开。先明确几个类的关系后面代码才不会晕类名作用你需要做什么SQLiteOpenHelper抽象类管理数据库创建与版本升级继承它重写onCreate/onUpgradeSQLiteDatabase数据库访问入口执行增删改查通过 helper 拿到实例调用 insert/queryCursor结果集游标指向查询返回的某一行循环moveToNext()取值用完close()SQLite 只支持五种存储类型NULL、INTEGER、REAL、TEXT、BLOB。它有个很宽容的特点——你可以在声明为 INTEGER 的字段里塞字符串因为它解析CREATE TABLE时会忽略字段名后面的类型信息主键INTEGER PRIMARY KEY除外那必须是 64 位整数。这个特性方便但也容易埋坑建议还是按语义声明类型。2. TaoToken 前置准备把统一 Key 和 API 通道配好在动手写数据库之前先把开发环境的接口通道理顺。很多同学写登录 Demo 时会顺手接一个发送验证码的真实接口这时候如果没有统一的 Key 管理每个项目都要去翻一遍密钥很容易乱。TaoToken 提供的就是一个统一的 API 通道你申请一个 Key就能在多个项目里复用同一套 Base URL 和鉴权方式。先说清楚它是什么、能做什么TaoToken 是一个面向开发者的模型调用与 API 聚合平台你可以在它的控制台里创建 API Key然后用统一的 Base URL 去调用不同模型。适合谁适合需要在 Android 项目里接入 AI 能力比如智能客服、文本润色、验证码语义校验的开发者也适合想用一个 Key 管理多个实验项目的同学。前置准备分三步走。第一步打开官网注册并登录进入控制台。第二步在控制台里创建 API Key复制出来保存好——注意Key 只在创建时完整显示一次关掉页面就看不到了。第三步记下两个地址官网是https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 根地址是https://taotoken.net/api这个不加 UTM 参数直接用于代码里的 Base URL。这里要提醒一句API Key 属于敏感凭证不要硬编码进LoginSQLiteActivity.java然后提交到 Git。正确做法是放在local.properties或者BuildConfig里通过 Gradle 注入。下面给一个local.properties的写法# local.properties不要提交到版本库 TAOTOKEN_API_KEYsk-你的实际Key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在app/build.gradle里读取并写入BuildConfigandroid { defaultConfig { buildConfigField String, TAOTOKEN_API_KEY, \${project.findProperty(TAOTOKEN_API_KEY) ?: }\ buildConfigField String, TAOTOKEN_BASE_URL, \https://taotoken.net/api\ } }这样在 Java 代码里就能用BuildConfig.TAOTOKEN_API_KEY取到 Key既避免了明文泄露也方便不同构建变体切换。如果你更习惯用gradle.properties把TAOTOKEN_API_KEY写进去、在build.gradle里用project.property读取也一样。关键是别把 Key 写死在 Activity 里。配好之后你可以先在控制台用模型对话页面发一条测试消息确认 Key 是通的再去写 Android 代码。这一步能帮你排除掉到底是 Key 错了还是代码错了的干扰。3. 可复制配置SQLiteOpenHelper 模板与登录页接入这一节是全文的技术核心我会给出一个可以直接复制进项目的UserDBHelper再把它和登录页串起来。先看实体类UserInfo字段和数据库表一一对应package com.example.jhjh; public class UserInfo { public long rowid; // 行号 public int xuhao; // 序号 public String name; // 姓名 public int age; // 年龄 public long height; // 身高 public float weight; // 体重 public boolean married; // 婚否 public String update_time;// 更新时间 public String phone; // 手机号 public String password; // 密码 public UserInfo() { rowid 0L; xuhao 0; name ; age 0; height 0L; weight 0.0f; married false; update_time ; phone ; password ; } }接着是UserDBHelper它用单例模式保证全局只有一个 helper 实例避免多线程下重复打开数据库。建表语句里我保留了phone和password两列方便登录页直接读写package com.example.jhjh; import android.annotation.SuppressLint; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; import java.util.ArrayList; import java.util.List; SuppressLint(DefaultLocale) public class UserDBHelper extends SQLiteOpenHelper { private static final String TAG UserDBHelper; private static final String DB_NAME user.db; private static final int DB_VERSION 1; private static UserDBHelper mHelper null; private SQLiteDatabase mDB null; public static final String TABLE_NAME user_info; private UserDBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } private UserDBHelper(Context context, int version) { super(context, DB_NAME, null, version); } public static UserDBHelper getInstance(Context context, int version) { if (version 0 mHelper null) { mHelper new UserDBHelper(context, version); } else if (mHelper null) { mHelper new UserDBHelper(context); } return mHelper; } public SQLiteDatabase openReadLink() { if (mDB null || !mDB.isOpen()) { mDB mHelper.getReadableDatabase(); } return mDB; } public SQLiteDatabase openWriteLink() { if (mDB null || !mDB.isOpen()) { mDB mHelper.getWritableDatabase(); } return mDB; } public void closeLink() { if (mDB ! null mDB.isOpen()) { mDB.close(); mDB null; } } Override public void onCreate(SQLiteDatabase db) { Log.d(TAG, onCreate); String drop_sql DROP TABLE IF EXISTS TABLE_NAME ;; db.execSQL(drop_sql); String create_sql CREATE TABLE IF NOT EXISTS TABLE_NAME ( _id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR NOT NULL, age INTEGER NOT NULL, height INTEGER NOT NULL, weight FLOAT NOT NULL, married INTEGER NOT NULL, update_time VARCHAR NOT NULL, phone VARCHAR, password VARCHAR );; Log.d(TAG, create_sql: create_sql); db.execSQL(create_sql); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { Log.d(TAG, onUpgrade oldVersion oldVersion , newVersion newVersion); if (newVersion 1) { String alter_sql ALTER TABLE TABLE_NAME ADD COLUMN phone VARCHAR;; db.execSQL(alter_sql); alter_sql ALTER TABLE TABLE_NAME ADD COLUMN password VARCHAR;; db.execSQL(alter_sql); } } public int delete(String condition) { return mDB.delete(TABLE_NAME, condition, null); } public int deleteAll() { return mDB.delete(TABLE_NAME, 11, null); } public long insert(UserInfo info) { ListUserInfo infoList new ArrayList(); infoList.add(info); return insert(infoList); } public long insert(ListUserInfo infoList) { long result -1; for (int i 0; i infoList.size(); i) { UserInfo info infoList.get(i); ListUserInfo tempList; if (info.name ! null info.name.length() 0) { String condition String.format(name%s, info.name); tempList query(condition); if (tempList.size() 0) { update(info, condition); result tempList.get(0).rowid; continue; } } if (info.phone ! null info.phone.length() 0) { String condition String.format(phone%s, info.phone); tempList query(condition); if (tempList.size() 0) { update(info, condition); result tempList.get(0).rowid; continue; } } ContentValues cv new ContentValues(); cv.put(name, info.name); cv.put(age, info.age); cv.put(height, info.height); cv.put(weight, info.weight); cv.put(married, info.married); cv.put(update_time, info.update_time); cv.put(phone, info.phone); cv.put(password, info.password); result mDB.insert(TABLE_NAME, , cv); if (result -1) { return result; } } return result; } public int update(UserInfo info, String condition) { ContentValues cv new ContentValues(); cv.put(name, info.name); cv.put(age, info.age); cv.put(height, info.height); cv.put(weight, info.weight); cv.put(married, info.married); cv.put(update_time, info.update_time); cv.put(phone, info.phone); cv.put(password, info.password); return mDB.update(TABLE_NAME, cv, condition, null); } public int update(UserInfo info) { return update(info, rowid info.rowid); } public ListUserInfo query(String condition) { String sql String.format(select rowid,_id,name,age,height, weight,married,update_time,phone,password from %s where %s;, TABLE_NAME, condition); Log.d(TAG, query sql: sql); ListUserInfo infoList new ArrayList(); Cursor cursor mDB.rawQuery(sql, null); while (cursor.moveToNext()) { UserInfo info new UserInfo(); info.rowid cursor.getLong(0); info.xuhao cursor.getInt(1); info.name cursor.getString(2); info.age cursor.getInt(3); info.height cursor.getLong(4); info.weight cursor.getFloat(5); info.married (cursor.getInt(6) ! 0); info.update_time cursor.getString(7); info.phone cursor.getString(8); info.password cursor.getString(9); infoList.add(info); } cursor.close(); return infoList; } public UserInfo queryByPhone(String phone) { UserInfo info null; ListUserInfo infoList query(String.format(phone%s, phone)); if (infoList.size() 0) { info infoList.get(0); } return info; } }注意insert里的逻辑如果传入的name或phone已存在就走update而不是重复插入。这是登录页记住密码场景的关键——同一个手机号反复登录数据库里只保留一条记录密码被更新为最新值。再看登录页LoginSQLiteActivity里和数据库相关的生命周期。onResume打开写连接onPause关闭连接这是官方推荐的做法避免连接泄漏Override protected void onResume() { super.onResume(); mHelper UserDBHelper.getInstance(this, 1); mHelper.openWriteLink(); } Override protected void onPause() { super.onPause(); mHelper.closeLink(); }登录成功且勾选了记住密码时写入数据库if (isRemember) { UserInfo info new UserInfo(); info.phone et_phone.getText().toString(); info.password et_password.getText().toString(); info.update_time DateUtil.getNowDateTime(yyyy-MM-dd HH:mm:ss); mHelper.insert(info); }密码框获得焦点时自动按手机号回填密码Override public void onFocusChange(View v, boolean hasFocus) { String phone et_phone.getText().toString(); if (v.getId() R.id.et_password) { if (phone.length() 0 hasFocus) { UserInfo info mHelper.queryByPhone(phone); if (info ! null) { et_password.setText(info.password); } } } }如果你还想在登录页里接入 TaoToken 做验证码语义校验或登录风控提示可以在loginSuccess()里加一段网络请求Base URL 用BuildConfig.TAOTOKEN_BASE_URLHeader 里带Authorization: BearerBuildConfig.TAOTOKEN_API_KEY。这样数据库负责本地持久化TaoToken 负责远端能力职责清晰。4. 验证请求与成功结果adb 调试数据库读写代码写完怎么确认数据库真的写进去了最直接的办法是用 adb 把.db文件拉出来看。先跑一次登录勾选记住密码然后打开终端执行adb shell run-as com.example.jhjh ls -l /data/data/com.example.jhjh/databases/正常会看到user.db和user.db-journal两个文件。接着把数据库拉到本地adb exec-out run-as com.example.jhjh cat /data/data/com.example.jhjh/databases/user.db user.db如果你本地装了 sqlite3直接查sqlite3 user.db select rowid, name, phone, password, update_time from user_info;预期输出类似1||13800001111|111111|2025-01-01 10:20:30如果phone和password有值说明insert成功。再回到 App把手机号重新输入、光标点到密码框密码自动回填说明queryByPhone也通了。验证 TaoToken 通道是否连通可以用 curl 先测curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d {model:gpt-4o-mini,messages:[{role:user,content:ping}]}返回里带choices数组就说明 Key 和 Base URL 都对。这一步和数据库验证是两条独立的线建议分开测出问题时好定位。5. 本篇常见错排查401、local proxy failed、reading choices这一节把几个高频报错对照着讲都是我在实际调试里遇到过的。报错一401 Unauthorized。这个几乎都是 Key 的问题。检查三处Key 是不是复制时带了空格Authorization头是不是写成了Bearer sk-xxxBearer 和 Key 之间有一个空格Key 是不是在控制台被删了。如果你用的是BuildConfig.TAOTOKEN_API_KEY先Log.d打印一下确认不是空字符串。还有一种情况是 Key 没传进去buildConfigField里project.findProperty返回 null导致拼出来是Bearer服务端自然拒绝。报错二local proxy failed或连接超时。这类通常是网络层的问题不是 Key 的问题。先确认设备能正常访问外网再确认 Base URL 写的是https://taotoken.net/api而不是别的地址。如果你在模拟器里跑模拟器的网络和宿主机是共享的一般不会有额外配置如果是真机检查一下 Wi-Fi 是否正常。注意这里不要引入任何网络代理工具直接用系统网络即可。报错三reading choices相关解析失败。这个报错通常出现在你解析响应 JSON 时choices字段取不到。原因可能是请求体里model字段写错了服务端返回的是错误结构而不是正常结构或者响应还没读完就解析了。建议先把原始响应Log.d出来看结构再写解析。如果你用的是 Gson字段名要和返回 JSON 严格对应choices是数组取[0].message.content。报错四数据库no such table: user_info。这说明onCreate没执行或者执行时表名不对。检查DB_VERSION是不是变了导致走了onUpgrade而不是onCreate检查TABLE_NAME常量前后是否一致。还有一个隐蔽的坑onCreate里我先DROP TABLE IF EXISTS再CREATE如果你在正式项目里这么写每次版本升级都会清空数据演示可以生产要改成只CREATE。报错五Cursor window allocation或游标未关闭。查询完一定要cursor.close()我在query方法里已经加了。如果你自己写了别的查询方法记得同样处理否则反复查询会泄漏。排查顺序建议先看 Logcat 里的TAG日志我在关键位置都打了Log.d确认 SQL 语句长什么样再用 adb 把 db 拉出来直接查最后才怀疑代码逻辑。这样能少走很多弯路。6. 语义一致 CTA把 Key、文档和模型对话用起来数据库这条线跑通之后你手里就有了一个能本地持久化用户信息的登录 Demo。接下来如果要把发送验证码登录风控这些能力接上就需要一个稳定的 API 通道。我建议你按下面的路径走一遍先去 API Keys 页面创建一个 Key地址是https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite创建后立刻复制保存。然后打开接入文档对照着把 Base URL 和鉴权头写进你的网络请求工具类文档地址是https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite。文档里有不同语言的请求示例Android 端用 OkHttp 照着改就行。写完请求代码别急着往登录页里塞先去模型对话页面发一条消息验证通道地址是https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite。确认能正常返回内容再把同样的 Base URL 和 Key 用到 Android 项目里。如果你打算长期做编码类或 Agent 类项目可以了解一下 Coding Plan地址是https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite它更适合需要持续调用、批量处理的场景。最后给一个实用技巧把UserDBHelper里的DB_VERSION和onUpgrade当成你的数据库迁移脚本来管理。每次改表结构就升版本号在onUpgrade里写对应的ALTER TABLE这样用户升级 App 时数据不会丢。我试过在 Demo 里直接改onCreate的建表语句而不升版本结果旧设备上表结构根本没变查出来的字段全是 null排查了半天才反应过来是版本号没动。记住这个坑能省你不少时间。