前言

这一篇文章把 Android 里最常见的一条网络请求学习路径串起来:先用 HttpURLConnection 手动完成 GET、POST,再用 JSONObject 和 Gson 处理 JSON,接着切到 OkHttp 和 Retrofit2,把请求构造、回调处理和结果解析一步步收拢。

整条链路都围绕同一组接口展开:查询用户信息时使用 GET,登录时使用 POST。这样做的好处是,前后几种方案处理的是同一类业务目标,读起来能更清楚地看到“底层做了什么”和“高层库帮我们省掉了什么”。

在这里插入图片描述

目录

1. 前言

这一篇文章把 Android 里最常见的一条网络请求学习路径串起来:先用 HttpURLConnection 手动完成 GET、POST,再用 JSONObject 和 Gson 处理 JSON,接着切到 OkHttp 和 Retrofit2,把请求构造、回调处理和结果解析一步步收拢。

整条链路都围绕同一组接口展开:查询用户信息时使用 GET,登录时使用 POST。这样做的好处是,前后几种方案处理的是同一类业务目标,读起来能更清楚地看到“底层做了什么”和“高层库帮我们省掉了什么”。

2. 在真正写请求代码前,先把协议和 HTTP 链路看清楚

2.1 网络通信协议为什么重要

网络请求表面上只是“发一个地址,拿一段结果”,但客户端和服务端真正能把数据收发起来,依赖的是一整套协议约定。协议负责约束数据格式、传输规则、错误检测、连接建立与关闭方式,所以它不是背景知识,而是解释请求为何能成功、为何会失败、参数为何这样组织的底层依据。

从 Android 开发的角度看,只要开始写 HttpURLConnection、OkHttp 或 Retrofit2,本质上就是在用应用层协议去驱动更底层的传输链路。只有先知道链路里每一层负责什么,后面看 URL、请求头、请求体、状态码、JSON 响应这些对象时,才不会停留在“会调用 API”这一层。

2.2 协议的分层模型

网络协议不会把所有职责都堆在一层里,而是采用分层方式组织。学习 Android 网络请求时,最常见的两个模型是 OSI 模型和 TCP/IP 模型。

这张图的作用不是要求把所有层的定义全部背下来,而是帮助建立一个判断:Android 代码里直接接触最多的是应用层对象,例如 HTTP、JSON、接口地址和请求参数;但这些内容能送到服务器,还要依赖更下层的传输、寻址和物理承载能力。

2.2.1 OSI 模型

OSI 模型把网络通信拆成七层:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。它更像一张“理论地图”,用于解释网络世界的职责边界。

对移动开发来说,这个模型的价值主要体现在两个地方:

  1. 能帮助理解为什么 HTTP 不负责连接可靠性,而 TCP 要负责。
  2. 能帮助理解为什么 JSON、编码、加密、页面请求这些对象,看起来都在一次请求里出现,但实际属于不同层面的职责。

2.2.2 TCP/IP 模型

实际开发中更常用的是 TCP/IP 四层模型:网络接口层、网络层、传输层、应用层。Android 网络请求真正跑起来,更多是沿着这套模型去理解。

其中和本篇最相关的是两层:

  • 传输层:决定数据怎么可靠或不可靠地送到对端,核心协议是 TCP 和 UDP。
  • 应用层:决定客户端和服务端如何组织请求与响应,核心协议是 HTTP、HTTPS、FTP、SMTP 等。

也就是说,后面写 GET、POST、请求头、请求体时,处理的是应用层语义;而一次 HTTP 请求最终能否可靠送达,背后通常由 TCP 去承担传输保证。

2.3 TCP、UDP、HTTP、HTTPS 在请求链路里的分工

HTTP 和 HTTPS 是应用层协议,描述的是“怎么请求资源、怎么返回结果”;TCP 和 UDP 是传输层协议,描述的是“数据怎么从一端送到另一端”。这几个协议既不是同一层,也不是互相替代关系。

移动端最常见的业务请求,通常是“应用层用 HTTP 或 HTTPS 组织数据,传输层由 TCP 负责可靠传输”。因此,学习网络请求时把 TCP 连接过程放在前面,是为了理解为什么一次 HTTP 请求不是凭空发出去的。

2.3.1 TCP 的三次握手

TCP 建立连接时,客户端和服务端会经历三次握手,核心目标是确认双方都具备收发能力,并同步初始序列号。

这个过程可以按下面的顺序理解:

  1. 客户端发送带 SYN 标志的数据包,表示“我想建立连接”。
  2. 服务端返回 SYN + ACK,表示“我收到了,也同意建立连接”。
  3. 客户端再发送 ACK,表示“我确认你的响应,连接正式建立”。

理解这个过程后,再去看移动端请求超时、连接失败、服务端未响应这些问题,就不会只停留在“接口地址写错了”这一层,而是会知道连接本身也可能出问题。

2.3.2 TCP 的四次挥手

连接关闭时,TCP 会经历四次挥手,用来确保双方都把剩余数据处理完,再释放资源。

它的顺序是:

  1. 一方发送 FIN,表示自己没有更多数据要发。
  2. 对端返回 ACK,先确认收到这个关闭意图。
  3. 对端确认自己也发完数据后,再发送 FIN。
  4. 发起方最后回一个 ACK,连接关闭完成。

这也是为什么网络请求结束后,不应该一直占着连接和流对象不释放。后面在 HttpURLConnection 里看到 disconnect(),本质上就是要求代码把这条链路收干净。

2.3.3 UDP 的特点

UDP 同样属于传输层,但它和 TCP 完全是两种设计取向。UDP 无连接、不保证可靠、不保证顺序,优点是开销小、速度快、延迟低。

因此它更适合实时语音、视频、游戏同步这类场景。本文演示的接口查询和登录请求都要求结果可靠返回,所以选择的是建立在 TCP 语义之上的 HTTP 请求链路,而不是 UDP。

2.4 HTTP 的基本通信流程与请求方法

一次典型的 HTTP 通信流程非常固定:

  1. 客户端构建请求。
  2. 服务器接收并处理请求。
  3. 服务器返回响应。
  4. 客户端解析响应并更新界面或状态。

本文里会用到最常见的两种请求方法:

  • GET:用于获取资源。参数通常拼到 URL 查询串里,例如 user_id=2&type=archives。
  • POST:用于提交数据。常见做法是把 JSON 放进请求体,例如登录接口把账号和密码作为请求体提交给服务端。

之所以把 GET 和 POST 拆开讲,是因为它们不仅方法名不同,参数放置位置、请求体是否存在、代码构造方式也都不同。

2.5 HTTP 请求和响应到底由什么组成

写 Android 网络请求时,最容易混淆的是“参数到底该写在哪”。这一点只要把 HTTP 请求和响应结构拆开看,就会清楚很多。

请求结构通常包含三部分:

  1. 请求行:包含方法、路径、协议版本,例如 GET /addons/cms/api.user/userInfo HTTP/1.1。
  2. 请求头:描述请求附加信息,例如 Content-Type、Accept、Authorization。
  3. 请求体:真正要提交的数据。GET 往往没有请求体,POST 则经常把 JSON 或表单数据放在这里。

例如登录接口提交的请求体,可以组织成这样一段 JSON:

{
  "account": "lsxbug",
  "password": "123456"
}

响应结构也分成三部分:

  1. 响应行:包含协议版本、状态码和状态描述。
  2. 响应头:描述返回内容属性,例如 Content-Type、Content-Length。
  3. 响应体:真正的业务数据,常见格式是 JSON。

本文后面会频繁处理下面这类登录响应:

{
  "code": 1,
  "msg": "登录成功",
  "time": "1724677422",
  "data": {
    "token": "8583c6c7-a6c3-4d56-90e5-180712ffbb81",
    "user_id": 4
  }
}

这里的 code、msg、time 和 data 都位于响应体里。后面无论用 JSONObject、Gson、OkHttp 还是 Retrofit2,本质上都绕不开“把这段响应体读出来,再转成代码里可操作的数据结构”这件事。

3. 在 Android 中发起网络请求前的准备

在 Android 中常见的请求方案有 HttpURLConnection、OkHttp、Retrofit。这一篇会从最底层一路往上走,不过在正式发请求之前,先要把权限、明文访问和测试接口准备好,否则后面的代码即使语法正确,也跑不起来。

3.1 添加网络权限

如果应用连网络访问权限都没有,任何请求代码都不会真正发出去。因此第一步先在 NetworkByJavaProject/app/src/main/AndroidManifest.xml 里声明 INTERNET 权限:

<uses-permission android:name="android.permission.INTERNET" />

这条权限的作用很单纯,就是告诉系统当前应用需要访问网络。没有它,后面不管是 HttpURLConnection、OkHttp 还是 Retrofit2,都没有发起外部请求的前提。

3.2 usesCleartextTraffic 允许明文传输

项目里访问的测试接口使用的是 http://titok.fzqq.fun/,也就是明文 HTTP。为了让应用能直接访问这类地址,可以先在 application 节点上打开 android:usesCleartextTraffic:

<application
    android:allowBackup="true"
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:fullBackupContent="@xml/backup_rules"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.NetworkByJavaProject"
    android:usesCleartextTraffic="true"
    tools:targetApi="31">

这里最关键的是 android:usesCleartextTraffic="true"。它表示当前应用允许进行明文 HTTP 通信。因为本文演示接口不是 HTTPS,所以如果不打开这项配置,请求在 Android 9.0 及以上设备上很容易直接被系统拦掉。

3.3 配置网络安全策略(Android 9.0 及以上)

如果不想全局放开明文流量,也可以针对指定域名做网络安全策略配置。在 AndroidManifest.xml 中增加 android:networkSecurityConfig:

<application
    android:networkSecurityConfig="@xml/network_security_config">
</application>

然后新建 res/xml/network_security_config.xml,只允许目标域名走明文流量:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">titok.fzqq.fun</domain>
    </domain-config>
</network-security-config>

相比直接打开全局 usesCleartextTraffic,这种方式控制更细。它把“允许明文访问”限制在指定域名上,更适合真实项目里逐步收紧网络安全边界。

3.4 准备好网络接口

整篇示例围绕两类接口展开:

  1. 用户信息查询接口,用 GET,参数里带 user_id 和 type。
  2. 登录接口,用 POST,请求体里带 account 和 password。

对应的测试文档入口为:

http://titok.fzqq.fun

有了这两类接口,后面的所有实现都能围绕同一组业务对象展开,从而更容易看清不同请求库之间的差异。

4. 使用 HttpURLConnection 发起第一个 GET 和 POST 请求

HttpURLConnection 是 Android 原生内置的网络请求方式。它的特点不是“写起来最省事”,而是“网络链路中的每一个环节都要自己处理”。正因为如此,用它打底特别适合建立请求全过程的直觉。

4.1 前置代码:先把页面和点击事件准备好

先准备页面,让 GET 和 POST 两类请求都能从同一个页面触发。布局里放了一个 user_id 输入框、一组登录账号密码输入框,以及两个按钮:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp"
    tools:context=".activity.HttpUrlConnectionActivity">

    <TextView
        android:id="@+id/tv_get_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="GET请求:"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/et_user_id"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入要查询的用户id"
        android:inputType="number"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_get_label" />

    <Button
        android:id="@+id/btn_get"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="发起GET请求"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/et_user_id" />

    <TextView
        android:id="@+id/tv_post_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="40dp"
        android:text="POST 请求:"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btn_get" />

    <EditText
        android:id="@+id/et_user_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入用户名"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_post_label" />

    <EditText
        android:id="@+id/et_password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入密码"
        android:inputType="textPassword"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/et_user_name" />

    <Button
        android:id="@+id/btn_post"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="发起POST请求"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/et_password" />
</androidx.constraintlayout.widget.ConstraintLayout>

这套布局的作用很明确:上半部分负责 GET 查询,下半部分负责 POST 登录。这样两种请求方式虽然代码不同,但输入来源和交互触发方式是一致的。

页面逻辑也先接好,只负责分发点击事件:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_http_url_connection);

    findViewById(R.id.btn_get).setOnClickListener(this);
    findViewById(R.id.btn_post).setOnClickListener(this);
    etUserId = findViewById(R.id.et_user_id);
    etUserName = findViewById(R.id.et_user_name);
    etPassword = findViewById(R.id.et_password);
}

@Override
public void onClick(View v) {
    if (v.getId() == R.id.btn_get) {
        sendGetRequest();
    } else if (v.getId() == R.id.btn_post) {
        sendPostRequest();
    }
}

之所以先把这段代码放在前面,是因为网络请求并不是“独立存在的一段工具代码”,它必须有明确的输入来源和触发时机。这里 EditText 负责承接用户输入,onClick() 负责把交互动作切到不同的请求方法里。

4.2 使用 HttpURLConnection 发送 GET 请求

GET 请求这部分最适合拿来理解 HttpURLConnection 的完整流程,因为它没有请求体,能让注意力集中在“建立连接、读取响应、切回主线程”这几个关键动作上。

先看完整代码:

private void sendGetRequest() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            String id = etUserId.getText().toString();
            String urlAdress = "http://titok.fzqq.fun/addons/cms/api.user/userInfo?user_id=" + id + "&type=archives";
            Log.i(TAG, "urlAdress:" + urlAdress);
            try {
                URL url = new URL(urlAdress);
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("GET");
                connection.setConnectTimeout(8000);
                connection.setReadTimeout(8000);

                InputStream inputStream = connection.getInputStream();
                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));

                StringBuilder builder = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    builder.append(line);
                }

                runOnUiThread(() -> {
                    String string = builder.toString();
                    Log.i(TAG, "网络访问结果:" + string);
                });

                connection.disconnect();
            } catch (MalformedURLException e) {
                throw new RuntimeException(e);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }).start();
}

这段代码可以按执行顺序拆开理解:

  1. 先从 etUserId 里取出用户输入的 id,再把它拼进查询地址。GET 请求的参数就这样直接挂到 URL 后面。
  2. 用 new URL(urlAdress) 创建目标地址对象。它只是描述连接目标,还没有真正发起请求。
  3. 调用 openConnection() 打开连接,再强转为 HttpURLConnection,因为设置请求方法、超时时间这些能力都定义在这个子类里。
  4. setRequestMethod("GET") 明确声明当前请求是 GET。
  5. setConnectTimeout(8000) 和 setReadTimeout(8000) 分别控制建立连接超时和读取数据超时,避免线程无限卡住。
  6. 通过 getInputStream() 拿到服务端返回的数据流,再包成 BufferedReader,方便逐行读取。
  7. 用 StringBuilder 把服务端每一行内容拼成完整响应字符串。
  8. 读取完成后用 runOnUiThread() 回到主线程,因为界面更新和交互反馈不能放在子线程里做。
  9. 最后调用 disconnect() 断开连接,释放底层资源。

这里最需要建立的习惯有两个。

  • 第一个是:网络请求属于耗时操作,必须放到子线程里。所以最外层先开了一个 new Thread(...)。如果把这段逻辑直接放进主线程执行,应用很容易触发卡顿甚至抛出主线程网络访问异常。
  • 第二个是:读取结果只是第一步,真正的业务处理从“拿到那段字符串”之后才开始。这里暂时只是打印日志,后面切到 JSONObject 和 Gson 时,就会把这段结果继续转成结构化数据。

4.3 使用 HttpURLConnection 发送 POST 请求

POST 请求和 GET 最大的区别,是参数不再直接拼在 URL 后面,而是放进请求体里发送给服务端。因此它比 GET 多了“设置请求头”和“通过输出流写入请求体”这两个动作。

完整代码如下:

private void sendPostRequest() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            String loginUrl = "http://titok.fzqq.fun/addons/cms/api.login/login";
            try {
                URL url = new URL(loginUrl);
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("POST");
                connection.setDoOutput(true);
                connection.setRequestProperty("Content-Type", "application/json;charset=utf-8");
                connection.setRequestProperty("Accept", "appliction/json");

                String account = etUserName.getText().toString();
                String password = etPassword.getText().toString();
                String jsonBody = "{\"account\":\"" + account + "\",\"password\": \"" + password + "\"}";
                Log.i(TAG, "jsonBody: " + jsonBody);

                OutputStream os = connection.getOutputStream();
                byte[] bytes = jsonBody.getBytes("utf-8");
                os.write(bytes, 0, jsonBody.length());

                int responseCode = connection.getResponseCode();
                if (responseCode == HttpURLConnection.HTTP_OK) {
                    InputStream is = connection.getInputStream();
                    BufferedReader br = new BufferedReader(new InputStreamReader(is, "utf-8"));
                    StringBuilder builder = new StringBuilder();
                    String line;
                    while ((line = br.readLine()) != null) {
                        builder.append(line);
                    }

                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            String loginResult = builder.toString();
                            Log.i(TAG, "loginResult: " + loginResult);
                        }
                    });
                } else {
                    Log.e(TAG, "run: 网络请求失败!");
                }
            } catch (MalformedURLException e) {
                throw new RuntimeException(e);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }).start();
}

这部分的关键点主要集中在请求体构造和请求头说明上。

setDoOutput(true) 的意思是告诉连接:这次请求需要向服务端写数据。如果不打开这个开关,后面即使调用 getOutputStream(),请求体也没有正确的输出前提。

Content-Type 用来声明请求体的数据格式,这里写成 application/json;charset=utf-8,表示提交的是 JSON,并且采用 utf-8 编码。这样服务端才能按正确格式解析账号和密码。

Accept 则是在告诉服务端:客户端希望收到哪种格式的响应数据。实际开发里这个值经常写成 application/json。

接着从 etUserName 和 etPassword 里取出输入值,手动拼成一段 JSON:

String jsonBody = "{\"account\":\"" + account + "\",\"password\": \"" + password + "\"}";

这段字符串就是整次 POST 请求的业务负载。把它转成字节数组,再通过 OutputStream 写给服务端,请求才算真正带着登录参数发送出去。

发送完请求体后,接下来的处理流程又回到了和 GET 类似的链路:先拿状态码,再读取输入流,再把结果拼成字符串,最后回到主线程处理界面逻辑。也正因为 HttpURLConnection 这一层都是手动步骤,所以它最能帮助建立“请求和响应到底经历了哪些环节”的全流程意识。

5. 先把 JSON 结构摸清,再用 JSONObject 手动读写数据

当服务端开始返回 JSON,或者 POST 需要我们自己组织 JSON 请求体时,下一步就不是“怎么发请求”,而是“怎么处理数据结构”。

5.1 Json 的基本结构

JSON 的核心只有两种容器:

  1. 对象,用 {} 包裹,内部是键值对。
  2. 数组,用 [] 包裹,内部是一组有序元素。

例如下面这段结构里,name、age、isStudent 是普通字段,address 是嵌套对象,courses 和 grades 是数组:

{
  "name": "Alice",
  "age": 30,
  "isStudent": false,
  "address": {
    "city": "New York",
    "zip": "10001"
  },
  "courses": ["Math", "Science", "History"],
  "grades": [85, 90, 92],
  "graduated": null
}

后面无论是用 JSONObject 还是 Gson,都是围绕这套结构在工作。区别只在于:JSONObject 更偏手动读取和手动拼装,Gson 更偏自动映射到 Java 对象。

5.2 使用 JSONObject 创建 JSON

页面先准备四个按钮,分别演示 JSONObject 和 Gson 的“创建”和“提取”两类操作:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_jsonobject);

    findViewById(R.id.btn_object).setOnClickListener(this);
    findViewById(R.id.btn_string).setOnClickListener(this);
    findViewById(R.id.btn_gson_object).setOnClickListener(this);
    findViewById(R.id.btn_gson_string).setOnClickListener(this);
}

@Override
public void onClick(View v) {
    if (v.getId() == R.id.btn_object) {
        getJSON();
    } else if (v.getId() == R.id.btn_string) {
        createJSON();
    } else if (v.getId() == R.id.btn_gson_object) {
        gsonCreateJSON();
    } else if (v.getId() == R.id.btn_gson_string) {
        gsonGetJSON();
    }
}

接着看用 JSONObject 手动创建一段完整 JSON 的过程:

private void createJSON() {
    JSONObject jsonObject = new JSONObject();
    try {
        jsonObject.put("name", "Alice");
        jsonObject.put("age", 30);
        jsonObject.put("isStudent", false);

        JSONObject addressJson = new JSONObject();
        addressJson.put("city", "New York");
        addressJson.put("zip", "10001");
        jsonObject.put("address", addressJson);

        JSONArray courses = new JSONArray();
        courses.put("Math");
        courses.put("Science");
        courses.put("History");
        jsonObject.put("courses", courses);

        JSONArray grades = new JSONArray();
        grades.put("85");
        grades.put("90");
        grades.put("92");
        jsonObject.put("grades", grades);

        jsonObject.put("graduated", null);

        String objectString = jsonObject.toString();
        Log.i(TAG, "createJSON: objectString:" + objectString);
    } catch (JSONException e) {
        throw new RuntimeException(e);
    }
}

这段代码的重点不是 put() 这个方法本身,而是它展示了“代码里的数据结构如何一层层对应到 JSON”:

  • 普通字段直接用 key + value 放进去。
  • 嵌套对象先单独创建一个 JSONObject,再整体挂到父对象上。
  • 数组则用 JSONArray 逐个 put() 元素。
  • 调用 toString() 之后,才会得到真正可发送、可存储、可打印的 JSON 字符串。

也就是说,前面在 HttpURLConnection 的 POST 里手动拼接字符串,只是最原始的一种做法;一旦结构稍微复杂一些,用 JSONObject 去显式构造层级关系会更清楚。

5.3 使用 JSONObject 提取参数

如果现在有一段现成的 JSON 字符串,JSONObject 也可以反过来把里面的字段逐个提取出来:

private void getJSON() {
    String json = "{\"name\":\"Alice\",\"age\":30,\"isStudent\":false,\"address\":{\"city\":\"New York\",\"zip\":\"10001\"},\"courses\":[\"Math\",\"Science\",\"History\"],\"grades\":[\"85\",\"90\",\"92\"]}\n";
    try {
        JSONObject object = new JSONObject(json);

        int age = object.getInt("age");
        String name = object.getString("name");
        Log.i(TAG, "getJSON: age = " + age);
        Log.i(TAG, "getJSON: name = " + name);

        JSONObject address = object.getJSONObject("address");
        String city = address.getString("city");
        Log.i(TAG, "getJSON: city = " + city);

        JSONArray courses = object.getJSONArray("courses");
        String h = courses.getString(2);
        Log.i(TAG, "getJSON: h = = " + h);
    } catch (JSONException e) {
        throw new RuntimeException(e);
    }
}

读取顺序同样严格对应 JSON 结构:

  1. 先用 new JSONObject(json) 把整段字符串转成对象。
  2. 普通字段直接用 getInt()、getString() 这类方法按 key 读取。
  3. 嵌套对象先用 getJSONObject("address") 取出来,再继续读它内部的字段。
  4. 数组先用 getJSONArray("courses") 拿到数组对象,再按索引读取元素。

JSONObject 的优点是直观,缺点也很明显:字段一多、层级一深,代码就会越来越碎。正因为这样,下一步才需要把 Gson 引进来,让“JSON 和实体类直接互转”这件事变得更省力。

6. 使用 Gson 让 JSON 和实体类直接互转

Gson 的价值在于把“读键值对”和“手动拼装层级”的工作,从逐字段操作转成“对象和字符串之间的转换”。这会直接改善请求体构造和响应体解析的可维护性。

6.1 引入依赖

先在 NetworkByJavaProject/app/build.gradle 的 dependencies 中加入 Gson:

implementation 'com.google.code.gson:gson:2.11.0'

加上这条依赖后,后面就可以直接创建 Gson 对象,用 toJson() 和 fromJson() 完成转换。

6.2 创建对象类

为了让 Gson 知道 JSON 要映射到什么结构,先准备一个 Student 实体类:

public class Student {

    private String name;
    private int age;
    private boolean isStudent;
    private Address address;
    private String[] courses;
    private Integer[] grades;

    public static class Address {
        private String city;
        private String zip;
    }
}

这里的字段设计和前面那段 JSON 是一一对应的:

  • name、age、isStudent 对应普通字段。
  • Address 对应嵌套对象。
  • courses 和 grades 对应数组。

只要字段名和 JSON 中的 key 对得上,Gson 就能自动完成映射。

6.3 使用 Gson 将实体类转为 JSON

先创建对象,再交给 Gson 转成字符串:

private void gsonGetJSON() {
    Student stu = new Student();
    stu.setName("老孙");
    stu.setAge(31);
    stu.setStudent(true);
    Student.Address address = new Student.Address();
    address.setCity("China");
    address.setZip("353333");

    stu.setCourses(new String[]{"语文", "数学", "英语"});
    stu.setGrades(new Integer[]{80, 70, 90});
    stu.setAddress(address);

    Gson gson = new Gson();
    String stuJson = gson.toJson(stu);
    Log.i(TAG, "gsonGetJSON: stuJson = " + stuJson);
}

这里 Gson 帮忙做了两件事:

  1. 沿着对象字段递归展开结构。
  2. 自动生成符合 JSON 规范的字符串。

因此,前面手动拼 "{\"account\":\"...\"}" 的工作,在结构固定之后就可以交给 Gson 处理。这样字段一改,代码只需要改对象,而不是重新拼接整段字符串。

6.4 使用 Gson 将 JSON 转为实体类

反向转换时,调用的是 fromJson():

private void gsonCreateJSON() {
    String json = "{\"address\":{\"city\":\"China\",\"zip\":\"353333\"},\"age\":31,\"courses\":[\"语文\",\"数学\",\"英语\"],\"grades\":[80,70,90],\"isStudent\":true,\"name\":\"老孙\"}\n";
    Gson gson = new Gson();
    Student student = gson.fromJson(json, Student.class);
    String name = student.getName();
    Integer[] grades = student.getGrades();
    Log.i(TAG, "gsonCreateJSON: name = " + name);
    Log.i(TAG, "gsonCreateJSON: grades[1] = " + grades[1]);
}

相比 JSONObject 一层层 getString()、getJSONArray() 去读,这种方式的好处很明显:

  • 转换入口只有一次。
  • 后续业务代码直接面向实体类字段,而不是字符串 key。
  • 代码更容易和 OkHttp、Retrofit2 这类请求库对接。

也正因为这一点,后面处理服务端返回结果时,都会越来越倾向于先定义实体类,再把响应体直接转成对象。

7. 使用 OkHttp 简化请求发送与结果处理

OkHttp 相比 HttpURLConnection 的优势,不是把网络请求变成“黑盒”,而是把那些高频的底层细节封装成更稳定的 API,例如请求构造、异步回调、超时配置和请求体封装。

7.1 前置代码

OkHttp 页面和 HttpURLConnection 基本一致,仍然是一个 GET 区域加一个 POST 区域:

private static final String TAG = "OkHttpActivity";
private EditText etUserId;
private EditText etUserName;
private EditText etPassword;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_ok_http);

    findViewById(R.id.btn_get).setOnClickListener(this);
    findViewById(R.id.btn_post).setOnClickListener(this);
    etUserId = findViewById(R.id.et_user_id);
    etUserName = findViewById(R.id.et_user_name);
    etPassword = findViewById(R.id.et_password);
}

@Override
public void onClick(View v) {
    if (v.getId() == R.id.btn_get) {
        sendGetRequest();
    } else if (v.getId() == R.id.btn_post) {
        sendPostRequest();
    }
}

这一步的作用,是让后面对比更纯粹:页面输入与交互逻辑没有变,变化的是底层请求发送方式。

7.2 引入依赖

先在 build.gradle 中加入 OkHttp:

implementation 'com.squareup.okhttp3:okhttp:4.12.0'

有了这个依赖,后面请求发送的核心对象就从 HttpURLConnection 切成了 OkHttpClient、Request、Call 和 Callback。

7.3 使用 OkHttp 发起 GET 请求

先看完整实现:

private void sendGetRequest() {
    String id = etUserId.getText().toString();
    String urlAdress = "http://titok.fzqq.fun/addons/cms/api.user/userInfo?user_id=" + id + "&type=archives";
    OkHttpClient okHttpClient = new OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build();

    Request request = new Request.Builder()
            .url(urlAdress)
            .get()
            .build();

    Call call = okHttpClient.newCall(request);

    Callback callback = new Callback() {
        @Override
        public void onFailure(@NonNull Call call, @NonNull IOException e) {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(OkHttpActivity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
                }
            });
        }

        @Override
        public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    if (response.isSuccessful()) {
                        try {
                            String result = response.body().string();
                            Log.i(TAG, "run result: " + result);
                            UserInfoQuery userInfoQuery = new Gson().fromJson(result, UserInfoQuery.class);
                            String msg = "当前用户名为:" + userInfoQuery.getData().getUser().getNickname();
                            Toast.makeText(OkHttpActivity.this, msg, Toast.LENGTH_SHORT).show();
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    } else {
                        Toast.makeText(OkHttpActivity.this, "网络请求失败:" + response.code(),
                                Toast.LENGTH_SHORT).show();
                    }
                }
            });
        }
    };

    call.enqueue(callback);
}

这一段和 HttpURLConnection 对比起来,简化主要体现在四个地方。

  • 第一,超时配置被收进了 OkHttpClient.Builder()。这意味着连接策略属于客户端配置,而不是每次都从底层连接对象逐条设置。
  • 第二,请求对象由 Request.Builder() 构造。url()、get()、请求头等信息都集中挂在 Request 上,请求语义更清晰。
  • 第三,newCall(request) 返回的是 Call,它代表一次可执行请求。后面无论异步 enqueue() 还是同步 execute(),都是围绕这个对象展开。
  • 第四,结果回调被收敛进 Callback。请求失败走 onFailure(),请求成功并收到响应走 onResponse()。这样业务分支比手动读状态码更集中。

不过有一点必须特别注意:enqueue() 虽然已经帮我们开好了异步线程,但 onFailure() 和 onResponse() 仍然不是主线程环境。所以这里如果要弹 Toast 或操作界面,还是得用 runOnUiThread() 切回来。

另外,示例里保留了同步请求的提示:

// call.enqueue(callback);
Response execute = call.execute();
execute.isSuccessful();
ResponseBody body = execute.body();

这段同步发送请求的价值,是明确告诉读者:execute() 会阻塞当前线程。它不是不能用,而是不应该放在主线程里直接跑。

7.4 使用 Gson 处理服务端返回的结果

前面 JSONObject 和 Gson 已经把 JSON 处理思路铺开了。到了 OkHttp 这里,重点变成:服务端返回的字符串,怎样快速落到可用的实体类上。

7.4.1 根据服务器返回的示例数据创建实体类

用户信息查询接口返回的 JSON 结构比较深,所以先根据服务端示例数据创建 UserInfoQuery 实体类:

public class UserInfoQuery {
   private int code;
   private String msg;
   private String time;
   private DataBean data;

   public static class DataBean {
      private UserBean user;
      private ListBean list;
      private int archives;
      private int comments;

      public static class UserBean {
         private int id;
         private String nickname;
         private String bio;
         private String avatar;
         private String status;
         private String url;
      }

      public static class ListBean {
         private int total;
         private int per_page;
         private int current_page;
         private int last_page;
      }
   }
}

这一步的重点不是“把类写得多完整”,而是让字段层级和服务端结构保持一致。后续代码真正会用到的是 data.user.nickname,所以实体类必须先能承接这条字段路径。

7.4.2 使用 Gson 处理服务端返回数据为实体类对象

拿到响应字符串后,直接让 Gson 把它转成实体类:

String result = response.body().string();
Log.i(TAG, "run result: " + result);
UserInfoQuery userInfoQuery = new Gson().fromJson(result, UserInfoQuery.class);
String msg = "当前用户名为:" + userInfoQuery.getData().getUser().getNickname();
Toast.makeText(OkHttpActivity.this, msg, Toast.LENGTH_SHORT).show();

这一小段代码把“读取响应”和“消费业务字段”彻底接上了:

  • response.body().string() 先把网络层的响应体拿成字符串。
  • fromJson() 再把字符串转成业务对象。
  • 最后通过 getData().getUser().getNickname() 精确拿到界面真正关心的字段。

也就是说,OkHttp 负责把响应拿回来,Gson 负责把响应变成对象,这两层职责在这里第一次形成了非常稳定的配合。

7.5 使用 OkHttp 发起 POST 请求

POST 场景里,OkHttp 的封装优势会更明显,因为请求体不再需要自己手动拿输出流去写。

7.5.1 创建请求体对象

先把登录参数定义成一个实体类:

public class ReqLogin {
    private String account;
    private String password;

    public ReqLogin(String account, String password) {
        this.account = account;
        this.password = password;
    }
}

之所以先定义这个类,是为了让账号密码不再停留在“手动拼接字符串”的阶段。后面只要把 ReqLogin 对象交给 Gson,请求体就能自动生成。

7.5.2 发送 POST 请求并处理响应

完整代码如下:

private void sendPostRequest() {
    String loginUrl = "http://titok.fzqq.fun/addons/cms/api.login/login";
    OkHttpClient client = new OkHttpClient();

    String account = etUserName.getText().toString();
    String password = etPassword.getText().toString();
    String jsonBody = new Gson().toJson(new ReqLogin(account, password));

    MediaType mediaType = MediaType.get("application/json;charset=utf-8");
    RequestBody requestBody = RequestBody.create(jsonBody, mediaType);

    Request request = new Request.Builder()
            .url(loginUrl)
            .post(requestBody)
            .build();

    client.newCall(request).enqueue(new Callback() {
        @Override
        public void onFailure(@NonNull Call call, @NonNull IOException e) {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(OkHttpActivity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
                }
            });
        }

        @Override
        public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    if (response.isSuccessful()) {
                        try {
                            String json = response.body().string();
                            Log.i(TAG, "run: " + json);
                            ResLogin resLogin = new Gson().fromJson(json, ResLogin.class);
                            Toast.makeText(OkHttpActivity.this, resLogin.getMsg() +
                                    ",欢迎用户" + resLogin.getData().getUser_id(), Toast.LENGTH_SHORT).show();
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    } else {
                        Toast.makeText(OkHttpActivity.this, "登录失败", Toast.LENGTH_SHORT).show();
                    }
                }
            });
        }
    });
}

这里可以把整个链路拆成三段。

第一段是请求体生成:

String jsonBody = new Gson().toJson(new ReqLogin(account, password));
MediaType mediaType = MediaType.get("application/json;charset=utf-8");
RequestBody requestBody = RequestBody.create(jsonBody, mediaType);

ReqLogin 负责承接输入字段,Gson 负责把对象转成 JSON,MediaType 负责声明请求体的数据类型和编码方式,RequestBody 则把这两部分真正封装成 OkHttp 可发送的请求体对象。

第二段是请求构造:

Request request = new Request.Builder()
        .url(loginUrl)
        .post(requestBody)
        .build();

和 GET 相比,唯一多出来的核心动作就是 post(requestBody)。这一步一旦挂上,请求方法和请求体就都明确了。

第三段是响应解析。登录接口返回的结果同样先转成字符串,再交给 ResLogin:

ResLogin resLogin = new Gson().fromJson(json, ResLogin.class);
Toast.makeText(OkHttpActivity.this, resLogin.getMsg() +
        ",欢迎用户" + resLogin.getData().getUser_id(), Toast.LENGTH_SHORT).show();

其中 msg 负责承接登录提示文案,data.user_id 则是业务上真正关心的用户身份结果。这样一来,请求和响应两端都已经摆脱手动字符串拼接,变成了“对象输入、对象输出”的模型。

7.6 OkHttp 拦截器可以统一处理什么

当请求数量开始增多,只在每个接口里单独写 header、通用参数和错误处理,很快就会重复。OkHttp 的拦截器正是用来解决这类横切逻辑的。

最常见的第一类用法,是统一加请求头:

OkHttpClient okHttpClient = new OkHttpClient.Builder()
    .addInterceptor(chain -> {
        Request request = chain.request().newBuilder()
            .header("token", token)
            .build();
        return chain.proceed(request);
    })
    .build();

这里的思路是:只要某个 OkHttpClient 挂了这个拦截器,所有通过它发出的请求都会自动带上 token。这样用户鉴权信息就不必在每个 Request.Builder() 里重复写一遍。

第二类用法,是统一加通用参数:

OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(chain -> {
        HttpUrl originalUrl = chain.request().url();
        HttpUrl newUrl = originalUrl.newBuilder()
            .addQueryParameter("apikey", "123456789")
            .build();

        Request newRequest = chain.request().newBuilder()
            .url(newUrl)
            .build();

        return chain.proceed(newRequest);
    })
    .build();

这适合语言环境、渠道号、公共业务标记之类每次请求都要带上的参数。

第三类用法,是统一处理响应错误:

OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(chain -> {
        Response response = chain.proceed(chain.request());

        if (response.code() == 404) {
            // 找不到资源
        } else if (response.code() == 501) {
            // 服务器错误
        }
        return response;
    })
    .build();

这类逻辑一旦集中到拦截器里,请求调用方就不需要每次都从零判断同一类错误,维护成本会明显下降。

8. 使用 Retrofit2 把接口定义、请求参数和结果解析收拢到一起

如果说 OkHttp 已经把“底层连接操作”收敛了,那么 Retrofit2 更进一步,它把“接口地址、方法类型、参数位置、返回类型”也抽成了声明式接口。

8.1 前置代码

Retrofit2 页面结构仍然和前面保持一致:

public class Retrofit2Activity extends AppCompatActivity implements View.OnClickListener {
    private static final String TAG = "Retrofit2Activity";
    private EditText etUserId;
    private EditText etUserName;
    private EditText etPassword;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_retrofit2);

        findViewById(R.id.btn_get).setOnClickListener(this);
        findViewById(R.id.btn_post).setOnClickListener(this);
        etUserId = findViewById(R.id.et_user_id);
        etUserName = findViewById(R.id.et_user_name);
        etPassword = findViewById(R.id.et_password);
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_get) {
            sendGetRequest();
        } else if (v.getId() == R.id.btn_post) {
            sendPostRequest();
        }
    }
}

这种保持输入区不变的做法,能让注意力完全落到接口定义和调用方式的变化上。

8.2 引入依赖

先加入 Retrofit2 本体依赖:

implementation 'com.squareup.retrofit2:retrofit:2.11.0'

为了让响应体可以直接转成实体类,还要加 Gson 转换器:

implementation 'com.squareup.retrofit2:converter-gson:2.11.0'

这两条依赖配齐之后,Retrofit2 才能同时具备接口声明能力和自动结果转换能力。

8.3 Retrofit2 发起 GET 请求

8.3.1 定义网络请求接口

先把用户信息查询接口定义成一个 Java 接口:

public interface ApiService {

    @GET("addons/cms/api.user/userInfo")
    Call<UserInfoQuery> getUserInfo(
            @Query("user_id") String userId,
            @Query("type") String type);
}

这里的每一个注解都对应着明确的请求语义:

  • @GET(...) 说明这是一个 GET 请求,同时给出相对路径。
  • @Query("user_id") 和 @Query("type") 表示这两个参数会作为查询参数自动拼到 URL 后面。
  • Call<UserInfoQuery> 表示这个接口最终会返回一个能拿到 UserInfoQuery 实体类的请求对象。

也就是说,前面在 HttpURLConnection 和 OkHttp 里手动拼地址字符串的工作,到了 Retrofit2 这里被拆成了“路径定义 + 参数声明”。

8.3.2 使用

调用时先创建 Retrofit 实例,再生成接口实现:

private void sendGetRequest() {
    Retrofit retrofit = new Retrofit.Builder()
            .baseUrl("http://titok.fzqq.fun/")
            .addConverterFactory(GsonConverterFactory.create())
            .build();

    ApiService apiService = retrofit.create(ApiService.class);

    String userId = etUserId.getText().toString();
    Call<UserInfoQuery> queryCall = apiService.getUserInfo(userId, "archives");

    queryCall.enqueue(new Callback<UserInfoQuery>() {
        @Override
        public void onResponse(Call<UserInfoQuery> call, Response<UserInfoQuery> response) {
            UserInfoQuery body = response.body();
            String nickname = body.getData().getUser().getNickname();

            Toast.makeText(Retrofit2Activity.this, "查询到:" + nickname, Toast.LENGTH_SHORT).show();
            Log.i(TAG, "onResponse: body " + nickname);
        }

        @Override
        public void onFailure(Call<UserInfoQuery> call, Throwable throwable) {
        }
    });
}

这段代码背后发生的事情,比表面看起来更值得注意。

baseUrl("http://titok.fzqq.fun/") 负责提供公共域名,所以接口注解里只需要写相对路径,不必每个请求都重复写完整地址。

addConverterFactory(GsonConverterFactory.create()) 则是把“响应体字符串转对象”的能力直接接进了 Retrofit2。因此回调里的 response.body() 已经不是字符串,而是 UserInfoQuery 实体类对象。

这就是 Retrofit2 和前面几种方案最明显的差别:请求构造声明化,响应解析自动化,调用层直接面向业务对象。

8.4 Retrofit2 发起 POST 请求

8.4.1 定义网络请求接口

登录接口在 ApiService 中这样定义:

public interface ApiService {

    @GET("addons/cms/api.user/userInfo")
    Call<UserInfoQuery> getUserInfo(
            @Query("user_id") String userId,
            @Query("type") String type);

    @POST("addons/cms/api.login/login")
    Call<ResLogin> login(@Body ReqLogin login);
}

这里 @Body ReqLogin login 的意义非常重要:它声明了当前请求体来自一个 ReqLogin 对象,Retrofit2 会结合前面配置好的 GsonConverterFactory,自动把这个对象转成 JSON 请求体发出去。

8.4.2 使用

完整调用代码如下:

private void sendPostRequest() {
    Retrofit retrofit = new Retrofit.Builder()
            .baseUrl("http://titok.fzqq.fun/")
            .addConverterFactory(GsonConverterFactory.create())
            .build();

    ApiService apiService = retrofit.create(ApiService.class);

    String account = etUserName.getText().toString();
    String password = etPassword.getText().toString();
    Call<ResLogin> loginCall = apiService.login(new ReqLogin(account, password));

    loginCall.enqueue(new Callback<ResLogin>() {
        @Override
        public void onResponse(Call<ResLogin> call, Response<ResLogin> response) {
            ResLogin resLogin = response.body();
            if (response.isSuccessful() && resLogin.getCode() == 1) {
                Toast.makeText(Retrofit2Activity.this, "欢迎你," +
                        response.body().getData().getUser_id(), Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(Retrofit2Activity.this, resLogin.getMsg(), Toast.LENGTH_SHORT).show();
            }
        }

        @Override
        public void onFailure(Call<ResLogin> call, Throwable throwable) {
            Toast.makeText(Retrofit2Activity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
        }
    });
}

这一段的阅读重点不再是“怎么拼请求体”,而是“怎么消费已经转好的业务结果”。

apiService.login(new ReqLogin(account, password)) 这一行就已经同时完成了三件事:

  1. 选中了 POST 登录接口。
  2. 把账号密码封装成请求体对象。
  3. 返回一个类型明确的 Call<ResLogin>。

到了 onResponse() 里,response.body() 已经是 ResLogin,所以可以直接判断:

  • response.isSuccessful():网络层是否成功收到响应。
  • resLogin.getCode() == 1:业务层是否真正登录成功。

这也是为什么 Retrofit2 特别适合接口数量较多的项目。它把“网络层成功”和“业务层成功”区分得更自然,代码也更接近接口文档本身。

9. 下载文件的几种常见写法

网络请求不只有取 JSON 这一种形态。文件下载同样是高频场景,只不过它最终要处理的不是字符串,而是字节流和本地文件写入。

9.1 使用 HttpURLConnection 下载文件

public void downloadFile(String fileUrl, String fileName) {
    try {
        URL url = new URL(fileUrl);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.connect();

        if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
            throw new RuntimeException("Failed to download file: " + connection.getResponseCode());
        }

        InputStream input = new BufferedInputStream(connection.getInputStream());
        FileOutputStream output = new FileOutputStream(fileName);
        byte[] data = new byte[4096];
        int count;
        while ((count = input.read(data)) != -1) {
            output.write(data, 0, count);
        }

        output.flush();
        output.close();
        input.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

下载文件时,响应体不再适合用 BufferedReader 按文本读取,而要按字节流处理。这里 InputStream 负责从网络侧读取字节,FileOutputStream 负责把字节持续落到本地文件里,while 循环则承担整段数据搬运。

9.2 使用 OkHttp 下载文件

public void downloadFileWithOkHttp(String fileUrl, String destinationPath) {
    OkHttpClient client = new OkHttpClient();
    Request request = new Request.Builder().url(fileUrl).build();
    try (Response response = client.newCall(request).execute()) {
        if (!response.isSuccessful()) throw new IOException("Failed to download file: " + response);

        File file = new File(destinationPath);
        try (InputStream inputStream = response.body().byteStream();
             FileOutputStream fos = new FileOutputStream(file)) {

            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                fos.write(buffer, 0, bytesRead);
            }
            fos.flush();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

这一版的核心变化是:响应体直接通过 response.body().byteStream() 提供字节流。也就是说,OkHttp 负责把请求和响应拿稳,文件写入逻辑仍然由代码自己控制。

9.3 使用 DownloadManager 下载文件

public void downloadFileUsingDownloadManager(Context context, String url, String fileName) {
    DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
    request.setTitle("Downloading...");
    request.setDescription("正在下载xxx文件...");
    request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
    request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);

    DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
    downloadManager.enqueue(request);
}

如果下载任务需要后台持续执行、系统通知管理和统一下载目录管理,DownloadManager 会比手写线程和流更合适。它本质上是系统服务,适合长时间运行的文件下载任务。

9.4 使用 Retrofit 下载文件

public interface DownloadService {
    @GET
    Call<ResponseBody> downloadFile(@Url String fileUrl);
}

public void downloadFileWithRetrofit(String fileUrl, String destinationPath) {
    Retrofit retrofit = new Retrofit.Builder()
            .baseUrl("https://example.com/")
            .build();

    DownloadService service = retrofit.create(DownloadService.class);
    Call<ResponseBody> call = service.downloadFile(fileUrl);
    try {
        ResponseBody body = call.execute().body();
        if (body != null) {
            InputStream inputStream = body.byteStream();
            FileOutputStream fos = new FileOutputStream(destinationPath);
            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                fos.write(buffer, 0, bytesRead);
            }
            fos.flush();
            fos.close();
            inputStream.close();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

这一版说明了 Retrofit2 并不只适合处理 JSON。当返回类型改成 ResponseBody 后,它也可以接管下载接口的声明和调用,真正的文件写入仍然按字节流落盘。

10. 上传文件的几种常见写法

上传和下载一样,都属于网络通信的一部分,但它的重点不再是“如何读取响应”,而是“如何把本地文件包装成请求体并正确声明类型”。

10.1 Content-Type 为什么重要

上传文件时,Content-Type 不能再简单看成一个“可有可无的请求头”,因为它直接决定服务端怎么解析请求体。

常见类型包括:

  • application/json:用于 JSON 数据。
  • application/x-www-form-urlencoded:用于传统表单字段。
  • multipart/form-data:用于文件上传或混合表单提交。
  • application/octet-stream:用于通用二进制流。
  • image/png、image/jpeg:用于图片文件。

在普通 JSON 请求里,OkHttp、Retrofit2 往往能帮我们自动处理好 Content-Type。但一旦进入文件上传场景,就必须清楚自己要提交的是哪种媒体类型,以及整个请求体是否需要按 multipart/form-data 组织。

10.2 使用 HttpURLConnection 上传文件

public void uploadFileWithHttpURLConnection(String filePath, String serverUrl) {
    HttpURLConnection connection = null;
    DataOutputStream outputStream = null;
    FileInputStream fileInputStream = null;
    try {
        File file = new File(filePath);
        URL url = new URL(serverUrl);
        connection = (HttpURLConnection) url.openConnection();
        connection.setDoOutput(true);
        connection.setRequestMethod("POST");
        connection.setRequestProperty("Content-Type", "multipart/form-data;boundary=--boundary");
        outputStream = new DataOutputStream(connection.getOutputStream());

        outputStream.writeBytes("--boundary\r\n");
        outputStream.writeBytes("Content-Disposition: form-data; name=\"file\";filename=\"" + file.getName() + "\"\r\n");
        outputStream.writeBytes("\r\n");

        fileInputStream = new FileInputStream(file);
        byte[] buffer = new byte[4096];
        int bytesRead;
        while ((bytesRead = fileInputStream.read(buffer)) != -1) {
            outputStream.write(buffer, 0, bytesRead);
        }

        outputStream.writeBytes("\r\n");
        outputStream.writeBytes("--boundary--\r\n");
        outputStream.flush();

        int responseCode = connection.getResponseCode();
        Log.i("TAG","Response Code: " + responseCode);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        try {
            if (fileInputStream != null) fileInputStream.close();
            if (outputStream != null) outputStream.close();
            if (connection != null) connection.disconnect();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这段代码最关键的是两点:

  1. 请求头声明成 multipart/form-data,并带上边界串 boundary。
  2. 请求体不再只是纯文本,而是要自己写入表单头、文件内容和结束边界。

也就是说,HttpURLConnection 上传文件时,开发者必须对 multipart 的组织格式有足够清晰的认识。

10.3 使用 OkHttp 上传文件

public void uploadFileWithOkHttp(String filePath, String serverUrl) {
    OkHttpClient client = new OkHttpClient();
    File file = new File(filePath);

    RequestBody fileBody = RequestBody.create(file, MediaType.parse("image/png"));
    MultipartBody requestBody = new MultipartBody.Builder()
            .setType(MultipartBody.FORM)
            .addFormDataPart("file", file.getName(), fileBody)
            .build();

    Request request = new Request.Builder()
            .url(serverUrl)
            .post(requestBody)
            .build();

    try (Response response = client.newCall(request).execute()) {
        if (response.isSuccessful()) {
           Log.i("TAG","Upload successful: " + response.message());
        } else {
           Log.i("TAG","Upload failed: " + response.message());
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

相比手动写边界,OkHttp 已经把文件上传最麻烦的部分封装掉了:

  • fileBody 负责描述单个文件片段及其媒体类型。
  • MultipartBody.Builder() 负责组织整个表单请求体。
  • addFormDataPart() 负责声明字段名、文件名和文件内容。

这样做的好处是,代码能更专注于业务输入,而不是手搓 multipart 协议细节。

10.4 使用 Retrofit 上传文件

public interface FileUploadService {
    @Multipart
    @POST("upload")
    Call<Void> uploadFile(@Part MultipartBody.Part file);
}

public void uploadFileWithRetrofit(String filePath, String serverUrl) {
    Retrofit retrofit = new Retrofit.Builder()
            .baseUrl(serverUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build();

    FileUploadService service = retrofit.create(FileUploadService.class);
    File file = new File(filePath);
    MultipartBody.Part filePart = MultipartBody.Part
            .createFormData("file", file.getName(), RequestBody.create(file, MediaType.parse("image/png")));

    Call<Void> call = service.uploadFile(filePart);
    call.enqueue(new retrofit2.Callback<Void>() {
        @Override
        public void onResponse(Call<Void> call, retrofit2.Response<Void> response) {
            if (response.isSuccessful()) {
                Log.i("TAG", "Upload successful");
            } else {
                Log.i("TAG", "Upload failed");
            }
        }

        @Override
        public void onFailure(Call<Void> call, Throwable t) {
            t.printStackTrace();
        }
    });
}

Retrofit2 在上传场景里的思路,和前面的 POST JSON 请求一脉相承:接口用注解描述,请求参数用类型约束,真正执行时只关心“传什么对象”。

其中:

  • @Multipart 说明当前请求是多部分表单。
  • @Part MultipartBody.Part file 说明这个请求有一个文件片段参数。
  • createFormData() 负责把字段名、文件名和文件内容真正封装成一个可上传片段。

这样一来,声明接口、构造参数、发送请求三部分的职责就彻底拆开了。

11. 相关代码附录

11.1 AndroidManifest 与依赖配置

NetworkByJavaProject/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.NetworkByJavaProject"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">
    </application>
</manifest>

NetworkByJavaProject/app/build.gradle

dependencies {
    implementation 'com.google.code.gson:gson:2.11.0'
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
    implementation 'com.squareup.retrofit2:retrofit:2.11.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
}

11.2 HttpURLConnection 页面与请求代码

NetworkByJavaProject/app/src/main/res/layout/activity_http_url_connection.xml

<EditText
    android:id="@+id/et_user_id"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="请输入要查询的用户id"
    android:inputType="number" />

<EditText
    android:id="@+id/et_user_name"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="请输入用户名" />

<EditText
    android:id="@+id/et_password"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="请输入密码"
    android:inputType="textPassword" />

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/activity/HttpUrlConnectionActivity.java

private void sendGetRequest() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            String id = etUserId.getText().toString();
            String urlAdress = "http://titok.fzqq.fun/addons/cms/api.user/userInfo?user_id=" + id + "&type=archives";
            try {
                URL url = new URL(urlAdress);
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("GET");
                connection.setConnectTimeout(8000);
                connection.setReadTimeout(8000);
                InputStream inputStream = connection.getInputStream();
                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
                StringBuilder builder = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    builder.append(line);
                }
                runOnUiThread(() -> Log.i(TAG, "网络访问结果:" + builder));
                connection.disconnect();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }).start();
}

private void sendPostRequest() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                URL url = new URL("http://titok.fzqq.fun/addons/cms/api.login/login");
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("POST");
                connection.setDoOutput(true);
                connection.setRequestProperty("Content-Type", "application/json;charset=utf-8");
                connection.setRequestProperty("Accept", "appliction/json");
                String jsonBody = "{\"account\":\"" + etUserName.getText() + "\",\"password\": \"" + etPassword.getText() + "\"}";
                OutputStream os = connection.getOutputStream();
                byte[] bytes = jsonBody.getBytes("utf-8");
                os.write(bytes, 0, jsonBody.length());
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }).start();
}

11.3 JSONObject 与 Gson 示例代码

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/activity/JSONObjectActivity.java

private void createJSON() {
    JSONObject jsonObject = new JSONObject();
    try {
        jsonObject.put("name", "Alice");
        jsonObject.put("age", 30);
        jsonObject.put("isStudent", false);
        JSONObject addressJson = new JSONObject();
        addressJson.put("city", "New York");
        addressJson.put("zip", "10001");
        jsonObject.put("address", addressJson);
        JSONArray courses = new JSONArray();
        courses.put("Math");
        courses.put("Science");
        courses.put("History");
        jsonObject.put("courses", courses);
        String objectString = jsonObject.toString();
        Log.i(TAG, objectString);
    } catch (JSONException e) {
        throw new RuntimeException(e);
    }
}

private void gsonCreateJSON() {
    String json = "{\"address\":{\"city\":\"China\",\"zip\":\"353333\"},\"age\":31,\"courses\":[\"语文\",\"数学\",\"英语\"],\"grades\":[80,70,90],\"isStudent\":true,\"name\":\"老孙\"}\n";
    Gson gson = new Gson();
    Student student = gson.fromJson(json, Student.class);
    Log.i(TAG, student.getName());
}

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/bean/Student.java

public class Student {
    private String name;
    private int age;
    private boolean isStudent;
    private Address address;
    private String[] courses;
    private Integer[] grades;

    public static class Address {
        private String city;
        private String zip;
    }
}

11.4 OkHttp 请求与实体类

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/activity/OkHttpActivity.java

Request request = new Request.Builder()
        .url(urlAdress)
        .get()
        .build();

Call call = okHttpClient.newCall(request);
call.enqueue(callback);
String jsonBody = new Gson().toJson(new ReqLogin(account, password));
MediaType mediaType = MediaType.get("application/json;charset=utf-8");
RequestBody requestBody = RequestBody.create(jsonBody, mediaType);
Request request = new Request.Builder()
        .url(loginUrl)
        .post(requestBody)
        .build();

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/bean/ReqLogin.java

public class ReqLogin {
    private String account;
    private String password;

    public ReqLogin(String account, String password) {
        this.account = account;
        this.password = password;
    }
}

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/bean/ResLogin.java

public class ResLogin {
    private int code;
    private String msg;
    private String time;
    private DataBean data;

    public static class DataBean {
        private String token;
        private int user_id;
    }
}

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/bean/UserInfoQuery.java

public class UserInfoQuery {
   private int code;
   private String msg;
   private String time;
   private DataBean data;
}

11.5 Retrofit2 接口与调用代码

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/reterofit/ApiService.java

public interface ApiService {

    @GET("addons/cms/api.user/userInfo")
    Call<UserInfoQuery> getUserInfo(
            @Query("user_id") String userId,
            @Query("type") String type);

    @POST("addons/cms/api.login/login")
    Call<ResLogin> login(@Body ReqLogin login);
}

NetworkByJavaProject/app/src/main/java/com/ls/networkbyjavaproject/activity/Retrofit2Activity.java

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://titok.fzqq.fun/")
        .addConverterFactory(GsonConverterFactory.create())
        .build();

ApiService apiService = retrofit.create(ApiService.class);
Call<UserInfoQuery> queryCall = apiService.getUserInfo(userId, "archives");
Call<ResLogin> loginCall = apiService.login(new ReqLogin(account, password));
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐