前言

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

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

在这里插入图片描述

目录

1. 前言

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

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

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

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

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

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

2.2 协议的分层模型

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

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

2.2.1 OSI 模型

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

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

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

2.2.2 TCP/IP 模型

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

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

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

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

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

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

移动端最常见的业务请求,通常是“应用层用 HTTPHTTPS 组织数据,传输层由 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 放进请求体,例如登录接口把账号和密码作为请求体提交给服务端。

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

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

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

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

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

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

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

响应结构也分成三部分:

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

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

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

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

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

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

3.1 添加网络权限

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

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

这条权限的作用很单纯,就是告诉系统当前应用需要访问网络。没有它,后面不管是 HttpURLConnectionOkHttp 还是 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_idtype
  2. 登录接口,用 POST,请求体里带 accountpassword

对应的测试文档入口为:

http://titok.fzqq.fun

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

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

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

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

先准备页面,让 GETPOST 两类请求都能从同一个页面触发。布局里放了一个 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(...)。如果把这段逻辑直接放进主线程执行,应用很容易触发卡顿甚至抛出主线程网络访问异常。
  • 第二个是:读取结果只是第一步,真正的业务处理从“拿到那段字符串”之后才开始。这里暂时只是打印日志,后面切到 JSONObjectGson 时,就会把这段结果继续转成结构化数据。

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

接着从 etUserNameetPassword 里取出输入值,手动拼成一段 JSON

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

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

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

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

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

5.1 Json 的基本结构

JSON 的核心只有两种容器:

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

例如下面这段结构里,nameageisStudent 是普通字段,address 是嵌套对象,coursesgrades 是数组:

{
  "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

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

@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 字符串。

也就是说,前面在 HttpURLConnectionPOST 里手动拼接字符串,只是最原始的一种做法;一旦结构稍微复杂一些,用 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.gradledependencies 中加入 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 是一一对应的:

  • nameageisStudent 对应普通字段。
  • Address 对应嵌套对象。
  • coursesgrades 对应数组。

只要字段名和 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
  • 代码更容易和 OkHttpRetrofit2 这类请求库对接。

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

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 切成了 OkHttpClientRequestCallCallback

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 处理服务端返回的结果

前面 JSONObjectGson 已经把 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 负责把对象转成 JSONMediaType 负责声明请求体的数据类型和编码方式,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 实体类的请求对象。

也就是说,前面在 HttpURLConnectionOkHttp 里手动拼地址字符串的工作,到了 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/pngimage/jpeg:用于图片文件。

在普通 JSON 请求里,OkHttpRetrofit2 往往能帮我们自动处理好 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编程工具,助力开发者即刻编程。

更多推荐