ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

B4A连接MySQL做登录注册:中间层API架构与实现

B4A连接MySQL做登录注册:中间层API架构与实现

简介:这是一份面向B4A学习者的安卓登录注册项目示例,解决如何让安卓App通过JDBC直连MySQL并完成用户认证的问题。资源包共53个文件,压缩后约1.84MB,既包含.b4a工程与.bas源码,也有Java辅助类、AndroidManifest.xml等配置,还带有编译生成的class、dex和可直接安装的apk,结构完整,导入B4A IDE即可查看运行并二次修改。项目覆盖从登录/注册界面布局、数据库用户表设计、连接参数配置,到登录查询与注册插入的完整链路,并重点演示了ExecuteReader、ExecuteNonQuery等SQL操作方法的实际用法,同时给出密码哈希存储与SQL注入防护思路,可让读者边读源码边理解B4A操作MySQL的每个环节。已有709人学习下载,适合刚接触B4A或希望为安卓应用增加账号体系的开发者参考仿写。

1. B4A连接MySQL做登录注册:为什么“中间加一层API”才是正路

B4A这个名字在Android快速开发圈里不算陌生,很多人第一次用它都是冲着“能快速写个带后端的小工具”来的,而“连接MySQL实现登录注册”几乎是绕不开的头一个需求。网上搜这个标题,你能看到不少教程直接教你在B4A里加载JDBC驱动、连3306端口、写SQL——我劝你先别走这条路。手机端直连数据库,等于把数据库地址、账号、密码全塞进App安装包里,抓个包就全暴露了,而且MySQL的连接协议在移动弱网下极不稳定,断线重连能写到你想哭。真正可靠、也是业内的常见做法是:B4A只发HTTP请求,PHP或Node.js做中间层,由后端统一跟MySQL打交道,App拿到的永远是JSON。这篇笔记就按这个架构,从建表、写接口到B4A端请求封装和踩坑,一条龙讲完,新手能照着做,熟手也能捡几个边界参数的提醒。

2. 后端接口先行:MySQL表设计、PDO注册/登录接口、本地验证

2.1 用户表怎么建:字段预留与字符集

登录注册的核心是一张用户表。字段不用多,但预留几个常用项,避免上线后频繁加字段。我用MySQL建表的惯用结构是:

CREATE DATABASE IF NOT EXISTS app_user DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE app_user; CREATE TABLE IF NOT EXISTS t_user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE COMMENT '登录名', nickname VARCHAR(50) DEFAULT '' COMMENT '显示昵称', password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希,绝不存明文', avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像路径', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login_at DATETIME DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT '1正常 0禁用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有几个决策理由。username加UNIQUE约束是硬防线,就算后端代码逻辑有漏洞,数据库也会拦下重复用户名。password_hash这一列的长度设255,是因为后面要用的password_hash()函数在不同算法下产物长度会变,太短了后期升算法就得改表。status字段很多人一开始省掉,等要做封号功能时才发现没有它,解禁就得删数据,所以我一律先留上。

字符集方面,utf8mb4不是可选而是必须。B4A端传上来的用户名可能是中文,用户昵称里可能带emoji,utf8mb4是MySQL里唯一能完整覆盖这些字符的编码。排序规则用utf8mb4_unicode_ci,大小写不敏感,用户输入“Admin”和“admin”会被视为同一个登录名,省去后端多余的比较逻辑。

2.2 PHP接口:注册与登录都走POST,参数从Body里读

B4A端发HTTP请求时,我通常让App把参数拼成JSON放进请求体,而不是走传统的表单编码。这样后端无论是PHP还是换成别的语言,解析方式都统一。PHP端我选PDO扩展而不是mysqli,核心原因是PDO的预处理语句对SQL注入的防护是内置的,写起来也更接近其他数据库的通用习惯。注册接口的完整代码如下:

<?php // api/register.php declare(strict_types=1); header('Content-Type: application/json; charset=utf-8'); $pdo = new PDO( 'mysql:host=127.0.0.1;dbname=app_user;charset=utf8mb4', 'app_user', 'your_password', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ] ); $input = json_decode(file_get_contents('php://input'), true); $username = trim($input['username'] ?? ''); $password = $input['password'] ?? ''; if (mb_strlen($username) < 3 || mb_strlen($password) < 6) { http_response_code(400); exit(json_encode(['code' => 400, 'msg' => '用户名至少3位,密码至少6位'])); } $stmt = $pdo->prepare('SELECT id FROM t_user WHERE username = ?'); $stmt->execute([$username]); if ($stmt->fetch()) { http_response_code(409); exit(json_encode(['code' => 409, 'msg' => '用户名已存在'])); } $hash = password_hash($password, PASSWORD_DEFAULT); $stmt = $pdo->prepare('INSERT INTO t_user (username, password_hash) VALUES (?, ?)'); $stmt->execute([$username, $hash]); echo json_encode([ 'code' => 200, 'msg' => '注册成功', 'data' => ['uid' => (int)$pdo->lastInsertId()] ]);

关键点逐一说明。第一,用file_get_contents('php://input')读原始请求体,而不是用$_POST,这直接绕开了B4A端Content-Type可能不规范的问题。第二,所有输入先过一遍trim和长度校验,这是后端的第一道门槛,不能指望B4A端校验。第三,SELECT判断存在只是提前拦截,最终防线是数据库的UNIQUE约束,所以后面还要处理Duplicate异常。第四,password_hash()生成的是带随机盐的哈希串,每次注册同一密码结果都不同,库里即使被拖走也拿不到明文。

登录接口写法大同小异,核心差异是验证密码用password_verify()而不是重新哈希再比较:

$stmt = $pdo->prepare('SELECT id, username, password_hash, status FROM t_user WHERE username = ?'); $stmt->execute([$username]); $user = $stmt->fetch(); if (!$user || !password_verify($password, $user['password_hash'])) { http_response_code(401); exit(json_encode(['code' => 401, 'msg' => '用户名或密码错误'])); } if ((int)$user['status'] === 0) { http_response_code(403); exit(json_encode(['code' => 403, 'msg' => '账号已被禁用'])); }

我一般会把PDO连接、统一响应函数、参数校验拆到公共文件里,比如common.php,业务接口里只写核心逻辑。这样新增一个“修改昵称”接口时,三分钟就能加完,还不会漏掉字符集设置。

2.3 本地跑通验证:先别碰B4A,用命令行确认后端

写完后端,不要急着在B4A里写界面。先用最简单的方式把接口打一遍,确认能注册、能重复注册报错、能登录,再切到App端,否则到时候两头找问题会非常痛苦。本地验证命令:

# 注册新用户 curl -X POST http://127.0.0.1:8080/api/register.php \ -H "Content-Type: application/json" \ -d '{"username":"test01","password":"secret123"}' # 重复注册同一个用户名,应该返回409 curl -X POST http://127.0.0.1:8080/api/register.php \ -H "Content-Type: application/json" \ -d '{"username":"test01","password":"secret123"}' # 登录拿用户信息 curl -X POST http://127.0.0.1:8080/api/login.php \ -H "Content-Type: application/json" \ -d '{"username":"test01","password":"secret123"}'

观察三点:注册第一次返回code为200,第二次返回code为409;返回的JSON里中文字符不是乱码;登录接口返回的密码哈希字段没有出现在data里。如果出现PHP报错,看命令行输出基本上能定位,最常见的是PDO连接串里密码不匹配或者数据库没建。用curl确认通过,后端这层就算立住了,后面的B4A对接是在跟一个稳定服务打交道。

3. B4A端HTTP请求封装:用OkHttpUtils2把注册登录跑通

3.1 工程配置:库选择与Android权限

B4A里做HTTP的老方案是HttpUtils2,新方案是OkHttpUtils2,后者底层是OkHttp,带连接池、超时控制、更稳的异步回调。新工程建议直接用OkHttpUtils2,旧工程迁移也只需要改类名和方法名,差异不大。

打开B4A后,在Libraries标签勾选OkHttpUtils2和JSON,然后在Manifest编辑器里加权限。Android 9及以上默认禁止明文HTTP流量,如果后端是http://加IP的形式,必须显式放开,这是新手最容易卡住的第一个点:

AddPermission(android.permission.INTERNET)

同时在Manifest的application节点加:

<application android:usesCleartextTraffic="true">

解释一下:usesCleartextTraffic只影响明文HTTP,不影响HTTPS。如果未来上了正式环境换成HTTPS,这行可以删掉。开发阶段为了连本地IP,先留着不丢人。权限加错位置会直接编译失败,症状是编译日志里出现Manifest merger failed,看到这个不要慌,检查是不是把application节点写到别的地方去了。

3.2 注册请求的最小代码:HttpJob与JobDone回调

B4A的OkHttpUtils2异步模型很简单,发起请求后,任务完成自动回调JobDone,主线程直接更新UI,不需要再管Handler或协程。注册按钮的事件里写:

Sub btnRegister_Click Dim job As HttpJob job.Initialize("register", Me) Dim generator As JSONGenerator generator.Initialize generator.WriteStartObject generator.WriteProperty("username", txtUsername.Text) generator.WriteProperty("password", txtPassword.Text) generator.WriteEndObject job.PostString("http://192.168.1.10:8080/api/register.php", generator.ToString) End Sub

发起请求的那一行,前面是一段可复用的JSON生成逻辑。B4A内置JSON库里有JSONGenerator类,专门用来生成JSON字符串,比手拼字符串安全得多——手拼容易在引号转义上翻车,尤其是密码里带双引号时。PostString方法的第二个参数就是请求体字符串,它会作为HTTP POST的body发出去。

回调里做响应解析和UI提示:

Sub JobDone(Job As HttpJob) If Job.Success Then Dim parser As JSONParser parser.Initialize(Job.GetString) Dim root As Map = parser.NextObject Select Case root.Get("code") Case 200 ToastMessageShow("注册成功", True) Case 409 ToastMessageShow(root.Get("msg"), True) Case Else ToastMessageShow("注册失败:" & root.Get("msg"), True) End Select Else ToastMessageShow("网络错误:" & Job.Error, True) End If Job.Release End Sub

这里要说得细一点。Job.Success为True只代表HTTP请求完成,不代表业务成功,真正的业务状态码在JSON的code字段里,所以用Select Case分支处理。Job.GetString拿到的就是后端返回的原始JSON字符串,交给JSONParser解析成Map,然后按key取值。最后Job.Release必须调用,它负责释放连接句柄,漏了的话连续操作几十次后App会卡死或请求无响应,这是B4A网络编程最容易忽略的“隐藏炸弹”。

3.3 登录请求与超时参数:加一个Takeoff,再设Timeout

登录比注册多的动作是保存登录态。先看请求部分的代码:

Sub btnLogin_Click Dim job As HttpJob job.Initialize("login", Me) Dim generator As JSONGenerator generator.Initialize generator.WriteStartObject generator.WriteProperty("username", txtUsername.Text) generator.WriteProperty("password", txtPassword.Text) generator.WriteEndObject job.Timeout = 15000 job.PostString("http://192.168.1.10:8080/api/login.php", generator.ToString) End Sub

与注册相比,多了一行job.Timeout = 15000。这里单位是毫秒,15000就是15秒。默认10秒在公网弱网场景下容易误判失败,局域网开发10秒够用,但一旦App要部署到外网服务器,我建议至少拉到15到20秒。Timeout管的是整个请求从发起到响应的总时长,不单独区分连接和读取,设太短会误伤,设太长会让用户等待太久。

登录成功后的回调里,除了提示,还要做两件事:把返回的token和uid存起来,然后跳转到主界面。存储方案在下一章专门讲,这里先把保存动作写上:

Case 200 Dim data As Map = root.Get("data") Dim kvs As KeyValueStore2 kvs.Initialize(File.DirInternal, "session") kvs.Put("token", data.Get("token")) kvs.Put("uid", data.Get("uid")) kvs.Put("username", data.Get("username")) kvs.Flush StartActivity(MainActivity)

注意data.Get("token")对应的token是后端在登录成功后生成并返回的。如果后端还没有实现token返回,只有uid和username,那就先存这两个,但后续自动登录和会话保持会缺一大块。我的建议是后端登录接口直接返回token字段,哪怕是最简单的“用户名+时间戳+随机数”拼接串,也能让B4A端把登录态管理先跑起来。

3.4 请求头与编码:床下的Content-Type坑

B4A的PostString方法有一个行为要注意:它不一定自动设置Content-Type为application/json。如果你的后端是用$_POST取参数,那一定会扑空,因为$_POST只能解析表单格式的body。我前文刻意让后端用php://input读原始body,就是为了绕开这个不确定性。

如果后端是别人写的、已经定型用$_POST取参,那你需要在B4A端手动设置请求头,让后端能正确识别:

job.PostString("http://192.168.1.10:8080/api/login.php", "username=" & username & "&password=" & password)

这种情况下参数要拼成URL编码表单格式。但我还是推荐走JSON+php://input的方案,因为后端逻辑更清晰、参数不会被URL编码引起的中文问题干扰,后续加复杂嵌套参数也不用改传输格式。

4. JSON解析与登录态保持:token不落地,App等于没登录

4.1 JSON解析的健壮写法:null值防御

B4A的JSON库会把对象解析成Map、数组解析成List。解析时最怕遇到null,一个data字段为null就能让整个App崩溃。后端要配合,在返回数据时保证结构稳定。我一般在PHP公共函数里做一层强制转换:

function api_success($data = []) { echo json_encode(['code' => 200, 'msg' => 'ok', 'data' => (object)$data]); }

这里把数组强制转成object,JSON输出里data就是{}而不是null。B4A端的解析代码也要做防御:

Dim parser As JSONParser parser.Initialize(responseText) Dim root As Map = parser.NextObject Dim data As Map If root.ContainsKey("data") And root.GetType("data") = 0 Then data = root.Get("data") Else Log("data字段缺失或为null") Return End If

root.GetType("data")返回值的含义需要注意:B4A的Map.GetType是按类型编号返回的,0对应Map类型(又是Map类型),-1表示不存在。如果不做类型判断,直接用root.Get("data"),遇到null或字符串就会抛异常。这行代码能挡住后端返回异常结构时的大部分闪退。

4.2 token存哪里:KeyValueStore2与内存缓存双写

登录成功后拿到的token,存储位置就两个选择:内存变量适合短期快速访问,KeyValueStore2适合持久化。我采取的是双写策略:

' 全局变量,快速访问 gToken = data.Get("token") gUid = data.Get("uid") ' 本地持久化,App重启后恢复 Dim kvs As KeyValueStore2 kvs.Initialize(File.DirInternal, "session") kvs.Put("token", gToken) kvs.Put("uid", gUid) kvs.Flush

KeyValueStore2的Initialize第一个参数传File.DirInternal,这个目录是App的私有目录,其他应用访问不到,比外部存储安全。写入后必须调用Flush,否则数据只是停留在内存缓存里,进程一被杀就丢。这一点文档里写得轻描淡写,实际丢过数据的都知道疼。

为什么不建议直接存SharedPreferences的老式写法?KeyValueStore2本身就是B4A对SharedPreferences的封装,类型支持更全,还能存Long和Map,不需要自己序列化。直接用官方推荐封装,少踩类型转换的坑。

4.3 自动登录:启动时读token并向后端校验

App启动时,MainActivity的Create事件里先检查本地有没有登录态:

Sub Activity_Create(FirstTime As Boolean) Dim kvs As KeyValueStore2 kvs.Initialize(File.DirInternal, "session") If kvs.ContainsKey("token") Then ' 有token,尝试自动登录 CheckLoginAndJump(kvs.Get("token")) Else StartActivity(LoginActivity) End If End Sub

CheckLoginAndJump内部要调一个后端接口去验证token有效性,而不是傻乎乎地认为本地有token就等于已登录。推荐的后端设计是提供一个profile接口,用token换取用户最新信息:

// api/profile.php $token = $input['token'] ?? $headers['Authorization'] ?? ''; $stmt = $pdo->prepare('SELECT id, username, nickname, status FROM t_user WHERE token = ? AND token_expire_at > NOW()'); $stmt->execute([$token]); $user = $stmt->fetch(); if (!$user) { http_response_code(401); exit(json_encode(['code' => 401, 'msg' => '登录已过期'])); } echo json_encode(['code' => 200, 'msg' => 'ok', 'data' => $user]);

B4A端收到200就更新本地用户信息并跳首页;收到401就清除本地session并跳登录页。这套流程把“自动登录”从乐观判断变成了可靠校验,体验上用户无感,安全上也不会拿着失效token还傻乎乎进主页。

5. 避坑指南:乱码、重复注册、超时与假登录的五条血泪记录

5.1 中文乱码:四层编码不一致的排查顺序

现象:注册成功后,打开数据库看到中文用户名是问号,或者App端收到的提示文字是“注册æˆ�功”这种天书。

原因:字符集在四个环节里不一致。PHP文件本身不是UTF-8保存、HTTP响应头没带charset、MySQL连接串没指定utf8mb4、B4A端请求头编码不对,任何一层出问题都会乱。查的时候从后往前查:先看MySQL表结构,再看PDO连接串,再查HTTP响应头,最后看PHP源文件编码。

解决:PHP文件统一存成UTF-8无BOM格式,PDO连接串写死charset=utf8mb4,响应头统一加Content-Type: application/json; charset=utf-8。B4A端不需要特殊处理,因为B4A字符串本身就基于UTF-8。四层对齐后,从注册到显示都不会再出现乱码。如果还乱,用命令行curl请求接口看返回的原始字节,确认是后端返回就乱还是B4A端解析乱,一步定位。

5.2 注册重复提交:UNIQUE约束抛异常,但没接住

现象:连续快速双击注册按钮,两次请求都发了。第一次显示成功,第二次却弹出“服务器错误”而不是“用户名已存在”。

原因:代码里先SELECT再INSERT,第一次INSERT提交后,第二次请求的SELECT可能在第一次提交之前执行了,查不到记录,于是也尝试INSERT,结果触发了数据库的唯一约束,抛出了Duplicate entry异常,而接口没有捕获这个异常,返回了500。

解决:在INSERT语句外套try-catch,捕获到SQLSTATE 23000就返回“用户名已存在”:

try { $stmt->execute([$username, $hash]); } catch (PDOException $e) { if ($e->getCode() == 23000) { http_response_code(409); exit(json_encode(['code' => 409, 'msg' => '用户名已存在'])); } throw $e; }

这比单纯靠SELECT判断靠谱得多,因为它是把数据库约束当成最终裁判,而不是依赖竞态窗口。B4A端也要做防连点,在JobDone回调前禁用按钮,这是两个端各自的防线,缺一不可。

5.3 请求超时:局域网IP能通,外网域名必挂

现象:本地连192.168.x.x的接口一切正常,部署到公网服务器后,登录经常转圈十几秒然后“网络错误”。抓包看到请求发出去了,响应迟迟不回。

原因:两边超时设置不匹配。B4A端默认超时10秒,公网服务器如果带宽不稳或后端PHP执行慢,10秒很容易超;同时PHP端默认执行时间上限是30秒,理论上不是瓶颈,但PHP-FPM并发请求多时,排队时间也会被算进B4A的超时里。

解决:B4A端把job.Timeout设为15000到20000,后端PHP脚本头部加一行set_time_limit(10)防止个别慢SQL拖垮进程。真正到弱网环境,还需要做一次失败重试:第一次超时后等1秒再发一次,很多场景第二发就能成功。重试逻辑不要写在JobDone里直接循环调用,而是用一个重试计数器,超过2次就放弃并给用户提示。

5.4 登录状态假保存:本地有token就被当成已登录

现象:用户登录成功后杀掉App,再打开直接进主界面,但点击任何需要登录保护的功能都提示“请重新登录”。看起来App没退出,其实请求全在401。

原因:自动登录只检查了本地有没有token,没有向后端确认token是否有效。后端可能因为安全策略把token过期时间设得很短,或者管理员手动踢人,App这边完全不知道。

解决:按前文所述,启动时调用profile接口做一次真实校验,401就清session回登录页。校验发生在后台几秒内,用户无感。注意不要在校验完成前渲染主界面的敏感数据,可以先显示一个启动动画,等校验结果出来再跳转。

5.5 注册成功但PHP崩溃:lastInsertId返回的不是uid

现象:注册接口第一次跑通了,但第二次注册同一个用户名时返回500,错误日志显示Fatal error: Uncaught PDOException。

原因:执行INSERT后紧接着调用lastInsertId(),但如果是重复注册触发了异常,代码没有catch。更隐蔽的情况是表里没有自增主键,lastInsertId永远返回0,B4A端拿到的uid就是0,后续逻辑全乱。

解决:第一,INSERT必须走try-catch;第二,建表时确保id列是AUTO_INCREMENT;第三,B4A端不要假设uid一定大于0,拿到0或空值时给个列表页刷新逻辑,避免把0写入KVS然后到处查询。

6. 上生产前要补的最后三课:密码成本因子、Token续期与请求日志

本地能注册能登录,只是完成了30%,剩下的70%是让它安全、可维护、出问题能找到原因。第一课是密码哈希的成本因子。PHP的password_hash默认cost是10,在普通服务器上单次哈希耗时约几十毫秒,够用。但如果你的服务器CPU比较强,可以提一档:

$hash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);

cost每+1,计算耗时翻倍。12在大多数服务器上是几十到一百毫秒,用户体验上感觉不到,但暴力破解的代价大了4倍。验证时不用改任何代码,password_verify会自动从哈希串里读出当时的cost,所以老用户不受影响。

第二课是token滑动续期。不要设计成“token永久有效”,出安全问题没法收回。正解是token带过期时间,后端在profile接口里判断如果剩余有效期少于7天,就顺手签发一个新token一起返回。B4A端在解析profile响应时,如果发现data里有新token,就覆盖本地KVS。这样用户只要在有效期内打开过一次App,token就会自动续上,体验上接近“永久登录”,安全上又可控。

第三课是请求日志。至少在关键的登录和注册接口里,把谁在什么时候从哪个IP请求了什么,成功还是失败,记成一行日志。出了安全问题,这些日志就是破案线索:

error_log( date('Y-m-d H:i:s') . '|' . $_SERVER['REMOTE_ADDR'] . '|' . ($input['username'] ?? '') . '|' . __FILE__ . "\n", 3, __DIR__ . '/api.log' );

我自己后来的习惯是:任何新接口先写三行日志再写业务代码。登录注册更是如此,用户说“点登录没反应”,你看一眼日志就能知道是参数没传、密码错了、还是被限流了,十分钟能定位的事不用来回复读。这两条接口在生产环境要按“随时可能被攻击”来设计,日志写得越细,事后止损越快。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进