网络编程实战:从网页展示到 OkHttp 数据交互与解析

178 阅读13分钟

前言

应用没网,那可太没意思了。

而与网络交互的主要场景有两种:一是展示网页内容(通常使用 webView);二是与服务器进行数据交换(比如获取文章列表、提交用户信息)。

我们现在就来学习如何使用 WebView,然后通过 HttpURLConnectionOkHttp 库请求数据,最后看看如何解析服务器返回的 XMLJSON 数据。

WebView 的用法

有时,我们需要在应用中展示一个网页,但你又不可能自己从头开发一个浏览器内核。那该怎么办?

不用担心,Android 已经提供了一个 WebView 控件,使用它可以在应用中嵌入一个浏览器,从而展示任何网页。

那我们来通过一个案例演示一下用法。

新建一个名为 WebViewTest 的 Empty Views Activity 项目,然后在布局中放置一个 WebView 控件。

activity_main.xml 文件中的代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>

然后在 MainActivity 中,配置 WebView 控件,并加载网页。代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val webView = binding.webView
        // 支持 JavaScript 脚本(有风险)
        webView.settings.javaScriptEnabled = true
        // 跳转网页时,我们希望仍在当前 WebView 中显示,而不是打开系统浏览器
        // 设置 WebViewClient
        webView.webViewClient = WebViewClient()
        // 加载网页
        webView.loadUrl("https://juejin.cn")
    }
    
    override fun onDestroy() {
        // WebView 组件可能会导致内存泄漏
        // 所以在 onDestroy 方法中销毁 WebView,释放对应的资源
        binding.webView.destroy()
        super.onDestroy()
    }
}

注意:开启 JavaScript 支持会使应用处于风险之中(跨站脚本攻击 XSS)。可能会造成数据泄露问题,所以务必请加载安全、可信的网页内容。

另外,因为访问网络需要声明权限,需要在 AndroidManifest.xml 文件添加如下权限声明:

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
    <uses-permission android:name="android.permission.INTERNET" />
    ...
</manifest>

只要手机联网了,现在运行程序,就可以看到掘金首页。

image.png

返回上一个页面

现在,网页能正常显示。但当你点进别的页面,想按下返回键返回上一个页面时,应用会直接退出。这显然不符合我们的预期。

我们在 onCreate() 方法中给当前 Activity 注册用户按下返回键的回调,在其中修改逻辑:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityMainBinding.inflate(layoutInflater)
    setContentView(binding.root)

    ...

    // 传入 LifecycleOwner 让回调与 Activity 的生命周期绑定
    onBackPressedDispatcher.addCallback(this) {
        //  WebView 是否有历史记录可回退
        if (binding.webView.canGoBack()) {
            // 有的话,就执行后退操作
            binding.webView.goBack()
        } else {
            // 如果没有,禁用当前回调
            isEnabled = false
            // 重新触发返回事件
            onBackPressedDispatcher.onBackPressed()
        }
    }
}

现在,返回时,会回到上一个浏览的页面。如果没有的话,就会直接退出应用。

使用 HTTP 访问网络

HTTP 的工作原理可以简单理解为:客户端向服务端发送一个 HTTP 请求,服务器收到请求后,进行处理并返回对应的数据给客户端,然后客户端再对返回的数据进行解析和处理。

我们来手动发送一个 HTTP 请求,来理解这个过程。

使用 HttpURLConnection

Android 发送 HTTP 请求的方式有两种:HttpURLConnectionHttpClient。不过从 Android 6.0 开始,HttpClient 已经被废弃了。我们来看看 HttpURLConnection 的用法。

它的使用步骤为:

  1. 首先创建 URL 对象,并传入目标的网络地址。

  2. 然后调用该对象的 openConnection() 方法,获取一个 HttpURLConnection 实例。

  3. 通过这个 HttpURLConnection 实例,我们可以设置 HTTP 请求的各种属性,比如请求的方法(GETPOST)、连接和读取的超时时间等。GET 表示希望从服务器获取数据,POST 表示希望提交数据给服务器。

  4. 之后,再调用 HttpURLConnection 实例的 getInputStream() 方法来获取服务器返回的输入流,像读取本地文件一样从流中读取数据。

  5. 最后,调用 HttpURLConnection 实例的 disconnect() 方法关闭 HTTP 连接,释放资源。

我们还是通过一个具体的例子来体验一下。

新建一个名为 NetworkTest 的 Empty Views Activity 项目。在布局中放置一个 ScrollView 控件,用于滚动内容。放置一个 Button 用于触发 HTTP 请求,放置一个 TextView,用于显示服务器返回的内容。

activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/sendRequestBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send Request" />

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/responseText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </ScrollView>
</LinearLayout>

MainActivity 中的代码如下。

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.sendRequestBtn.setOnClickListener {
            sendRequestWithHttpURLConnection()
        }

    }

    /**
     * 使用 HttpURLConnection 发送 HTTP 请求
     */
    private fun sendRequestWithHttpURLConnection() {

        // 开启一个子线程来执行网络操作
        thread {
            var connection: HttpURLConnection? = null
            try {
                val response = StringBuilder()
                val url = URL("https://juejin.cn/")
                connection = url.openConnection() as HttpURLConnection
                connection.requestMethod = "GET"
                connection.connectTimeout = 8000
                connection.readTimeout = 8000

                val input = connection.inputStream
                input.use { inputStream ->
                    val reader = BufferedReader(InputStreamReader(inputStream))
                    reader.use { bufferedReader ->
                        bufferedReader.forEachLine {
                            response.append(it)
                        }
                    }
                }

                // 回到主线程中更新 UI
                runOnUiThread {
                    binding.responseText.text = response.toString()
                }
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                // 关闭连接
                connection?.disconnect()
            }
        }
    }

}

别忘了,要声明访问网络的权限。现在运行程序,并点击按钮,你就会看到掘金首页的 HTML 源代码:

image.png

只需对这些代码进行解析,即可看到美观的页面。

我们刚刚学了如何从服务器获取数据,而要将数据提交给服务器,也很简单。只需把请求方法换成 POST,然后通过 connection.outputStream 获取一个输出流,将要提交的数据写入到这个流中即可。数据之间使用 & 进行分隔。

比如登录时向发送用户名和密码:

// 将请求方法改为 POST
connection.requestMethod = "POST"

// 通过输出流将数据写入到请求体中
val output = DataOutputStream(connection.outputStream)
// 提交数据的格式为 key=value&key2=value2 
output.writeBytes("username=admin&password=123456")

使用 OkHttp

虽然 HttpURLConnection 是原生的,但在实际开发中,我们很少用它来处理复杂请求。因为它的 API 有些陈旧、繁琐,就比如前面手动拼接字符串时,既麻烦还很容易出错(如忘记处理特殊字符)。而且缺少一些高级功能,比如请求重试、连接池优化等,我们通常会使用第三方库 OkHttp

这是 OkHttp 项目的地址:OkHttp on GitHub

使用 OkHttp 之前,先在 app\build.gradle.kts 文件中添加依赖:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

我们来看看 OkHttp 的用法。

首先创建 OkHttpClient 的实例。注意,不要每次请求时都创建一个 OkHttpClient 实例,而是应该全局共享同一个 OkHttpClient 实例,因为它内部维护了连接池和线程池,这样能够提升网络请求的性能和效率。

// 创建一个 object 单例对象来持有全局唯一的 HttpClient 实例
object OkHttpSingleton {
    val instance: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
        .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
        .build()
}

然后创建 Request 请求对象。这个对象用于设置请求目标的网络地址(URL)、请求方法(默认为 GET)、请求头、请求体等信息。

val request = Request.Builder()
    .url("https://juejin.cn/")
    .build()

之后调用 OkHttpClientnewCall() 方法创建一个 Call 对象,并调用它的 execute() 方法(同步执行)来发送请求。我们可以通过 Response 对象获取服务器返回的内容。

val response = client.newCall(request).execute()
val responseData = response.body?.string()

现在,我们使用 OkHttp 来实现之前的请求逻辑:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.sendRequestBtn.setOnClickListener {
            sendRequestWithOkHttp()
        }

    }

    /**
     * 使用 OkHttp 发送 HTTP 请求
     */
    private fun sendRequestWithOkHttp() {
        thread {
            try {
                // 使用我们单例 Client
                val client = OkHttpSingleton.instance

                // 创建 Request
                val request = Request.Builder()
                    .url("https://juejin.cn/")
                    .build()

                // 发起同步请求并获取 Response
                val response = client.newCall(request).execute()
                // response.body?.string() 方法中会自动处理流的读取和关闭
                val responseData = response.body?.string()

                runOnUiThread {
                    binding.responseText.text = responseData
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

}

是不是感觉代码简洁了许多,OkHttp 让网络请求变得简单。

如果要发送 POST 请求,需要构建 RequestBody 对象,它表示要发送的请求体内容。对于键值对形式的数据,我们可以使用 FormBody.Builder

val requestBody = FormBody.Builder()
    .add("username", "admin")
    .add("password", "123456")
    .build()

然后在创建 Request 请求对象时,将刚刚创建的 RequestBody 对象传入 post() 方法即可。

val request = Request.Builder()
    .url("https://juejin.cn/")
    .post(requestBody)
    .build()

接下来的步骤,和发送 GET 请求一样,调用 execute() 方法来发送请求。

解析数据:XML 与 JSON

前面,我们成功从服务器中获取到了响应数据,我们一般来说并不是直接将响应数据展示给用户的,而是会解析它,并转换为数据对象。

服务器返回的数据格式有很多,但主流有两种:XMLJSON,我们就来看看如何解析它们。

准备测试数据:搭建本地 Web 服务器

为了模拟从服务器中获取数据,我们在本地搭建一个简单的 Web 服务器。以 Apache 服务器为例。

Apache Lounge 下载 Apache 服务器并解压,我的解压路径为:D:\Develop\Apache Http Server

以管理员身份运行 cmd,然后在 Apache 的 bin 目录(我的是 D:\Develop\Apache Http Server\Apache24\bin)下执行 httpd -k install 命令进行安装。

image.png

然后在 conf 文件夹下的 httpd.conf 文件中(我这里的路径是 D:\Develop\Apache Http Server\Apache24\conf\httpd.conf),修改 SRVROOT 的值为 Apache 的根目录。

Define SRVROOT "D:\Develop\Apache Http Server\Apache24"
ServerRoot "${SRVROOT}"
DocumentRoot "${SRVROOT}/htdocs"

DocumentRoot 为存放网页文件的目录。

最后双击 bin 目录下的 ApacheMonitor.exe 文件,再点击 "Start" 按钮,即可启动服务器。

QQ_1750753932833.png

我们来验证一下。在浏览器中输入 http://127.0.0.1/,如果出现了如下页面,说明服务器已成功启动。

QQ_1750754103896.png

现在开始准备 XML 格式的数据,在 htdocs 目录下新建 get_data.xml 文件,文件内容如下:

<apps>
    <app>
        <id>1</id>
        <name>Google Maps</name>
        <version>1.0</version>
    </app>
    <app>
        <id>2</id>
        <name>Chrome</name>
        <version>2.1</version>
    </app>
    <app>
        <id>3</id>
        <name>Google Play</name>
        <version>2.3</version>
    </app>
</apps>

准备 JSON 格式的数据。在 htdocs 目录下,新建 get_data.json 文件,其内容如下:

[
  {
    "id": "5",
    "version": "5.5",
    "name": "Clash of Clans"
  },
  {
    "id": "6",
    "version": "7.0",
    "name": "Boom Beach"
  },
  {
    "id": "7",
    "version": "3.5",
    "name": "Clash Royale"
  }
]

你也可以通过浏览器来验证,访问 http://127.0.0.1/get_data.xml 地址,应该会出现如下内容:

QQ_1750754325513.png

访问 http://127.0.0.1/get_data.json 地址,会有如下页面:

QQ_1750835105818.png

另外,从 Android 9.0 开始,应用默认禁止使用明文的 HTTP 请求,只能使用 HTTPS 请求,因为 HTTP 类型的网络请求有安全隐患。而我们本地的服务器使用的就是 HTTP。

所以我们需要进行额外的配置。创建 res/xml/network_security_config.xml 配置文件,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>

这个配置的意思是允许使用明文的方式在网络上传输数据。

然后在 AndroidManifest.xml 文件的 <application> 中引用这个配置文件:

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
    ...
    <application
        ...
        android:networkSecurityConfig="@xml/network_security_config">
        ...
    </application>

</manifest>

现在,我们就可以在代码中获取并解析这些数据了。

解析 XML 格式数据

解析 XML 格式数据的方式也有很多,我们来看看常用的两种:Pull 解析和 SAX 解析。

Pull 解析方式

Pull 解析器像一个迭代器,我们需要从解析器中获取每一个解析事件(如“开始标签”、“结束标签”),使用起来很灵活、直观。

我们在 sendRequestWithOkHttp() 方法中,让它请求本地服务器的 XML 文件,并且调用 parseXMLWithPull 方法来解析响应的数据。

代码如下:

/**
 * 使用 OkHttp 发送 HTTP 请求
 */
private fun sendRequestWithOkHttp() {
    thread {
        try {
            val client = OkHttpSingleton.instance

            val request = Request.Builder()
                // 对于模拟器来说,10.0.2.2 是计算机的本机 IP 地址
                .url("http://10.0.2.2/get_data.xml")
                .build()

            val response = client.newCall(request).execute()
            val responseData = response.body?.string()

            responseData?.let { parseXMLWithPull(it) }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}


/**
 * 使用 Pull 解析 XML 格式的数据
 */
private fun parseXMLWithPull(xmlData: String) {
    try {
        // 获取 XmlPullParserFactory 实例
        val factory = XmlPullParserFactory.newInstance()
        // 获取 XmlPullParser 对象
        val xmlPullParser = factory.newPullParser()
        // 设置要解析的 XML 字符串
        xmlPullParser.setInput(StringReader(xmlData))

        // 获取当前的解析事件
        var eventType = xmlPullParser.eventType
        var id = ""
        var name = ""
        var version = ""

        // 循环遍历,只要还没到文档末尾就继续
        while (eventType != XmlPullParser.END_DOCUMENT) {
            // 获取当前节点的名称
            val nodeName = xmlPullParser.name
            when (eventType) {
                // 开始解析某个节点
                XmlPullParser.START_TAG -> {
                    // 获取节点的具体内容
                    when (nodeName) {
                        "id" -> id = xmlPullParser.nextText()
                        "name" -> name = xmlPullParser.nextText()
                        "version" -> version = xmlPullParser.nextText()
                    }
                }
                // 完成解析某个节点
                XmlPullParser.END_TAG -> {
                    if (nodeName == "app") {
                        Log.d("PullParser", "id is $id, name is $name, version is $version")
                    }
                }
            }
            // 获取下一个解析事件
            eventType = xmlPullParser.next()
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

现在来测试一下,运行程序并点击“发送请求”按钮,Logcat 日志信息如下:

D/PullParser    com.example.networktest    id is 1, name is Google Maps, version is 1.0
D/PullParser    com.example.networktest    id is 2, name is Chrome, version is 2.1
D/PullParser    com.example.networktest    id is 3, name is Google Play, version is 2.3

可以看到,我们已经成功将 XML 格式数据中的内容解析出来了。

SAX 解析方式

SAX (Simple API for XML) 是一种基于事件驱动的解析方式。它的内存占用小,适合解析大型文件;但编程模型相对复杂,我们要在一个 Handler 中响应不同的解析事件。

首先,创建 ContentHandler 类,继承自 DefaultHandler。代码如下:

class ContentHandler : DefaultHandler() {
    // 记录当前节点名
    private var nodeName = ""

    // 记录当前节点的内容
    private lateinit var id: StringBuilder
    private lateinit var name: StringBuilder
    private lateinit var version: StringBuilder

    // 在文档解析开始时调用,我们完成初始化工作
    override fun startDocument() {
        // 初始化 StringBuilder
        id = StringBuilder()
        name = StringBuilder()
        version = StringBuilder()
    }

    // 在某个节点解析开始时调用
    override fun startElement(
        uri: String, localName: String, qName: String,
        attributes: Attributes,
    ) {
        // 更新当前节点名,如 id、name 或 version
        nodeName = localName
    }

    // 获取节点内容时调用,注意这个方法可能会被多次调用
    override fun characters(ch: CharArray, start: Int, length: Int) {
        // 根据当前节点名将内容添加到对应 StringBuilder 对象中
        when (nodeName) {
            "id" -> id.appendRange(ch, start, start + length)
            "name" -> name.appendRange(ch, start, start + length)
            "version" -> version.appendRange(ch, start, start + length)
        }
    }

    // 在某个节点解析结束时调用
    override fun endElement(uri: String, localName: String, qName: String) {
        // 当前节点是 app
        if ("app" == localName) {
            // 打印 id、name 和 version 的内容,注意去除可能存在的回车或者换行符
            Log.d(
                "SAXParser",
                "id is ${id.toString().trim()}, name is ${
                    name.toString().trim()
                }, version is ${version.toString().trim()}"
            )

            // 将 StringBuilder 清空,以便记录下一个 app 的数据
            id.setLength(0)
            name.setLength(0)
            version.setLength(0)
        }
    }

    // 在文档解析结束时调用
    override fun endDocument() {
    }
}

然后,在 MainActivity 中,调用 parseXMLWithSAX() 方法来解析响应的数据。

代码如下:

/**
 * 使用 SAX 解析 XML 类型的数据
 */
private fun parseXMLWithSAX(xmlData: String) {
    try {
        // 获取 SAXParserFactory 实例
        val factory = javax.xml.parsers.SAXParserFactory.newInstance()
        // 获取 org.xml.sax.XMLReader 对象
        val xmlReader = factory.newSAXParser().xmlReader

        // 将我们自定义的 Handler 设置进去
        xmlReader.contentHandler = ContentHandler()
        // 开始执行解析
        xmlReader.parse(org.xml.sax.InputSource(StringReader(xmlData)))
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

现在重新运行程序,并点击按钮,你会看到同样的结果:

SAXParser               com.example.networktest              D  id is 1, name is Google Maps, version is 1.0
SAXParser               com.example.networktest              D  id is 2, name is Chrome, version is 2.1
SAXParser               com.example.networktest              D  id is 3, name is Google Play, version is 2.3

解析 JSON 格式数据

JSON 因其轻量、易读的特性,已成为现在 API 数据交换的主流格式。

使用 JSONObject

这是 Android SDK 自带的解析方式,对于简单的 JSON 结构,使用起来很直接。

MainActivity 中,修改请求地址,并且调用 parseJSONWithJSONObject() 方法来解析响应的数据。

/**
 * 使用 OkHttp 发送 HTTP 请求
 */
private fun sendRequestWithOkHttp() {
    thread {
        try {
            val client = OkHttpSingleton.instance

            val request = Request.Builder()
                .url("http://10.0.2.2/get_data.json")
                .build()

            val response = client.newCall(request).execute()
            val responseData = response.body?.string()

            responseData?.let { parseJSONWithJSONObject(it) }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

/**
 * 使用 JSONObject 解析 JSON 数据
 */
private fun parseJSONWithJSONObject(jsonData: String) {
    try {
        // 整个内容是一个 JSON 数组
        val jsonArray = org.json.JSONArray(jsonData)
        // 循环遍历
        for (i in 0 until jsonArray.length()) {
            // 获取数组中的每一个 JSONObject 对象
            val jsonObject = jsonArray.getJSONObject(i)
            // 取出其中的数据,分别为 id、name、version
            val id = jsonObject.getString("id")
            val name = jsonObject.getString("name")
            val version = jsonObject.getString("version")
            Log.d("JSONObject", "id is $id, name is $name, version is $version")
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

现在运行程序,并点击“发送请求”按钮,可以在日志中看到:

D/JSONObject    com.example.networktest    id is 5, name is Clash of Clans, version is 5.5
D/JSONObject    com.example.networktest    id is 6, name is Boom Beach, version is 7.0
D/JSONObject    com.example.networktest    id is 7, name is Clash Royale, version is 3.5

使用 GSON

手动从 JSONObject 取出数据还是有些繁琐,而且没有类型安全检查。GSON 是 Google 发布的库,使用它能让解析 JSON 数据的工作更简单。

首先在 app/build.gradle.kts 文件中添加 GSON 库的依赖:

dependencies {
    implementation("com.google.code.gson:gson:2.13.0")
}

那么它有什么神奇之处呢?它的神奇之处在于可以将 JSON 格式的字符串自动映射成一个对象。

GSON 的核心思想就是对象映射,比如对于 JSON 数据 { "name": "Tom", "age": 20 },我们可以定义一个具有 nameage 字段的 Person 数据类,GSON 就能将以上数据解析为一个 Person 对象。

对于我们的 get_data.json 数据,它是一个对象列表。我们除了需要定义 App 类外,还需要使用 TypeToken 来告诉 GSON 目标类型。因为 Java 和 Kotlin 中的泛型是使用类型擦除机制实现的,所以 GSON 在运行时无法知道列表中的元素类型,而 TypeToken 可以解决这个问题。

首先定义一个 App 类,添加 idnameversion 三个字段。

data class App(val id: String, val name: String, val version: String)

然后 MainActivity 中,调用 parseJSONWithGSON() 方法来解析响应的数据。

代码如下:

/**
 * 使用 GSON 解析 JSON 数据
 */
private fun parseJSONWithGSON(jsonData: String) {
    val gson = com.google.gson.Gson()
    // 定义我们期望解析成的类型,即 List<App>
    val typeOf = object : com.google.gson.reflect.TypeToken<List<App>>() {}.type
    // 完成所有解析
    val appList = gson.fromJson<List<App>>(jsonData, typeOf)

    for (app in appList) {
        Log.d("GSON", "id is ${app.id}, name is ${app.name}, version is ${app.version}")
    }
}

使用 GSON,代码非常简洁,并且更加类型安全。

最后,虽然 GSON 很强大,但对于新 Kotlin 项目来说,推荐使用 kotlinx.serializationMoshi

网络请求的封装与回调

我们之前的写法很有问题,因为没有对通用的网络代码进行复用,并且之后难以维护。我们应该把它封装到一个通用的工具类中。

但这样的话,有一个问题:我们的网络请求在子线程中执行,调用者在主线程。我们该如何将请求结果(成功的数据或是失败的异常)通知给主线程呢?

当然直接 return 返回是不行的,因为调用的函数会立刻返回,而此时网络请求还在后台执行。

其实解决办法也很简单,就是使用回调(Callback)机制

首先定义一个 HttpCallbackListener 接口,网络请求工具类在请求结束后,会通过接口的两个方法来通知调用方。

interface HttpCallbackListener {
    /**
     * 服务器成功响应时调用
     */
    fun onFinish(response: String)

    /**
     * 网络操作出现错误时调用
     */
    fun onError(e: Exception)
}

然后创建 HttpUtil 工具类,提供一个 sendOkHttpRequest() 方法用于发送网络请求。不过,这个方法并不返回响应数据,而是接收一个 HttpCallbackListener 对象作为参数。当请求结束后,就调用这个 listener 对象的对应方法。

代码如下:

object HttpUtil {

    private val client = OkHttpSingleton.instance

    fun sendOkHttpRequest(address: String, listener: HttpCallbackListener) {
        val request = Request.Builder().url(address).build()
        // OkHttp 自带的异步方法 enqueue,它会在内部开启子线程并处理回调
        client.newCall(request).enqueue(object : okhttp3.Callback {
            override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
                // 请求失败,通过我们自己的接口回调错误
                listener.onError(e)
            }

            override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
                // 判断 HTTP 响应码是否表示成功
                if (response.isSuccessful) {
                    // 成功了,获取响应体
                    response.body?.string()?.let { responseData ->
                        listener.onFinish(responseData)
                    } ?: listener.onError(IOException("Response body is null on successful response"))
                } else {
                    // 如果 HTTP 响应码表示失败,直接回调 onError
                    listener.onError(IOException("Response unsuccessful. Code: ${response.code}"))
                }
            }
        })
    }
}

现在发送请求时,只需调用 HttpUtil 的方法,并传入 HttpCallbackListener 回调接口的实现,在其中处理成功和失败的逻辑即可。如下:

private fun requestData() {
    val address = "http://10.0.2.2/get_data.json"
    HttpUtil.sendOkHttpRequest(address, object : HttpCallbackListener {
        override fun onFinish(response: String) {
            // 拿到成功返回的数据
            Log.d("MainActivity", "Response from server: $response")

            // 更新 UI
            runOnUiThread {
                binding.responseText.text = response
            }
        }

        override fun onError(e: Exception) {
            // 在 onError 里,我们处理错误情况
            e.printStackTrace()
            runOnUiThread {
                binding.responseText.text = "Request Failed: ${e.message}"
            }
        }
    })
}

注意: 回调方法默认是在子线程中执行的,不能在回调中直接更新 UI 操作,而是要将操作切换到主线程。

现在我们就完成了一次从请求到回调的完整封装。