返回

安卓 App 读 MySQL 多行崩溃? 详解 PHP 与 JSON 常见错误

mysql

安卓 App 获取 MySQL 多行数据就崩溃?揪出 PHP 和 JSON 的那些坑

写安卓 App,从服务器数据库拉数据是家常便饭。但有时候,明明感觉代码没啥大问题,可一请求多条数据,App 就“duang”一下崩了,留下一堆看不懂的错误日志。别急,这事儿不少人都遇到过。咱们今天就来扒一扒,特别是当你的后端用 PHP + MySQL 时,这种诡异的崩溃是咋回事,又该怎么解决。

啥情况?App 一读 MySQL 多行数据就崩了

你可能正在做一个功能,比如根据用户名,从 MySQL 数据库里捞出这个用户相关的所有优惠券信息。你在安卓这边写了个 AsyncTask,用一个网络请求库(这里看起来像个自定义的 jsonParser)去访问你的 PHP 接口。

你的 doInBackground 代码大概是这样:

   protected String doInBackground(String... args) {
        try {
            // 构建请求参数
            List<NameValuePair> params = new ArrayList<NameValuePair>();
            params.add(new BasicNameValuePair("name", name)); // 根据 name 查询
            // 通过 URL 发起 GET 请求,获取 JSON 字符串
            JSONObject json = jsonParser.
                    makeHttpRequest(url_all_coupons, "GET", params);

            // 在 Logcat 里看看返回了啥
            Log.d("All Products: ", json.toString()); // 这里可能就是崩溃点

            // 检查返回状态码,看看请求成功没
            int success = json.getInt(TAG_SUCCESS); // 如果 json 是 null,这里就炸了 (NullPointerException)

            if (success == 1) {
                // 成功找到数据
                // 获取名叫 TAG_COUPONS 的 JSON 数组
                coupons = json.getJSONArray(TAG_COUPONS);

                // 遍历数组里的每个优惠券对象
                for (int i = 0; i < coupons.length(); i++) {
                    JSONObject c = coupons.getJSONObject(i); // 解析每个 JSON 对象

                    // 从 JSON 对象里提取具体字段
                    String couponcreated = c.getString(TAG_COUPONCREATED);
                    String couponexpires = c.getString(TAG_COUPONEXPIRES);
                    String coupondetails = c.getString(TAG_COUPONDETAILS);
                    // ... 后续处理数据 ...
                }
            } else {
                // 处理失败情况,比如没找到数据
            }
        } catch (JSONException e) {
            Log.e("JSON Parser", "Error parsing data " + e.toString()); // JSON 解析出错会被这里捕获
            e.printStackTrace();
        } catch (Exception e) {
            // 其他异常,比如网络问题
            Log.e("Network Error", "Error in network operation " + e.toString());
            e.printStackTrace();
        }
        return null; // 返回结果给 onPostExecute
   }

然后,你满怀期待地运行 App,结果……崩了!Logcat 刷出一堆红字:

 E/JSON Parser﹕ Error parsing data org.json.JSONException: Value <br of type java.lang.String cannot be converted to JSONObject

 3961-4003/info.androidhive.loginandregistration E/AndroidRuntime﹕ FATAL EXCEPTION: AsyncTask #1
    java.lang.RuntimeException: An error occured while executing doInBackground()
            at android.os.AsyncTask$3.done(AsyncTask.java:299)
            // ... 省略一堆堆栈跟踪 ...
     Caused by: java.lang.NullPointerException
            at info.androidhive.loginandregistration.CouponPageActivity$LoadAllCoupons.doInBackground(CouponPageActivity.java:98) // 注意这行,空指针发生在这里
            // ... 省略更多堆栈 ...

关键信息有两个:

  1. org.json.JSONException: Value <br of type java.lang.String cannot be converted to JSONObject: 安卓想把服务器返回的数据当成一个 JSON 对象来解析,结果发现开头是个 <br 标签,这根本不是 JSON 格式,解析器直接懵了,抛出 JSONException
  2. java.lang.NullPointerException (空指针异常): 紧接着 JSON 解析失败后,代码试图在 CouponPageActivity.java 的第 98 行(对应上面代码里的 json.getInt(TAG_SUCCESS);)操作一个 null 对象。这说明,你的 jsonParser.makeHttpRequest 在遇到那个无效的 <br 后,可能没能成功构建 JSONObject,而是返回了 null

所以,根源在于服务器返回的不是纯净的 JSON 数据。

刨根问底:为啥会收到 <br> 而不是 JSON?

既然安卓端收到的是 <br ... 这样的东西,那问题多半出在提供数据的 PHP 脚本那边。咱们来看看你的 PHP 代码片段:

<?php
// ... 省略了数据库连接代码 ...

// 获取从安卓 App 传来的 'name' 参数
$name = $_GET['name']; // 假设你做了必要的清理防注入,这里没显示

// 执行 SQL 查询
// 注意:mysql_* 系列函数已经过时并且不安全,后面会说怎么改
$result = mysql_query("SELECT * FROM coupons WHERE name = '$name'") or die(mysql_error()); // 问题可能在这里!

// 检查查询结果是不是空的
if (mysql_num_rows($result) > 0) {
    // 找到数据了,准备构建 JSON
    $response["products"] = array(); // PHP 5.4+ 可以用 []

    // 循环读取每一行数据
    while ($row = mysql_fetch_array($result)) {
        // 创建一个临时数组存这一行的数据
        $product = array();
        $product["couponcreated"] = $row["couponcreated"];
        $product["couponexpires"] = $row["couponexpires"];
        $product["coupondetails"] = $row["coupondetails"];

        // 把这行数据加到最终的 response 数组里
        array_push($response["products"], $product);
    }
    // 设置成功标志
    $response["success"] = 1;

    // 把 PHP 数组转换成 JSON 字符串并输出
    echo json_encode($response);

} else {
    // 没找到数据
    $response["success"] = 0;
    $response["message"] = "No products found"; // 最好加个消息

    // 输出 JSON 格式的错误信息
    echo json_encode($response);
}

?> // 确保这后面没有多余的空格或字符

瞄准这行:$result = mysql_query("SELECT * FROM coupons WHERE name = '$name'") or die(mysql_error());

这里的 or die(mysql_error()) 是个常见的 “懒人” 错误处理方式。它的意思是:如果 mysql_query 执行失败(比如 SQL 语法错了、数据库连接断了、表或字段不存在等),就立刻停止脚本执行 (die),并且把 mysql_error() 返回的数据库错误信息直接 输出 到页面上。

MySQL 的错误信息有时候会包含 HTML 标签(比如为了在网页上显示得好看些),或者 PHP 配置本身可能会在输出错误信息前加上 <br /> 标签。即使不包含 HTML,只要 die() 被触发,并且输出了任何非 JSON 的文本,安卓那边接收到的就不再是纯净的 JSON 数据,解析自然失败,进而导致 JSONException

另外,还有几种可能性导致 PHP 输出不干净:

  1. PHP 警告或通知 (Warnings/Notices): 如果你的 PHP 配置 (php.ini 里的 display_errors) 设置为 On,并且代码里有一些不影响运行但 PHP 觉得“不规范”的地方(比如使用了未定义的变量,或者像这里用了已废弃mysql_* 函数),PHP 可能会直接把这些警告信息输出。这些警告信息通常包含 HTML 标签,比如 <br /> 或者 <b>Warning</b>:
  2. echoprint 调试信息: 开发过程中可能随手加了 echo "Debug point 1"; 忘了删掉。
  3. <?php ... ?> 标签之外的字符: 比如在 <?php 之前或 ?> 之后不小心加了空格、换行或者任何 HTML 代码。

知道了病根,咱们就能对症下药了。

治标更要治本:修复方案来了

解决这个问题,不能头痛医头脚痛医脚,得从 PHP 和安卓两方面入手,并且顺便把潜在的安全风险也处理掉。

方案一:净化 PHP 输出,只给纯净的 JSON

这是最直接的办法,确保你的 PHP 脚本只输出 JSON,其他啥都没有。

原理与作用:

保证 HTTP 响应体就是一串干干净净、格式正确的 JSON 字符串。安卓的 JSON 解析器就能正常工作了。

操作步骤:

  1. 优雅处理数据库错误,别用 die():
    or die(mysql_error()) 改掉。改成检查 $result 是否为 false,如果失败了,应该记录错误日志(给开发者看),然后给客户端返回一个表示“出错”的 JSON。

    <?php
    // ... 连接数据库 ...
    
    // 先设置响应头,告诉客户端这是 JSON
    header('Content-Type: application/json; charset=utf-8'); // 很重要!
    
    $response = array(); // 初始化响应数组
    
    $name = $_GET['name']; // 再次提醒:实际项目要严格过滤和清理用户输入!
    
    $query = "SELECT * FROM coupons WHERE name = '$name'"; // 拼接 SQL,有风险
    $result = mysql_query($query);
    
    // 检查查询是否成功
    if ($result === false) {
        // 查询失败了!
        $response["success"] = 0;
        $response["message"] = "数据库查询失败,请稍后重试。"; // 给用户的提示
    
        // 【重要】把实际的 MySQL 错误记录到服务器日志里,而不是直接给用户
        error_log("MySQL Query Error: " . mysql_error() . " in script " . __FILE__ . " for query: " . $query);
    
    } else {
        // 查询成功,继续处理结果
        if (mysql_num_rows($result) > 0) {
            $response["products"] = array();
            while ($row = mysql_fetch_assoc($result)) { // 建议用 mysql_fetch_assoc 获取关联数组
                $product = array();
                $product["couponcreated"] = $row["couponcreated"];
                $product["couponexpires"] = $row["couponexpires"];
                $product["coupondetails"] = $row["coupondetails"];
                array_push($response["products"], $product);
            }
            $response["success"] = 1;
        } else {
            // 查询成功,但没找到匹配的数据
            $response["success"] = 0; // 可以用 0 代表没找到,或用其他状态码区分
            $response["message"] = "未找到名为 '$name' 的优惠券。";
        }
    }
    
    // 最终统一输出 JSON
    echo json_encode($response);
    
    // 脚本结束,不要有任何其他输出了
    exit; // 确保后面不会意外输出任何东西
    
    ?>
    
  2. 关闭或重定向 PHP 错误显示:

    • 开发环境: 可以暂时保留错误显示方便调试,但要明白这可能干扰 API 输出。
    • 生产环境: 绝对 要禁止向浏览器输出错误信息。修改 php.ini
      display_errors = Off
      log_errors = On
      error_log = /path/to/your/php_error.log ; 指定日志文件路径
      
      如果你无法修改 php.ini,可以在 PHP 脚本开头临时设置:
      <?php
      ini_set('display_errors', 0); // 禁止向浏览器输出错误
      ini_set('log_errors', 1);     // 允许记录错误日志
      // ini_set('error_log', '/path/to/log/file'); // 如果需要指定日志文件位置
      
      header('Content-Type: application/json; charset=utf-8'); // 记得设置 Content-Type
      
      // ... 你的其他 PHP 代码 ...
      ?>
      
    • 原理: 让 PHP 错误信息写到服务器本地日志文件里,这样既能排查问题,又不污染给客户端的 JSON 输出。
  3. 仔细检查代码: 确保 PHP 文件从头到尾,<?php 标签之前和 ?> 标签之后,没有任何空格、换行或者 HTML 代码。并且,代码逻辑里没有意外的 echoprint

安全建议:

  • 这个方案虽然解决了 JSON 格式问题,但 PHP 代码里直接拼接 $name 到 SQL 语句中,存在严重的 SQL 注入 风险!攻击者可以通过构造特殊的 name 值(比如 ' OR '1'='1)来绕过验证或窃取、篡改数据。这必须在方案二中解决。

方案二:升级到更现代、更安全的数据库交互方式 (PDO 或 MySQLi)

既然都动手改 PHP 代码了,强烈建议把过时的 mysql_* 函数换掉,用 PDO(PHP Data Objects)或者 MySQLi(MySQL Improved Extension)。

原理与作用:

  • 安全性: PDO 和 MySQLi 支持预处理语句 (Prepared Statements) ,这是防御 SQL 注入最有效的手段。它把 SQL 命令和用户输入的数据分开处理,数据库本身知道哪些是指令、哪些是数据,从而避免了恶意数据被当成指令执行。
  • 功能性: 提供更丰富的数据库操作功能和更好的错误处理机制。
  • 维护性: mysql_* 在 PHP 5.5.0 中被废弃,并在 PHP 7.0.0 中被移除。继续使用意味着你的代码无法运行在较新版本的 PHP 上,也得不到官方维护。

操作步骤与代码示例 (以 PDO 为例):

假设你已经配置好了 PDO 连接数据库 ($pdo 对象)。

<?php
// ... 假设你已经通过 PDO 连接了数据库,得到了 $pdo 对象 ...
// 例如: $dsn = 'mysql:host=localhost;dbname=your_database;charset=utf8mb4';
//      $username = 'db_user';
//      $password = 'db_password';
//      $options = [
//          PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // 让 PDO 抛出异常,方便捕获
//          PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,       // 默认取关联数组
//          PDO::ATTR_EMULATE_PREPARES   => false,                  // 禁用模拟预处理,使用真正的预处理
//      ];
//      try {
//          $pdo = new PDO($dsn, $username, $password, $options);
//      } catch (\PDOException $e) {
//          // 实际项目中应该记录日志,并返回通用错误信息
//          throw new \PDOException($e->getMessage(), (int)$e->getCode());
//      }

header('Content-Type: application/json; charset=utf-8');

$response = array();
$name = $_GET['name']; // 从请求获取 name

// 【安全核心】使用预处理语句
$sql = "SELECT couponcreated, couponexpires, coupondetails FROM coupons WHERE name = :name"; // 使用命名占位符 :name

try {
    // 1. 准备 SQL 语句
    $stmt = $pdo->prepare($sql);

    // 2. 绑定参数值到占位符 (提供了类型安全)
    $stmt->bindParam(':name', $name, PDO::PARAM_STR); // 明确告诉 PDO,这是个字符串

    // 3. 执行查询
    $stmt->execute();

    // 4. 获取所有匹配的行 (PDO::FETCH_ASSOC 使每行是关联数组)
    $coupons = $stmt->fetchAll(); // 或者使用 fetch() 逐行处理

    if ($coupons) {
        // 找到了数据
        $response['success'] = 1;
        $response['products'] = $coupons; // 直接就是我们想要的数组结构
    } else {
        // 没找到数据
        $response['success'] = 0;
        $response['message'] = "未找到名为 '$name' 的优惠券。";
    }

} catch (PDOException $e) {
    // 捕获数据库操作过程中可能发生的异常
    $response['success'] = 0;
    $response['message'] = "数据库操作出错,请稍后再试。"; // 对外显示通用错误

    // 对内记录详细错误日志
    error_log("PDOException in " . __FILE__ . ": " . $e->getMessage() . " for name: " . $name);
}

// 输出 JSON 结果
echo json_encode($response);
exit;

?>

代码示例 (以 MySQLi 面向对象方式为例):

<?php
// ... 假设你已经通过 MySQLi 连接了数据库,得到了 $mysqli 对象 ...
// 例如: $mysqli = new mysqli('localhost', 'db_user', 'db_password', 'your_database');
//      if ($mysqli->connect_error) {
//          // 记录日志,返回错误
//          die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error); // 不推荐直接 die
//      }
//      $mysqli->set_charset('utf8mb4'); // 设置字符集

header('Content-Type: application/json; charset=utf-8');

$response = array();
$name = $_GET['name'];

// 【安全核心】使用预处理语句
$sql = "SELECT couponcreated, couponexpires, coupondetails FROM coupons WHERE name = ?"; // 使用问号 ? 作为占位符

try {
    // 1. 准备 SQL 语句
    $stmt = $mysqli->prepare($sql);
    if ($stmt === false) {
        // 准备失败,通常是 SQL 语法问题
        throw new Exception("MySQLi prepare failed: " . $mysqli->error);
    }

    // 2. 绑定参数值到占位符 (类型 "s" 代表 string)
    $stmt->bind_param("s", $name);

    // 3. 执行查询
    if (!$stmt->execute()) {
        // 执行失败
        throw new Exception("MySQLi execute failed: " . $stmt->error);
    }

    // 4. 获取结果
    $result = $stmt->get_result(); // 获取结果集对象

    if ($result->num_rows > 0) {
        $response['success'] = 1;
        $response['products'] = $result->fetch_all(MYSQLI_ASSOC); // 一次性获取所有关联数组行
    } else {
        $response['success'] = 0;
        $response['message'] = "未找到名为 '$name' 的优惠券。";
    }

    // 5. 关闭 statement
    $stmt->close();

} catch (Exception $e) {
    // 捕获过程中可能发生的异常
    $response['success'] = 0;
    $response['message'] = "数据库操作出错,请稍后再试。";

    // 记录详细错误日志
    error_log("MySQLi Exception in " . __FILE__ . ": " . $e->getMessage() . " for name: " . $name);

    // 如果 $stmt 已创建但发生错误,也尝试关闭它
    if (isset($stmt) && $stmt instanceof mysqli_stmt) {
        $stmt->close();
    }
}

// 关闭数据库连接(如果是持久连接则不需要)
// $mysqli->close();

// 输出 JSON 结果
echo json_encode($response);
exit;

?>

安全建议:

  • 永远使用预处理语句 处理来自用户(或任何不可信来源)的数据。这是防止 SQL 注入的黄金法则。
  • 最小权限原则: 数据库用户只授予执行所需操作的最小权限。比如,只读操作就给只读权限。
  • 验证和清理输入: 除了预处理,PHP 端还应该对 $name 进行基本的验证(比如检查长度、格式等),防止无效或恶意输入。

进阶使用技巧:

  • PDO 错误模式: 设置 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,让 PDO 在出错时直接抛出 PDOException,可以用 try...catch 块来捕获和处理,代码更清晰。
  • MySQLi 错误报告: 使用 mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); 让 MySQLi 在出错时抛出异常,而不是仅返回 false 或警告。
  • Fetch Modes: PDO 和 MySQLi 都支持多种数据获取模式(如 PDO::FETCH_ASSOC, MYSQLI_ASSOC 只获取关联数组;PDO::FETCH_OBJ, MYSQLI_FETCH_OBJ 获取对象等),选择合适的模式能简化后续代码。
  • 事务处理: 对于需要多个数据库操作一起成功或失败的场景(如转账),使用事务(beginTransaction, commit, rollBack)。

方案三:优化 Android 端的 JSON 解析和错误处理

即使后端 API 设计得很好,安卓端代码也应该有防御性编程,处理可能出现的异常情况。

原理与作用:

让安卓 App 在遇到网络问题、API 返回非预期数据(比如 null、空字符串、格式错误)时,不会直接崩溃,而是能优雅地处理错误,给用户一个友好的提示,或者至少记录下详细日志方便排查。

操作步骤与代码示例 (Java):

修改 doInBackground 方法:

import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
// ... 其他 import ...

protected String doInBackground(String... args) {
    JSONObject json = null; // 初始化为 null

    try {
        List<NameValuePair> params = new ArrayList<NameValuePair>();
        params.add(new BasicNameValuePair("name", name));

        // 【改进点】发起请求后,得到 json 对象
        json = jsonParser.makeHttpRequest(url_all_coupons, "GET", params);

        // 【改进点】非常重要:检查 json 是否为 null
        if (json == null) {
            Log.e("API Error", "Received null JSON response from server.");
            // 这里可以返回一个特定的错误代码或消息给 onPostExecute
            return "error_null_response";
        }

        Log.d("API Response", json.toString()); // 确认收到了什么

        // 【改进点】将 JSON 解析操作也包在 try-catch 中,更精细地捕获 JSONException
        try {
            int success = json.getInt(TAG_SUCCESS);

            if (success == 1) {
                JSONArray couponsArray = json.getJSONArray(TAG_COUPONS);

                for (int i = 0; i < couponsArray.length(); i++) {
                    JSONObject c = couponsArray.getJSONObject(i);

                    // 【改进点】对每个 getString 也可以做更细致的检查,比如用 optString
                    String couponcreated = c.optString(TAG_COUPONCREATED, "N/A"); // 如果 key 不存在或值为 null,返回默认值 "N/A"
                    String couponexpires = c.optString(TAG_COUPONEXPIRES, "");     // 返回空字符串
                    String coupondetails = c.getString(TAG_COUPONDETAILS);         // 如果确定这个字段必须有,可以用 getString,但需处理可能的 JSONException

                    // ... 处理数据 ...
                }
                return "success"; // 操作成功
            } else {
                // API 返回 success != 1 的情况
                String message = json.optString("message", "Unknown server error (success=0)");
                Log.w("API Logic Error", "Server response indicates failure: " + message);
                return "error_server_logic: " + message; // 返回带消息的错误标识
            }

        } catch (JSONException jsonEx) {
            // 这个 catch 专门处理 JSON 结构解析错误,比如 key 不存在、类型不匹配
            Log.e("JSON Parsing Error", "Error parsing JSON fields: " + jsonEx.toString());
            // json 对象本身不是 null,但内部结构有问题
            return "error_json_structure";
        }

    } catch (Exception e) {
        // 这个 catch 捕获网络请求过程中的异常或其他未预料的错误
        // 注意:如果 jsonParser.makeHttpRequest 内部抛出了 JSONException,可能在这里被捕获
        Log.e("Background Task Error", "Error during doInBackground: " + e.toString());
        e.printStackTrace(); // 打印完整堆栈信息对于调试很重要
        return "error_generic_exception";
    }
}

进阶使用技巧:

  • 使用 Gson 或 Moshi: 手动解析 JSON 容易出错且代码冗长。推荐使用像 Gson 或 Moshi 这样的库。你可以定义一个 Java 或 Kotlin 的数据类 (POJO/Data Class) 来匹配 JSON 结构,然后一行代码就能完成转换,它们还能更好地处理 null 值和类型转换。

    // 假设你有 Kotlin Data Class
    data class Coupon(val couponcreated: String?, val couponexpires: String?, val coupondetails: String?)
    data class ApiResponse(val success: Int, val products: List<Coupon>?, val message: String?)
    
    // 使用 Gson
    // ... 在 doInBackground ...
    val responseBody = // 获取服务器返回的 String
    val gson = Gson()
    try {
        val apiResponse = gson.fromJson(responseBody, ApiResponse::class.java)
        if (apiResponse != null && apiResponse.success == 1 && apiResponse.products != null) {
            // 使用 apiResponse.products
            return "success"
        } else {
            // 处理错误情况
            Log.w("API Error", "Gson parsed response indicates failure or null data: ${apiResponse?.message}")
            return "error_api_logic"
        }
    } catch (e: JsonSyntaxException) {
        Log.e("Gson Error", "Failed to parse JSON: $responseBody", e)
        return "error_json_syntax"
    } catch (e: Exception) {
        // ... 其他异常处理 ...
    }
    

安全建议:

  • 虽然主要是健壮性问题,但注意不要在客户端日志或用户界面上暴露过多敏感的错误信息(比如数据库内部错误详情)。服务器应返回通用的错误提示。

举一反三:一些额外的建议

  • 日志记录的重要性: 别只依赖 Logcat。服务器端(PHP)记录详细的错误和请求日志至关重要。安卓端也可以考虑集成更专业的日志框架(如 Timber),并可能在用户同意的情况下将关键错误日志上传到分析平台(如 Firebase Crashlytics)。
  • API 响应规范: 建立一套统一的 API 响应格式。比如,所有响应都是 JSON 对象,总包含 success (或 status) 字段和可选的 data 字段 (成功时) 或 error 字段(包含错误码 code 和消息 message,失败时)。这让客户端处理起来更方便、更一致。
  • HTTP 状态码: 合理使用 HTTP 状态码。比如,服务器内部错误用 500,请求参数错误用 400,资源未找到用 404,身份验证失败用 401/403。虽然在你的 jsonParser 里可能已经处理了,但这是良好 API 设计的一部分。

解决这类问题往往需要前后端配合。仔细分析错误日志,理解数据流动的每一步,特别是服务器端到底输出了什么,通常就能找到症结所在。别忘了,安全第一,升级老旧代码总是个好主意。