
简介面向初学Android开发的技术文档讲解如何使用Android Studio连接SQLite数据库完成登录注册功能。内容覆盖项目结构搭建、登录与注册界面XML布局、SQLiteOpenHelper管理数据库、Cursor与ContentValues实现数据增删改查、SharedPreferences保存用户信息以及Intent完成页面跳转等完整流程。文档从新建工程开始逐步展示数据库帮助类定义、用户表创建、注册时插入账号密码、登录时通过SELECT校验等细节并穿插LinearLayout与ConstraintLayout的布局要点便于读者对照实践。资源为PDF格式仅1个文件压缩包大小138KB携带方便。目前已有12721人学习下载适合正在学习Android本地存储和基础业务逻辑的开发者。通过完整示例代码与界面配置说明读者可快速在本地复现登录注册效果同时掌握SQLite数据库的核心操作为后续开发更完善的应用打下基础。1. SQLite登录注册功能看起来简单为什么翻车率反而最高在 Android Studio 里写登录注册是很多开发者入门安卓时的第一道坎两个输入框、两个按钮、一张 user 表示例代码一搜一大把。可真动手时翻车点几乎全集中在 SQLite 这一层——注册报成功登录却对不上去模拟器里找 user.db打开发现表是空的改一次表结构App 直接崩在启动页。经历过几次之后你会发现一个反直觉的事实SQLite 并不是需要「连接」的服务器它只是嵌在应用沙盒里的一个单文件数据库所谓连接就是打开这个 .db 文件并拿到操作句柄。这篇文章从这条主线出发先把连接的本质讲清楚再给出一套能直接跑通的建表逻辑、数据层封装和注册登录完整闭环最后拆掉五个高频踩坑点。适合做安卓课设、给工具类 App 加本地账号体系的开发者新手能照着敲熟手可以直接翻到数据层抄走。2. 连接SQLite的正确姿势进程内数据库与users表设计Android 里的 SQLite 和你在服务器上用的 MySQL、PostgreSQL 有一个本质差异它没有独立的数据库进程没有端口也不需要账号密码去建立网络连接。它是一套跑在你应用进程内的嵌入式数据库引擎数据最终落成一个单文件路径在应用私有目录下。所以「Android Studio 连接 SQLite」这件事并不存在一个可视化面板让你输 IP 和端口而是通过代码拿到一个 SQLiteDatabase 对象之后所有增删改查都在这个对象上进行。理解这一层后面很多报错就都有了解释方向。2.1 SQLite的「连接」本质打开一个文件而不是连一台服务器我见过不少第一次做安卓项目的开发者习惯性去搜「数据库连接字符串」然后把 MySQL 那套思路往 Android 上套折腾半天连不上。其实 Android 里最接近「连接」的动作是下面这一行SQLiteDatabase db openOrCreateDatabase(user.db, MODE_PRIVATE, null);这行代码的意思是在当前应用私有目录下打开 user.db 这个文件文件不存在就创建存在就直接打开。执行完你拿到的是一个可读可写的数据库句柄。它不经过网络、不占用端口、不需要用户名密码而且这个文件天然带应用级隔离——别的应用碰不到卸载 App 时跟着一起被删掉。既然文件是私有的为什么还要强调「连接」这个词因为 SQLiteOpenHelper 这个类把连接管理做成了生命周期模型首次打开时触发 onCreate 建表版本变化时触发 onUpgrade。如果你直接跳过它自己每次手动 openOrCreateDatabase表结构的创建和维护会变得非常散乱。所以我的习惯是项目里只要是正经做登录注册一律用 SQLiteOpenHelper 封装连接而不是到处裸调 openOrCreateDatabase。它解决的核心问题不是「能不能连上」而是「建表、升级、存数据这一整套动作什么时候执行、执行几次」。2.2 users表直接用这份建表SQL字段、类型与约束一次定对登录注册功能再简单也值得把表设计想清楚再动手。下面是常用的建表语句它包含 id、username、password、salt、created_at 五个字段覆盖了从注册到登录再到后续扩展的全部需要CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password TEXT NOT NULL, salt TEXT, created_at INTEGER NOT NULL );字段说明与参数建议如下字段类型约束作用idINTEGERPRIMARY KEY AUTOINCREMENT自增主键作为用户唯一标识usernameTEXTNOT NULL UNIQUE COLLATE NOCASE登录账号不允许重复passwordTEXTNOT NULL存储登录凭证生产环境存哈希saltTEXT可空密码哈希时使用的随机盐created_atINTEGERNOT NULL注册时间戳便于排序和排查两个容易被忽略的点。第一username 不要用 VARCHAR(20) 这类写法SQLite 的 TEXT 类型本身不限制长度你在列上写 VARCHAR(20) 也不会真的截断到 20 个字符反而会给新手造成「限制生效」的错觉。第二COLLATE NOCASE 让用户名对比忽略大小写这样 ABC 和 abc 会被视为同一个用户避免出现两个视觉上重复的账号。这个细节在后面的避坑章节还会专门展开。salt 字段现在可以先留着不填等做到密码哈希时直接写入。如果建表时忘了这一列后续用 ALTER TABLE users ADD COLUMN salt TEXT 补上也可以但就是多一次数据库升级操作。2.3 表结构要改怎么办版本号、onUpgrade与数据迁移的边界SQLite 的表结构不是「改一行代码就生效」的。你修改建表 SQL 后必须把数据库版本号加一SQLiteOpenHelper 才会在打开数据库时触发 onUpgrade否则新结构对老数据库文件完全不生效。这个机制是新手最容易理解错的地方——以为 onCreate 每次启动都会跑实际上 onCreate 只在数据库文件第一次创建时执行一次。版本号就是 SQLiteOpenHelper 构造函数里的最后一个参数比如 super(context, user.db, null, 2)。当检测到磁盘上的旧版本号小于新版本号时系统依次调用 onUpgrade。常见的省事写法是直接 DROP TABLE 再重建这在课程设计和内部工具里可以用因为数据丢了也无所谓但正经项目里不能这么干正确做法是在 onUpgrade 里写增量迁移比如 ALTER TABLE users ADD COLUMN salt TEXT。注意千万不要在 onUpgrade 里只写一句 DROP TABLE 而不重建也别在 onCreate 里东拼西凑地补列。表结构变更的准确操作顺序是升版本号、写迁移逻辑、验证老数据是否仍在。这三步少一步后面排查起来都像玄学。3. 数据层封装SQLiteOpenHelper里的建表、注册与登录查询数据层的职责很单纯对上层提供注册和登录两个能力内部负责管理连接、执行 SQL、关闭资源。很多示例代码把这一层直接写进 Activity注册逻辑、数据库操作、界面提示全堆在一起。功能是能跑但只要出现一处状态问题定位起来要翻遍整个类。我习惯的做法是拆成两部分SQLiteOpenHelper 负责连接和表结构UserDao 负责业务数据操作。下面三节给出最小可运行的写法。3.1 helper类最小写法建库、建表与版本管理的骨架public class UserDbHelper extends SQLiteOpenHelper { private static final String DB_NAME user.db; private static final int DB_VERSION 1; public static final String TABLE_USER users; public static final String COLUMN_ID id; public static final String COLUMN_USERNAME username; public static final String COLUMN_PASSWORD password; public static final String COLUMN_SALT salt; public static final String COLUMN_CREATED_AT created_at; public UserDbHelper(Context context) { super(context.getApplicationContext(), DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { String sql CREATE TABLE TABLE_USER ( COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, COLUMN_USERNAME TEXT NOT NULL UNIQUE COLLATE NOCASE, COLUMN_PASSWORD TEXT NOT NULL, COLUMN_SALT TEXT, COLUMN_CREATED_AT INTEGER NOT NULL ); db.execSQL(sql); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课程设计阶段够用正式项目请改为增量迁移 db.execSQL(DROP TABLE IF EXISTS TABLE_USER); onCreate(db); } }两个地方值得说。构造时用 context.getApplicationContext() 而不是直接把 Activity 传进去是为了避免数据库连接持有 Activity 引用导致内存泄漏。数据库名、表名、列名全部抽成常量是血泪经验——业务层写 SQL 时手滑打错一个列名报错信息往往要到运行时才出现抽成常量后至少编译期能帮你兜住一部分。3.2 注册方法insert的返回值与ContentValues的字段映射注册操作本质是一个 INSERT但用什么姿势插入很有讲究。不要拼 SQL 字符串用 ContentValues 做字段映射一方面避免手误另一方面也为后续防注入留了口子。public long register(String username, String password) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(COLUMN_USERNAME, username); values.put(COLUMN_PASSWORD, password); values.put(COLUMN_CREATED_AT, System.currentTimeMillis()); long rowId db.insert(TABLE_USER, null, values); db.close(); return rowId; }db.insert 的返回值是插入行的行号也就是 id。如果插入失败返回 -1。为什么用 long 而不是 boolean因为 rowId 可以在注册成功后直接拿来当 userId 存登录态一个返回值干两件事。username 上的 UNIQUE 约束会帮你兜住重复注册——第二次插同名用户时直接返回 -1不需要你先查一遍再插。先查询再插入的做法是多余的存在并发和竞态问题UNIQUE 约束才是正确防线。注意一点这是同步写法课程设计和内部工具直接在主线程跑没问题。真实项目如果数据量大或写入频繁记得放到子线程执行SQLite 写操作虽然快但批量写入时依然可能卡 UI 线程。3.3 登录方法query的参数顺序与Cursor关闭的强迫症登录查询比注册稍微绕一点。为了把「用户不存在」和「密码错误」区分开需要先按用户名查出密码字段再比对。我建议把登录方法返回一个状态码而不是简单的 boolean这样界面层能给用户更明确的提示。public static final int LOGIN_OK 0; public static final int LOGIN_USER_NOT_FOUND 1; public static final int LOGIN_PASSWORD_WRONG 2; public int login(String username, String password) { SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.query(TABLE_USER, new String[]{COLUMN_PASSWORD}, COLUMN_USERNAME ?, new String[]{username}, null, null, null); if (!cursor.moveToFirst()) { cursor.close(); db.close(); return LOGIN_USER_NOT_FOUND; } String storedPassword cursor.getString( cursor.getColumnIndexOrThrow(COLUMN_PASSWORD)); cursor.close(); db.close(); return storedPassword.equals(password) ? LOGIN_OK : LOGIN_PASSWORD_WRONG; }db.query 的第一个参数是表名第二个参数是返回的列第三个参数是 WHERE 条件第四个参数是条件中问号对应的值数组后面依次是 groupBy、having、orderBy。这套方法比 rawQuery 更适合日常使用因为参数是分离的不需要自己拼字符串。Cursor 的关闭顺序也有讲究先关 Cursor再关 db顺序反了偶尔会触发一些莫名其妙的资源释放问题。我的习惯是每写一个查询方法第一件事就是确认 Cursor 在最晚时机关闭习惯成自然。这里把查询密码列出来做比对意味着数据库里存的是明文还是哈希都不影响登录流程逻辑——你只需要保证「存储时的处理」和「比对时的处理」完全一致就行。3.4 读写连接getReadableDatabase与getWritableDatabase的差别这两个方法的名字有迷惑性。getReadableDatabase 并不是「拿一个只读连接」绝大多数情况下它返回的就是同一个可写连接只有当磁盘空间满等极端情况下它才可能退化为只读模式。所以实践上的选择标准很简单只读操作用 getReadableDatabase写操作用 getWritableDatabase二者在正常环境下差别不大但语义上更清晰。关于连接生命周期有两种做法都有人用。一种是全局持有一个 SQLiteDatabase 实例到处传另一种是每次操作通过 helper 获取连接、用完关闭。我更推荐后者原因是 SQLite 连接不是线程安全的多线程共享同一个 SQLiteDatabase 实例反而容易产生并发问题每次操作获取一个短连接用完即关牺牲一点点性能换来确定性。helper 本身要长生命周期持有一般通过一个全局的单例或 Application 里持有别在 Activity 里反复 new那样连接管理会变得混乱。4. 界面与业务闭环注册登录在Activity里怎么串起来数据层就绪后剩下的事情是把界面和业务逻辑串起来。布局上既可以用两个 Activity 分别放注册页和登录页也可以在一个 Activity 里切换显示逻辑上没有本质差别。关键在于输入校验的顺序、错误提示的粒度以及登录成功后状态管理这三个环节。4.1 注册登录页最少需要哪些控件布局与输入约束一个能跑通的最小界面只需要四个控件用户名输入框、密码输入框、注册按钮、登录按钮。下面是一个简化的布局片段LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical EditText android:idid/etUsername android:hint请输入用户名 android:inputTypetext android:maxLength20 android:singleLinetrue / EditText android:idid/etPassword android:hint请输入密码 android:inputTypetextPassword android:maxLength32 / Button android:idid/btnRegister android:text注册 / Button android:idid/btnLogin android:text登录 / /LinearLayout密码框一定要加 inputTypetextPassword否则输入内容明晃晃显示在屏幕上。maxLength 的作用是限制输入超长内容防止有人填一个 500 位的密码后续存储和比对都没必要。若是希望在软键盘弹出时界面不被完全遮挡把整个布局装进 ScrollView 里就行这也是登录页常见的处理方式。4.2 注册点击事件空值、长度、重复用户的处理顺序注册点击事件的逻辑顺序很重要先做空值检查再做长度校验最后调用数据层。顺序反了空用户名也会先进数据库查询浪费一次 I/O 却拿不到结果。public void onRegisterClick(View view) { String username etUsername.getText().toString().trim(); String password etPassword.getText().toString().trim(); if (username.isEmpty() || password.isEmpty()) { showToast(用户名和密码不能为空); return; } if (password.length() 6) { showToast(密码至少需要6位); return; } UserDao dao new UserDao(getApplicationContext()); long rowId dao.register(username, password); if (rowId ! -1) { showToast(注册成功); etPassword.setText(); } else { showToast(注册失败用户名可能已被占用); } }trim() 一定要做而且注册和登录两边都得做。EditText 拿到的字符串有时会带前导或结尾空格注册时存了带空格的用户名登录时输入不带空格匹配不上这类问题最难排查。业务层做校验时也要记住提示「注册失败」比把「UNIQUE constraint failed」这种底层异常直接抛给用户要友好得多。4.3 登录点击事件把「查无此人」「密码错误」「数据库异常」区分开登录事件直接复用 3.3 的 login 返回状态码三种结果分别给用户三种提示public void onLoginClick(View view) { String username etUsername.getText().toString().trim(); String password etPassword.getText().toString().trim(); if (username.isEmpty() || password.isEmpty()) { showToast(用户名和密码不能为空); return; } UserDao dao new UserDao(getApplicationContext()); int result dao.login(username, password); if (result UserDao.LOGIN_OK) { saveLoginState(username); startActivity(new Intent(this, MainActivity.class)); finish(); } else if (result UserDao.LOGIN_PASSWORD_WRONG) { showToast(密码错误); } else { showToast(该用户不存在); } }从产品角度很多登录接口刻意不区分「用户不存在」和「密码错误」统一提示「用户名或密码错误」目的是防止别人爆破用户名。但本地单机 App 里用户就是本人区分开反而体验更好这里按场景取舍即可。登录成功后要立刻保存登录状态再跳转页面避免页面跳过去才发现状态没写进去这是非常常见的状态管理翻车点。4.4 登录成功以后SharedPreferences里存什么不存什么登录状态的保存用的是 SharedPreferences但它只能算一个本地会话缓存不能当保险库用。我推荐存这三样userId、username、loginTime。不存 password即使加密过也不建议存。SharedPreferences.Editor editor getSharedPreferences(session, MODE_PRIVATE).edit(); editor.putString(username, username); editor.putLong(loginTime, System.currentTimeMillis()); editor.apply();loginTime 的作用是做会话过期判断。比如设定 7 天有效期每次进入主界面时检查当前时间减去 loginTime 是否超过阈值超过就要求重新登录。没有过期机制的话登录状态会永久有效用户删不掉、改不了只能卸载重装体验很差。这个「会话要有生命周期」的意识比具体代码值钱得多。5. SQLite登录注册避坑指南五个高频翻车点与排查方法登录注册这套链路不长但每个环节都有对应的坑。下面按「现象 → 原因 → 解决」拆开讲五个我见过最多的问题前四个是刚写完代码就会遇见的最后一个属于设计层面容易被忽略。5.1 注册成功却登录失败先查两个方法对输入的处理是否一致现象注册时提示成功切到登录页输入一模一样的用户名和密码却提示密码错误或用户不存在。 原因最常见的是注册和登录两个入口对输入做了不同处理比如注册时对密码做了 trim 或拼接登录时直接拿原始输入比对或者注册时用了一个加密函数登录时忘用了。另一个隐蔽来源是 EditText 的输入联想在文本末尾悄悄加了空格注册时没 trim登录时 trim 了。 解决先给两个入口的输入都统一加 trim再把「存储密码的处理函数」和「比对密码的处理函数」抽成同一个方法。记住一个原则注册时怎么处理字符串登录时必须用完全一样的处理流程中间不能有一处不同的转换。5.2 no such table与table already exists建表执行时机与表结构变更现象App 第一次装好能跑改了表结构之后覆盖安装一进登录页就崩日志要么报 no such table要么报 table users already exists。 原因onCreate 只在数据库文件首次创建时执行一次。你改了建表 SQL但旧库文件还在新结构根本不会生效table already exists 则常见于 onUpgrade 里 DROP 和 CREATE 的顺序不对或者旧库已经被创建过又重复执行了 CREATE TABLE。 解决凡涉及表结构变更务必把 DB_VERSION 加一在 onUpgrade 里写增量迁移不要指望覆盖安装自动解决。排查时打开 Android Studio 的 Device File Explorer进入 data/data/你的包名/databases/直接查看 user.db 是否存在、大小是否正常。只看日志猜原因效率太低。5.3 操作稍多就卡死或database is locked连接与游标没关干净现象注册登录几次之后App 明显变卡Logcat 不停刷 database is locked (code 14)。 原因Cursur 没关闭或者每次拿到的 SQLiteDatabase 没 close导致文件锁一直被占用多个线程同时对同一数据库写操作时也会出现锁等待。 解决养成「谁打开谁关闭」的习惯Cursor 用完后第一时间 close数据库连接同样如此。代码里可以用 try-finally 包裹确保异常路径下也能关闭。若还需要并发写入用事务包裹批量操作把多次独立写入合并成一次事务提交能明显缓解锁竞争。5.4 登录状态里出现明文密码SharedPreferences的错误用法现象为了做自动登录把用户名密码整体写进 SharedPreferences之后在安全检查或备份文件里发现密码原样可见。 原因把「会话状态」和「密码持久化」混为一谈。SharedPreferences 是明文存储在 root 设备或者开启了备份的情况下文件内容很容易被读取。 解决SharedPreferences 只存 userId、username、loginTime密码只存在 SQLite 的 password 字段里而且必须哈希后存储详细实现看下一章。早期我在一个内部工具里图省事直接存了明文测试机借给别人十分钟数据库就被拷走了那次的教训比写哈希函数贵太多。5.5 ABC与abc用户不一致SQLite默认大小写敏感现象注册了 ABC登录时输 abc 提示用户不存在或者注册了 abc 之后还能再注册 ABC出现两个只有大小写不同的账号。 原因SQLite 对 TEXT 类型默认按 BINARY 规则比较也就是逐字节比较ABC 和 abc 不相等。 解决建表时给 username 加 COLLATE NOCASE这样 UNIQUE 约束和普通查询都会忽略 ASCII 大小写差异ABC 和 abc 视为同一用户。如果表已经建好且不方便重建可以在查询条件里写成 username ? COLLATE NOCASE同样能解决查询问题但 UNIQUE 约束还是管不到历史重复数据。注意COLLATE NOCASE 只对 ASCII 字符生效中文字符没有大小写概念所以中文用户名不受影响。另外password 列不要加 NOCASE密码的大小写敏感性是有意为之不能因为加了规则导致 ABC 和 abc 的密码都能登录。6. 让这套登录注册能上线三个必须补的进阶动作课设和内部工具做到上面那一步已经能交付了但要是准备放到真实环境用有三个动作必须在发布前补上。它们不涉及复杂的框架改造代码量都不大但对安全性是决定性的。6.1 密码必须哈希入库PBKDF2的最小实现密码学上不建议自己设计加密方案直接用成熟的 PBKDF2 算法即可。每个用户生成一个随机盐存到 users 表的 salt 字段password 字段只存哈希结果。校验时取出盐重新对输入做哈希再和库里存的值比较private static String hash(String password, byte[] salt) throws Exception { KeySpec spec new PBEKeySpec(password.toCharArray(), salt, 10000, 256); SecretKeyFactory f SecretKeyFactory.getInstance(PBKDF2WithHmacSHA256); byte[] hash f.generateSecret(spec).getEncoded(); return Base64.encodeToString(hash, Base64.NO_WRAP); }盐必须随机生成每个用户一盐不能所有用户共用同一个盐。迭代次数 10000 是对性能和安全的折中你完全可以根据设备情况往上调整。6.2 登录查询保持参数化SQL注入的一句话代价凡是把用户输入直接拼进 SQL 字符串的写法都是在给注入留后门。比如 username input一旦用户名里输入带单引号的恶意内容整条 SQL 语义就被篡改了。前面所有 query 方法一直用 selectionArgs 传参就是为了杜绝这类问题。这也是为什么 3.3 的登录方法写成 COLUMN_USERNAME ?而不是直接拼字符串。6.3 上线前的三个验证动作与一个调试习惯验证登录状态的失效逻辑登录后手动卸载重装确认登录态不会「幽灵复活」。因为卸载会同时清掉数据库和 SharedPreferences如果重新安装后还能带出旧登录态说明状态被存进了外部存储的备份里这是要处理的。验证错误提示的兜底连续输错密码确认崩溃日志里不会把 SQL 语句或表结构信息展示到界面上。最后养成一个调试习惯用 Android Studio 的 Device File Explorer 把 user.db 导出到电脑在 SQLite 浏览器里直接检查表结构和数据比靠日志盲猜快得多。我现在写任何带本地登录的功能无论规模大小都会默认把这三步走完。早期我也干过跳过哈希直接存明文的事结果测试机被同事借走不到十分钟所有密码就被翻了个底朝天。那之后我给自己定了个规矩密码处理永远不偷懒登录注册再小也要当正式功能写。希望帮到你。本文还有配套的精品资源点击获取