抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

课本

书籍资源进入官网下载,PC端进入

第十一章-看看精彩的世界,使用网络技术

如果你在玩手机的时候不能上网,那你一定会感到特别地枯燥乏味。没错,现在早已不是玩单机的时代了,无论是PC、手机、平板,还是电视,都具备上网的功能, 21世纪的确是互联网的时代。
当然,Android手机肯定也是可以上网的。作为开发者,我们就需要考虑如何利用网络编写出更加出色的应用程序,像QQ、微博、微信等常见的应用都会大量使用网络技术。本章主要讲述如何在手机端使用HTTP和服务器进行网络交互,并对服务器返回的数据进行解析,这也是Android中最常使用到的网络技术,下面就让我们一起来学习一下吧。

WebView的用法

有时候我们可能会碰到一些比较特殊的需求,比如说在应用程序里展示一些网页。相信每个人都知道,加载和显示网页通常是浏览器的任务,但是需求里又明确指出,不允许打开系统浏览器,我们当然不可能自己去编写一个浏览器出来,这时应该怎么办呢?
不用担心,Android早就考虑到了这种需求,并提供了一个WebView控件,借助它我们就可以在自己的应用程序里嵌入一个浏览器,从而非常轻松地展示各种各样的网页。

  1. WebView的用法也相当简单,下面我们就通过一个例子来学习一下吧。新建一个WebViewTest项目,然后修改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>
    
    可以看到,我们在布局文件中使用到了一个新的控件:WebView。这个控件就是用来显示网页的,这里的写法很简单,给它设置了一个id,并让它充满整个屏幕。
  2. 然后修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
    
         val webView:WebView = findViewById(R.id.webView)
         webView.apply {
             // 告诉WebView启用JavaScript执行。默认值为false。
             settings.javaScriptEnabled = true
             webViewClient = WebViewClient()
             loadUrl("https://brightmoon.ren/")
         }
     }
    }
    

    MainActivity中的代码也很短,通过WebView的getSettings()方法可以设置一些浏览器的属性,这里我们并没有设置过多的属性,只是调用了setJavaScriptEnabled()方法,让WebView支持JavaScript脚本。
    接下来是比较重要的一个部分,我们调用了WebView的setWebViewClient()方法,并传入了一个WebViewClient的实例。这段代码的作用是,当需要从一个网页跳转到另一个网页时,我们希望目标网页仍然在当前WebView中显示,而不是打开系统浏览器。
    最后一步就非常简单了,调用WebView的loadUrl()方法,并将网址传入,即可展示相应网页的内容.

  3. 另外还需要注意,由于本程序使用到了网络功能,而访问网络是需要声明权限的,因此我们还得修改AndroidManifest.xml文件,并加入权限声明,如下所示:
    ```xml
    <?xml version=”1.0” encoding=”utf-8”?>
<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.WebViewTest"
    tools:targetApi="31">
    <activity
        android:name=".MainActivity"
        android:exported="true">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>

        <meta-data
            android:name="android.app.lib_name"
            android:value="" />
    </activity>
</application>

</manifest>

4. 在开始运行之前,确保你的手机或模拟器是联网的。然后就可以运行一下程序了,效果如图所示。
![博客第一行代码3学习使用WebView加载网页](https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/img/博客第一行代码3学习使用WebView加载网页.png)
可以看到,WebViewTest这个程序现在已经具备了一个简易浏览器的功能,不仅成功将百度的首页展示了出来,还可以通过点击链接浏览更多的网页。
当然,WebView还有很多更加高级的使用技巧,我们就不再继续探讨了,因为那不是本章的重点。这里先介绍了一下WebView的用法,只是希望你能对HTTP有一个最基本的认识,接下来我们就要利用这个协议做一些真正的网络开发工作了。
5. [本小节代码](https://github.com/1802024110/Android_Learn/tree/2702962c0fafcbc58953ccb8cc6ca6ae726f065f/WebViewTest)

## 使用HTTP访问网络
如果说真的要去深入分析HTTP,可能需要花费整整一本书的篇幅。这里我当然不会这么干,因为毕竟你是跟着我学习Android开发的,而不是网站开发。对于HTTP,你只需要稍微了解一些就足够了,它的工作原理特别简单,就是客户端向服务器发出一条HTTP请求,服务器收到请求之后会返回一些数据给客户端,然后客户端再对这些数据进行解析和处理就可以了。是不是非常简单?一个浏览器的基本工作原理也就是如此了。比如说上一节中使用到的WebView控件,其实就是我们向百度的服务器发起了一条HTTP请求,接着服务器分析出我们想要访问的是百度的首页,于是把该网页的HTML代码进行返回,然后WebView再调用手机浏览器的内核对返回的HTML代码进行解析,最终将页面展示出来。
简单来说,WebView已经在后台帮我们处理好了发送HTTP请求、接收服务器响应、解析返回数据,以及最终的页面展示这几步工作,只不过它封装得实在是太好了,反而使得我们不能那么直观地看出HTTP到底是如何工作的。因此,接下来就让我们通过手动发送HTTP请求的方式更加深入地理解这个过程。

### 使用HttpURLConnection
在过去,Android上发送HTTP请求一般有两种方式:HttpURLConnection和HttpClient。不过由于HttpClient存在API数量过多、扩展困难等缺点,Android团队越来越不建议我们使用这种方式。终于在Android 6.0系统中,`HttpClient的功能被完全移除`了,标志着此功能被正式弃用,因此本小节我们就学习一下现在官方建议使用的`HttpURLConnection`的用法。
1. 首先需要获取HttpURLConnection的实例,一般只需创建一个`URL`对象,并传入目标的网络地址,然后调用一下`openConnection()`方法即可,如下所示:
```kotlin
val url = URL("https://brightmoon.ren/")
val connection = url.openConnection() as HttpURLConnection
  1. 在得到了HttpURLConnection的实例之后,我们可以设置一下HTTP请求所使用的方法。常用的方法主要有两个:GETPOST。GET表示希望从服务器那里获取数据,而POST则表示希望提交数据给服务器。写法如下:
    connection.requestMethod = "GET"
    
  2. 接下来就可以进行一些自由的定制了,比如设置连接超时、读取超时的毫秒数,以及服务器希望得到的一些消息头等。这部分内容根据自己的实际情况进行编写,示例写法如下:
    connection.connectTimeout = 8000
    connection.readTimeout = 8000
    
  3. 之后再调用getInputStream()方法就可以获取到服务器返回的输入流了,剩下的任务就是对输入流进行读取:
    val input = connection.inputStream
    
  4. 最后可以调用disconnect()方法将这个HTTP连接关闭:
    connection.disconnect()
    

下面就让我们通过一个具体的例子来真正体验一下HttpURLConnection的用法。

  1. 新建一个NetworkTest项目,首先修改activity_main.xml中的代码,如下所示:
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:orientation="vertical"
     android:layout_width="match_parent"
     android:layout_height="match_parent" >
     <Button
         android:id="@+id/sendRequestBtn"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:text="发送请求" />
     <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>
    
    注意,这里我们使用了一个新的控件:ScrollView。它是用来做什么的呢?由于手机屏幕的空间一般比较小,有些时候过多的内容一屏是显示不下的,借助ScrollView控件,我们就可以以滚动的形式查看屏幕外的内容。另外,布局中还放置了一个Button和一个TextView,Button用于发送HTTP请求,TextView用于将服务器返回的数据显示出来。
  2. 接着修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         sendRequestWithHttpUrl("https://brightmoon.ren/")
     }
    
     private fun sendRequestWithHttpUrl(url : String){
         thread {
             // 预定义一个HttpURLConnection对象,方便下边调用
             var connection : HttpURLConnection? = null
             try {
                 // 字符缓冲区,用于保存返回内容
                 val response = StringBuffer()
                 // 构建一个Url对象
                 val url = URL(url)
                 // 实例HttpURLConnection对象
                 connection = url.openConnection() as HttpURLConnection
                 // 设置连接超时时间
                 connection.connectTimeout = 8000
                 // 设置读取超时时间
                 connection.readTimeout = 8000
                 // 打开输入流
                 val input = connection.inputStream
                 // 创建使用默认大小的输入缓冲区的缓冲字符输入流。
                 val reader = BufferedReader(InputStreamReader(input))
    
                 reader.use {
                     // 遍历
                     reader.forEachLine {
                         // 添加到缓冲区
                         response.append(it)
                     }
                 }
                 // 调用showResponse
                 showResponse(response.toString())
             }catch (e:Exception){
                 e.printStackTrace()
             }finally {
                 // 关闭链接
                 connection?.disconnect()
             }
         }
     }
     private fun showResponse(response: String) {
         // 在UI线程上运行指定的操作。如果当前线程是UI线程,那么动作将立即执行。
         runOnUiThread {
             val responseText:TextView = findViewById(R.id.responseText)
             // 在这里进行UI操作,将结果显示到界面上
             responseText.text = response
         }
     }
    }
    

    可以看到,我们在发送请求按钮的点击事件里调用了sendRequestWithHttpUrl()方法,在这个方法中先是开启了一个子线程,然后在子线程里使用HttpURLConnection发出一条HTTP请求,请求的目标地址就是我的的首页。接着利用BufferedReader对服务器返回的流进行读取,并将结果传入showResponse()方法中。而在showResponse()方法里,则是调用了一个runOnUiThread()方法,然后在这个方法的Lambda表达式中进行操作,将返回的数据显示到界面上。
    那么这里为什么要用这个runOnUiThread()方法呢?别忘了,Android是不允许在子线程中进行UI操作的。我们在10章中学习了异步消息处理机制的工作原理,而runOnUiThread()方法其实就是对异步消息处理机制进行了一层封装,它背后的工作原理和第10章中所介绍的内容是一模一样的。借助这个方法,我们就可以将服务器返回的数据更新到界面上了。

  3. 完整的流程就是这样。不过在开始运行之前,仍然别忘了要声明一下网络权限。修改AndroidManifest.xml中的代码,如下所示:
    ```kotlin
    <?xml version=”1.0” encoding=”utf-8”?>

</manifest>

4. 好了,现在运行一下程序,并点击`发送请求`按钮,结果如图所示。
![博客第一行代码3服务器响应的数据](https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/img/博客第一行代码3服务器响应的数据.gif)
是不是看得头晕眼花?没错,服务器返回给我们的就是这种HTML代码,只是通常情况下浏览器会将这些代码解析成漂亮的网页后再展示出来。
那么如果想要提交数据给服务器应该怎么办呢?其实也不复杂,只需要将HTTP请求的方法改成POST,并在获取输入流之前把要提交的数据写出即可。注意,每条数据都要以键值对的形式存在,数据与数据之间用“&”符号隔开。比如说我们想要向服务器提交用户名和密码,就可以这样写:
```kotlin
connection.requestMethod = "POST"
val output = DataOutputStream(connection.outputStream)
output.writeBytes("username=admin&password=123456")

好了,相信你已经将HttpURLConnection的用法很好地掌握了

  1. 本小节代码

使用OkHttp

当然我们并不是只能使用HttpURLConnection,完全没有任何其他选择,事实上在开源盛行的今天,有许多出色的网络通信库都可以替代原生的HttpURLConnection,而其中OkHttp无疑是做得最出色的一个。

OkHttp是由鼎鼎大名的Square公司开发的,这个公司在开源事业上贡献良多,除了OkHttp之外,还开发了Retrofit、Picasso等知名的开源项目。OkHttp不仅在接口封装上做得简单易用,就连在底层实现上也是自成一派,比起原生的HttpURLConnection,可以说是有过之而无不及,现在已经成了广大Android开发者首选的网络通信库。那么本小节我们就来学习一下OkHttp的用法。OkHttp的项目主页地址是:项目主页

在使用OkHttp之前,我们需要先在项目中添加OkHttp库的依赖。编辑app/build.gradle文件,在dependencies闭包中添加如下内容:

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

添加上述依赖会自动下载两个库:一个是OkHttp库,一个是Okio库,后者是前者的通信基础。其中4.10.0是我写本书时OkHttp的最新版本,你可以访问OkHttp的项目主页,查看当前最新的版本是多少。

下面我们来看一下OkHttp的具体用法,首先需要创建一个OkHttpClient的实例,如下所示:

val client = OkHttpClient()

接下来如果想要发起一条HTTP请求,就需要创建一个Request对象:

val request = Request.Builder().build()

当然,上述代码只是创建了一个空的Request对象,并没有什么实际作用,我们可以在最终的build()方法之前连缀很多其他方法来丰富这个Request对象。比如可以通过url()方法来设置目标的网络地址,如下所示:

val request = Request.Builder()
 .url("https://brightmoon.ren/")
 .build()

之后调用OkHttpClient的newCall()方法来创建一个Call对象,并调用它的execute()方法来发送请求并获取服务器返回的数据,写法如下:

val response = client.newCall(request).execute()

Response对象就是服务器返回的数据了,我们可以使用如下写法来得到返回的具体内容:

val responseData = response.body?.string()

如果是发起一条POST请求,会比GET请求稍微复杂一点,我们需要先构建一个Request Body对象来存放待提交的参数,如下所示:

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

然后在Request.Builder中调用一下post()方法,并将RequestBody对象传入:

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

接下来的操作就和GET请求一样了,调用execute()方法来发送请求并获取服务器返回的数据即可。

好了,OkHttp的基本用法就先学到这里,在本章的稍后部分我们还会学习OkHttp结合Retrofit的使用方法,到时候再进一步学习。那么现在我们先把NetworkTest这个项目改用OkHttp的方式再实现一遍吧。

  1. 由于布局部分完全不用改动,所以直接修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         val sendRequestBtn: Button = findViewById(R.id.sendRequestBtn)
         sendRequestBtn.setOnClickListener {
             sendRequestWithHttpUrl("https://brightmoon.ren/")
         }
     }
    
     private fun sendRequestWithHttpUrl(url : String){
         thread {
             try {
                 // 创建OkHttpClient
                 val client = OkHttpClient()
                 // 创建构造器
                 val request = Request.Builder().apply {
                     url(url)
                 }.build()
                 // 发送请求
                 val response = client.newCall(request).execute()
                 // 获得返回值
                 val responseData = response.body?.string()
                 // 判断是否为空
                 responseData?.let {
                     // 渲染
                     showResponse(it)
                 }
             }catch (e:Exception){
                 e.printStackTrace()
             }
         }
     }
     private fun showResponse(response: String) {
         runOnUiThread {
             val responseText:TextView = findViewById(R.id.responseText)
             responseText.text = response
         }
     }
    }
    

    这里我们并没有做太多的改动,在这个方法中同样还是先开启了一个子线程,然后在子线程里使用OkHttp发出一条HTTP请求,请求的目标地址还是我的首页,OkHttp的用法也正如前面所介绍的一样。最后仍然调用了showResponse()方法,将服务器返回的数据显示到界面上。

  2. 仅仅是改了这么多代码,现在我们就可以重新运行一下程序了。点击发送请求按钮后,你会看到和上一小节中同样的运行结果。由此证明,使用OkHttp来发送HTTP请求的功能也已经成功实现了

  3. 本小节代码

解析XML格式数据

通常情况下,每个需要访问网络的应用程序都会有一个自己的服务器,我们可以向服务器提交数据,也可以从服务器上获取数据。不过这个时候就出现了一个问题,这些数据到底要以什么样的格式在网络上传输呢?随便传递一段文本肯定是不行的,因为另一方根本就不知道这段文本的用途是什么。因此,一般我们会在网络上传输一些格式化后的数据,这种数据会有一定的结构规则和语义,当另一方收到数据消息之后,就可以按照相同的结构规则进行解析,从而取出想要的那部分内容。

在网络上传输数据时最常用的格式有两种:XMLJSON。下面我们就来一个一个地进行学习。本节首先学习一下如何解析XML格式的数据。

在开始之前,我们还需要先解决一个问题,就是从哪儿才能获取一段XML格式的数据呢?这里我准备了这份:

https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析xml数据.xml

Pull解析方式

解析XML格式的数据其实也有挺多种方式的,本节中我们学习比较常用的两种:Pull解析和SAX解析。那么简单起见,这里仍然是在NetworkTest项目的基础上继续开发,这样我们就可以重用之前网络通信部分的代码,从而把工作的重心放在XML数据解析上。

  1. 既然XML格式的数据已经提供好了,现在要做的就是从中解析出我们想要得到的那部分内容。修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         val sendRequestBtn: Button = findViewById(R.id.sendRequestBtn)
         sendRequestBtn.setOnClickListener {
             sendRequestWithHttpUrl("https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析xml数据.xml")
         }
     }
    
     private fun sendRequestWithHttpUrl(url : String){
         thread {
             try {
                 val client = OkHttpClient()
                 val request = Request.Builder().apply {
                     url(url)
                 }.build()
                 val response = client.newCall(request).execute()
                 val responseData = response.body?.string()
                 responseData?.let {
                     // 将输出方法改为转换xml
                     parseXMLWithPull(it)
                 }
             }catch (e:Exception){
                 e.printStackTrace()
             }
         }
     }
    
     private fun parseXMLWithPull(xmlData: String) {
         try {
             // 创建可用于创建XML拉式解析器的PullParserFactory的新实例。
             val factory = XmlPullParserFactory.newInstance()
             // 使用当前配置的工厂特性创建XML Pull Parser的新实例。
             val xmlPullParser = factory.newPullParser()
             // 将解析器的输入源设置为给定的字符串读取器并重置解析器。
             xmlPullParser.setInput(StringReader(xmlData))
             // 返回当前事件的类型(START_TAG, END_TAG, TEXT等)。
             var eventType = xmlPullParser.eventType
             var id = ""
             var name = ""
             var version = ""
             // xml文档的逻辑结束
             while (eventType!=XmlPullParser.END_DOCUMENT){
                 // 对于START_TAG或END_TAG事件,在启用名称空间时返回当前元素的(本地)名称。
                 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 ("app" == nodeName) {
                             Log.d("MainActivity", "id is $id")
                             Log.d("MainActivity", "name is $name")
                             Log.d("MainActivity", "version is $version")
                         }
                     }
                 }
                 // 获取下一个解析事件
                 eventType = xmlPullParser.next()
             }
         } catch (e:Exception){}
     }
    }
    

    可以看到,这里首先将HTTP请求的地址改成了https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析xml数据.xml,在得到了服务器返回的数据后,我们不再直接将其展示,而是调用了parseXMLWithPull()方法来解析服务器返回的数据。
    下面就来仔细看下parseXMLWithPull()方法中的代码吧。这里首先要创建一个XmlPullParserFactory的实例,并借助这个实例得到XmlPullParser对象,然后调用XmlPullParser的setInput()方法将服务器返回的XML数据设置进去,之后就可以开始解析了。解析的过程也非常简单,通过getEventType()可以得到当前的解析事件,然后在一个while循环中不断地进行解析,如果当前的解析事件不等于XmlPullParser.END_DOCUMENT,说明解析工作还没完成,调用next()方法后可以获取下一个解析事件。
    在while循环中,我们通过getName()方法得到了当前节点的名字。如果发现节点名等于id、name或version,就调用nextText()方法来获取节点内具体的内容,每当解析完一个app节点,就将获取到的内容打印出来。
    好了,整体的过程就是这么简单,不过在程序运行之前还得再进行一项额外的配置。从Android9.0系统开始,应用程序默认只允许使用HTTPS类型的网络请求,HTTP类型的网络请求因为有安全隐患默认不再被支持。

  2. 那么为了能让程序使用HTTP,我们还要进行如下配置才可以。右击res目录→New→Directory,创建一个xml目录,接着右击xml目录→New→File,创建一个network_config.xml文件。然后修改network_config.xml文件中的内容,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
     <base-config cleartextTrafficPermitted="true">
         <trust-anchors>
             <certificates src="system" />
         </trust-anchors>
     </base-config>
    </network-security-config>
    

    这段配置文件的意思就是允许我们以明文的方式在网络上传输数据,而HTTP使用的就是明文传输方式。

  3. 接下来修改AndroidManifest.xml中的代码来启用我们刚才创建的配置文件:

    <?xml version="1.0" encoding="utf-8"?>
    <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:networkSecurityConfig="@xml/network_config"
    
         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.NetworkTest"
         tools:targetApi="31">
         <activity
             android:name=".MainActivity"
             android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
             <meta-data
                 android:name="android.app.lib_name"
                 android:value="" />
         </activity>
     </application>
    </manifest>
    

    这样就可以在程序中使用HTTP了,下面让我们来测试一下吧。

  4. 运行NetworkTest项目,然后点击发送请求按钮,观察Logcat中的打印日志,如图所示。
    博客第一行代码3学习打印从XML中解析出的数据
    可以看到,我们已经将XML数据中的指定内容成功解析出来了。
  5. 本小节代码

SAX解析方式

Pull解析方式虽然非常好用,但它并不是我们唯一的选择。SAX解析也是一种特别常用的XML解析方式,虽然它的用法比Pull解析要复杂一些,但在语义方面会更加清楚
要使用SAX解析,通常情况下我们会新建一个类继承自DefaultHandler,并重写父类的5个方法,如下所示:

class MyHandler : DefaultHandler() {
        // 开始XML解析的时候
        override fun startDocument() {}

        // 解析某个节点的时候
        override fun startElement(
            uri: String, localName: String, qName: String, attributes: Attributes
        ) {}

        //获取节点/中内容的时候
        override fun characters(ch: CharArray, start: Int, length: Int) {}

        // 完成解析某个节点的时候
        override fun endElement(uri: String, localName: String, qName: String) {}

        // 完成整个XML解析的时候
        override fun endDocument() {}
    }

这5个方法一看就很清楚吧?其中,startElement()、characters()和endElement()这3个方法是有参数的,从XML中解析出的数据就会以参数的形式传入这些方法中。需要注意的是,在获取节点中的内容时,characters()方法可能会被调用多次,一些换行符也被当作内容解析出来,我们需要针对这种情况在代码中做好控制。

那么下面就让我们尝试用SAX解析的方式来实现和上一小节同样的功能吧。

  1. 新建一个ContentHandler类继承自DefaultHandler,并重写父类的5个方法,如下所示:

    class ContentHandler : DefaultHandler() {
         private var nodeName = ""
         private lateinit var id:StringBuilder
         private lateinit var name: StringBuilder
         private lateinit var version:StringBuilder
    
         // 开始XML解析的时候
         override fun startDocument() {
             // 将这几个变量重置
             id = StringBuilder()
             name = StringBuilder()
             version = StringBuilder()
         }
    
         // 解析某个节点的时候
         override fun startElement(
             uri: String, localName: String, qName: String, attributes: Attributes
         ) {
             // 当前节点名字
             nodeName = localName
             Log.d("ContentHandler", "uri is $uri")
             Log.d("ContentHandler", "localName is $localName")
             Log.d("ContentHandler", "qName is $qName")
             Log.d("ContentHandler", "attributes is $attributes")
         }
    
         //获取节点/中内容的时候
         override fun characters(ch: CharArray, start: Int, length: Int) {
             // 根据当前节点名判断将内容添加到哪一个StringBuilder对象中
             when(nodeName){
                 "id" -> id.append(ch, start, length)
                 "name" -> name.append(ch, start, length)
                 "version" -> version.append(ch, start, length)
             }
         }
    
         // 完成解析某个节点的时候
         override fun endElement(uri: String, localName: String, qName: String) {
             // 判断app节点是否解析完成
             if ("app" == localName) {
                 Log.d("ContentHandler", "id is ${id.toString().trim()}")
                 Log.d("ContentHandler", "name is ${name.toString().trim()}")
                 Log.d("ContentHandler", "version is ${version.toString().trim()}")
                 // 最后要将StringBuilder清空
                 id.setLength(0)
                 name.setLength(0)
                 version.setLength(0)
             }
         }
    
         // 完成整个XML解析的时候
         override fun endDocument() {}
     }
    

    可以看到,我们首先给id、name和version节点分别定义了一个StringBuilder对象,并在startDocument()方法里对它们进行了初始化。每当开始解析某个节点的时候,startElement()方法就会得到调用,其中localName参数记录着当前节点的名字,这里我们把它记录下来。接着在解析节点中具体内容的时候就会调用characters()方法,我们会根据当前的节点名进行判断,将解析出的内容添加到哪一个StringBuilder对象中。最后在endElement()方法中进行判断,如果app节点已经解析完成,就打印出id、name和version的内容。需要注意的是,目前id、name和version中都可能是包括回车或换行符的,因此在打印之前我们还需要调用一下trim()方法,并且打印完成后要将StringBuilder的内容清空,不然的话会影响下一次内容的读取。

  2. 接下来的工作就非常简单了,修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         val sendRequestBtn: Button = findViewById(R.id.sendRequestBtn)
         sendRequestBtn.setOnClickListener {
             sendRequestWithHttpUrl("https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析xml数据.xml")
         }
     }
    
     private fun sendRequestWithHttpUrl(url : String){
         thread {
             try {
                 val client = OkHttpClient()
                 val request = Request.Builder().apply {
                     url(url)
                 }.build()
                 val response = client.newCall(request).execute()
                 val responseData = response.body?.string()
                 responseData?.let {
    
                     try {
                         val factory = SAXParserFactory.newInstance()
                         val xmlReader = factory.newSAXParser().xmlReader
                         val handler = ContentHandler()
                         // 将ContentHandler的实例设置到XMLReader中
                         xmlReader.contentHandler = handler
                         // 开始执行解析
                         xmlReader.parse(InputSource(StringReader(it)))
                     } catch (e: Exception) {
                         e.printStackTrace()
                     }
    
                 }
             }catch (e:Exception){
                 e.printStackTrace()
             }
         }
     }
    
     inner class ContentHandler : DefaultHandler() {
         private var nodeName = ""
         private lateinit var id:StringBuilder
         private lateinit var name: StringBuilder
         private lateinit var version:StringBuilder
         override fun startDocument() {
             id = StringBuilder()
             name = StringBuilder()
             version = StringBuilder()
         }
         override fun startElement(
             uri: String, localName: String, qName: String, attributes: Attributes
         ) {
             nodeName = localName
             Log.d("ContentHandler", "uri is $uri")
             Log.d("ContentHandler", "localName is $localName")
             Log.d("ContentHandler", "qName is $qName")
             Log.d("ContentHandler", "attributes is $attributes")
         }
         override fun characters(ch: CharArray, start: Int, length: Int) {
             when(nodeName){
                 "id" -> id.append(ch, start, length)
                 "name" -> name.append(ch, start, length)
                 "version" -> version.append(ch, start, length)
             }
         }
         override fun endElement(uri: String, localName: String, qName: String) {
             if ("app" == localName) {
                 Log.d("ContentHandler", "id is ${id.toString().trim()}")
                 Log.d("ContentHandler", "name is ${name.toString().trim()}")
                 Log.d("ContentHandler", "version is ${version.toString().trim()}")
                 id.setLength(0)
                 name.setLength(0)
                 version.setLength(0)
             }
         }
         override fun endDocument() {}
     }
    }
    

    在得到了服务器返回的数据后,我们这次通过调用parseXMLWithSAX()方法来解析XML数据。parseXMLWithSAX()方法中先是创建了一个SAXParserFactory的对象,然后再获取XMLReader对象,接着将我们编写的ContentHandler的实例设置到XMLReader中,最后调用parse()方法开始执行解析。

  3. 现在重新运行一下程序,点击发送请求按钮后观察Logcat中的打印日志,你会看到和图中一样的结果。
    博客第一行代码3学习使用sax解析xml
    由于startElement会被调用很多次,所以蓝色部分也会输出多次.
  4. 本小节代码

解析JSON格式数据

现在你已经掌握了XML格式数据的解析方式,那么接下来我们要学习一下如何解析JSON格式的数据了。比起XML,JSON的主要优势在于它的体积更小,在网络上传输的时候更省流量。但缺点在于,它的语义性较差,看起来不如XML直观。

在开始之前,我们还需要一个json文件,仍然可以使用下面这个:

https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析json数据.json

好了,这样我们就把JSON格式的数据准备好了,下面就开始学习如何在Android程序中解析这些数据吧。

使用JSONObject

类似地,解析JSON数据也有很多种方法,可以使用官方提供的JSONObject,也可以使用Google的开源库GSON。另外,一些第三方的开源库如JacksonFastJSON等也非常不错。本节中我们就来学习一下前两种解析方式的用法。

  1. 修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         val sendRequestBtn: Button = findViewById(R.id.sendRequestBtn)
         sendRequestBtn.setOnClickListener {
             sendRequestWithHttpUrl("https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析json数据.json")
         }
     }
    
     private fun sendRequestWithHttpUrl(url : String){
         thread {
             try {
                 val client = OkHttpClient()
                 val request = Request.Builder().apply {
                     url(url)
                 }.build()
                 val response = client.newCall(request).execute()
                 val responseData = response.body?.string()
                 responseData?.let {
                     parseJSONWithJSONObject(responseData)
                 }
             }catch (e:Exception){
                 e.printStackTrace()
             }
         }
     }
    
     private fun parseJSONWithJSONObject(jsonData: String) {
         try {
             val jsonArray = JSONArray(jsonData)
             for (i in 0 until jsonArray.length()) {
                 val jsonObject = jsonArray.getJSONObject(i)
                 val id = jsonObject.getString("id")
                 val name = jsonObject.getString("name")
                 val version = jsonObject.getString("version")
                 Log.d("MainActivity", "id is $id")
                 Log.d("MainActivity", "name is $name")
                 Log.d("MainActivity", "version is $version")
             }
         }catch (e: Exception){
             e.printStackTrace()
         }
     }
    }
    

首先将HTTP请求的地址改成https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析json数据.json,然后在得到服务器返回的数据后调用parseJSONWithJSONObject()方法来解析数据。可以看到,解析JSON的代码真的非常简单,由于我们在服务器中定义的是一个JSON数组,因此这里首先将服务器返回的数据传入一个JSONArray对象中。然后循环遍历这个JSONArray,从中取出的每一个元素都是一个JSONObject对象,每个JSONObject对象中又会包含id、name和version这些数据。接下来只需要调用getString()方法将这些数据取出,并打印出来即可。

  1. 好了,就是这么简单!现在重新运行一下程序,并点击发送请求按钮,结果如图所示。
    博客第一行代码3打印从JSON中解析出的数据

使用GSON

如果你认为使用JSONObject来解析JSON数据已经非常简单了,那你就太容易满足了。Google提供的GSON开源库可以让解析JSON数据的工作简单到让你不敢想象的地步,那我们肯定是不能错过这个学习机会的。

不过,GSON并没有被添加到Android官方的API中,因此如果想要使用这个功能的话,就必须在项目中添加GSON库的依赖。编辑app/build.gradle文件,在dependencies闭包中添加如下内容:

dependencies {
 ...
 implementation 'com.google.code.gson:gson:2.10'
}

同样的,这里使用的是我这时候最新的版本.可以去项目主页查看最新的版本.

那么GSON库究竟是神奇在哪里呢?它的强大之处就在于可以将一段JSON格式的字符串自动映射成一个对象,从而不需要我们再手动编写代码进行解析了。

比如说一段JSON格式的数据如下所示:

{"name":"Tom","age":20}

那我们就可以定义一个Person类,并加入name和age这两个字段,然后只需简单地调用如下代码就可以将JSON数据自动解析成一个Person对象了:

val gson = Gson()
val person = gson.fromJson(jsonData, Person::class.java)

如果需要解析的是一段JSON数组,会稍微麻烦一点,比如如下格式的数据:

[{"name":"Tom","age":20}, {"name":"Jack","age":25}, {"name":"Lily","age":22}]

这个时候,我们需要借助TypeToken将期望解析成的数据类型传入fromJson()方法中,如下所示:

val typeOf = object : TypeToken<List<Person>>() {}.type
val people = gson.fromJson<List<Person>>(jsonData, typeOf)

好了,基本的用法就是这样,下面就让我们来真正地尝试一下吧。

  1. 首先新增一个App类,并加入id、name和version这3个字段,如下所示:
    class App(val id: String, val name: String, val version: String)
    
  2. 然后修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         val sendRequestBtn: Button = findViewById(R.id.sendRequestBtn)
         sendRequestBtn.setOnClickListener {
             sendRequestWithHttpUrl("https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/安卓第一行代码解析json数据.json")
         }
     }
    
     private fun sendRequestWithHttpUrl(url : String){
         thread {
             try {
                 val client = OkHttpClient()
                 val request = Request.Builder().apply {
                     url(url)
                 }.build()
                 val response = client.newCall(request).execute()
                 val responseData = response.body?.string()
                 responseData?.let {
                     parseJSONWithJSONObject(responseData)
                 }
             }catch (e:Exception){
                 e.printStackTrace()
             }
         }
     }
    
     private fun parseJSONWithJSONObject(jsonData: String) {
         try {
             // 关键代码开始
             val gson = Gson()
             val typeOf = object:TypeToken<List<App>>() {}.type
             val appList = gson.fromJson<List<App>>(jsonData,typeOf)
             for (app in appList) {
                 Log.d("MainActivity", "id is ${app.id}")
                 Log.d("MainActivity", "name is ${app.name}")
                 Log.d("MainActivity", "version is ${app.version}")
             }
             // 关键代码结束
         }catch (e: Exception){
             e.printStackTrace()
         }
     }
    }
    
  3. 现在重新运行程序,点击发送请求按钮后观察Logcat中的打印日志,你会看到和图中一样的结果。
    博客第一行代码3学习使用gson解析json

  4. 本小节gson代码

网络请求回调的实现方式

目前你已经掌握了HttpURLConnection和OkHttp的用法,知道了如何发起HTTP请求,以及解析服务器返回的数据,但也许你还没有发现,之前我们的写法其实是很有问题的。因为一个应用程序很可能会在许多地方都使用到网络功能,而发送HTTP请求的代码基本是相同的,如果我们每次都去编写一遍发送HTTP请求的代码,这显然是非常差劲的做法。

没错,通常情况下我们应该将这些通用的网络操作提取到一个公共的类里,并提供一个通用方法,当想要发起网络请求的时候,只需简单地调用一下这个方法即可。比如使用如下的写法:

 object HttpUtil {
        fun sendHttpRequest(address: String): String {
            var connection: HttpURLConnection? = null
            try {
                val response = StringBuilder()
                val url = URL(address)
                connection = url.openConnection() as HttpURLConnection
                connection.connectTimeout = 8000
                connection.readTimeout = 8000
                val input = connection.inputStream
                val reader = BufferedReader(InputStreamReader(input))
                reader.use {
                    reader.forEachLine {
                        response.append(it)
                    }
                }
                return response.toString()
            } catch (e: Exception) {
                e.printStackTrace()
                return e.message.toString()
            } finally {
                connection?.disconnect()
            }
        }
    }

以后每当需要发起一条HTTP请求的时候,就可以这样写:

val address = "https://brightmoon.ren/"
val response = HttpUtil.sendHttpRequest(address)

在获取到服务器响应的数据后,我们就可以对它进行解析和处理了。但是需要注意,网络请求通常属于耗时操作,而sendHttpRequest()方法的内部并没有开启线程,这样就有可能导致在调用sendHttpRequest()方法的时候主线程被阻塞

你可能会说,很简单嘛,在sendHttpRequest()方法内部开启一个线程,不就解决这个问题了吗?其实没有你想象中那么容易,因为如果我们在sendHttpRequest()方法中开启一个线程来发起HTTP请求,服务器响应的数据是无法进行返回的。这是由于所有的耗时逻辑都是在子线程里进行的,sendHttpRequest()方法会在服务器还没来得及响应的时候就执行结束了,当然也就无法返回响应的数据了。

那么在遇到这种情况时应该怎么办呢?其实解决方法并不难,只需要使用编程语言的回调机制就可以了。下面就让我们来学习一下回调机制到底是如何使用的。

首先需要定义一个接口,比如将它命名成HttpCallbackListener,代码如下所示:

interface HttpCallbackListener {
 fun onFinish(response: String)
 fun onError(e: Exception)
}

可以看到,我们在接口中定义了两个方法:onFinish()方法表示当服务器成功响应我们请求的时候调用,onError()表示当进行网络操作出现错误的时候调用。这两个方法都带有参数,onFinish()方法中的参数代表服务器返回的数据,而onError()方法中的参数记录着错误的详细信息

接着修改HttpUtil中的代码,如下所示:

object HttpUtil {
        fun sendHttpRequest(address: String, listener: HttpCallbackListener) {
            thread {
                var connection: HttpURLConnection? = null
                try {
                    val response = StringBuilder()
                    val url = URL(address)
                    connection = url.openConnection() as HttpURLConnection
                    connection.connectTimeout = 8000
                    connection.readTimeout = 8000
                    val input = connection.inputStream
                    val reader = BufferedReader(InputStreamReader(input))
                    reader.use {
                        reader.forEachLine {
                            response.append(it)
                        }
                    }
                    // 回调onFinish()方法
                    listener.onFinish(response.toString())
                } catch (e: Exception) {
                    e.printStackTrace()
                    // 回调onError()方法
                    listener.onError(e)
                } finally {
                    connection?.disconnect()
            }
        }
    }
}

我们首先给sendHttpRequest()方法添加了一个HttpCallbackListener参数,并在方法的内部开启了一个子线程,然后在子线程里执行具体的网络操作。注意,子线程中是无法通过return语句返回数据的,因此这里我们将服务器响应的数据传入了HttpCallbackListener的onFinish()方法中,如果出现了异常,就将异常原因传入onError()方法中。

现在sendHttpRequest()方法接收两个参数,因此我们在调用它的时候还需要将HttpCallbackListener的实例传入,如下所示:

HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
        override fun onFinish(response: String) {
            // 得到服务器返回的具体内容
        }
        override fun onError(e: Exception) {
            // 在这里对异常情况进行处理
        }
    })

这样当服务器成功响应的时候,我们就可以在onFinish()方法里对响应数据进行处理了。类似地,如果出现了异常,就可以在onError()方法里对异常情况进行处理。如此一来,我们就巧妙地利用回调机制将响应数据成功返回给调用方了。

不过你会发现,上述使用HttpURLConnection的写法总体来说还是比较复杂的,那么使用OkHttp会变得简单吗?答案是肯定的,而且要简单得多,下面我们来具体看一下。在HttpUtil中加入一个sendOkHttpRequest()方法,如下所示:

object HttpUtil {
        ...
        fun sendOkHttpRequest(address: String, callback: okhttp3.Callback) {
            val client = OkHttpClient()
            val request = Request.Builder()
                .url(address)
                .build()
            client.newCall(request).enqueue(callback)
        }
    }

可以看到,sendOkHttpRequest()方法中有一个okhttp3.Callback参数,这个是OkHttp库中自带的回调接口,类似于我们刚才自己编写的HttpCallbackListener。然后在client.newCall()之后没有像之前那样一直调用execute()方法,而是调用了一个enqueue()方法,并把okhttp3.Callback参数传入。相信聪明的你已经猜到了,OkHttp在enqueue()方法的内部已经帮我们开好子线程了,然后会在子线程中执行HTTP请求,并将最终的请求结果``回调到okhttp3.Callback当中。

那么我们在调用sendOkHttpRequest()方法的时候就可以这样写:

HttpUtil.sendOkHttpRequest(address, object : Callback {
        override fun onResponse(call: Call, response: Response) {
            // 得到服务器返回的具体内容
            val responseData = response.body?.string()
        }
        override fun onFailure(call: Call, e: IOException) {
            // 在这里对异常情况进行处理
        }
    })

由此可以看出,OkHttp的接口设计得确实非常人性化,它将一些常用的功能进行了很好的封装,使得我们只需编写少量的代码就能完成较为复杂的网络操作。
另外,需要注意的是,不管是使用HttpURLConnection还是OkHttp,最终的回调接口都还是在子线程中运行的,因此我们不可以在这里执行任何的UI操作,除非借助runOnUiThread()方法来进行线程转换

最好用的网络库:Retrofit

既然我们这一章讲解Android网络技术,那么就不得不提到Retrofit,因为它实在是太好用了。Retrofit同样是一款由Square公司开发的网络库,但是它和OkHttp的定位完全不同。OkHttp侧重的是底层通信的实现,而Retrofit侧重的是上层接口的封装。事实上,Retrofit就是Square公司在OkHttp的基础上进一步开发出来的应用层网络通信库,使得我们可以用更加面向对象的思维进行网络操作。Retrofit的项目主页地址是:https://github.com/square/retrofit。

那么本节我们就来学习一下Retrofit的用法,新建一个RetrofitTest项目,然后马上开始吧。

Retrofit的基本用法

首先我想谈一谈Retrofit的基本设计思想。Retrofit的设计基于以下几个事实。

同一款应用程序中所发起的网络请求绝大多数指向的是同一个服务器域名。这个很好理解,因为任何公司的产品,客户端和服务器都是配套的,很难想象一个客户端一会去这个服务器获取数据,一会又要去另外一个服务器获取数据吧?

另外,服务器提供的接口通常是可以根据功能来归类的。比如新增用户、修改用户数据、查询用户数据这几个接口就可以归为一类,上架新书、销售图书、查询可供销售图书这几个接口也可以归为一类。将服务器接口合理归类能够让代码结构变得更加合理,从而提高可阅读性和可维护性。

最后,开发者肯定更加习惯于调用一个接口,获取它的返回值这样的编码方式,但当调用的是服务器接口时,却很难想象该如何使用这样的编码方式。其实大多数人并不关心网络的具体通信细节,但是传统网络库的用法却需要编写太多网络相关的代码。

而Retrofit的用法就是基于以上几点来设计的,首先我们可以配置好一个根路径,然后在指定服务器接口地址时只需要使用相对路径即可,这样就不用每次都指定完整的URL地址了。

另外,Retrofit允许我们对服务器接口进行归类,将功能同属一类的服务器接口定义到同一个接口文件当中,从而让代码结构变得更加合理。

最后,我们也完全不用关心网络通信的细节,只需要在接口文件中声明一系列方法和返回值,然后通过注解的方式指定该方法对应哪个服务器接口,以及需要提供哪些参数。当我们在程序中调用该方法时,Retrofit会自动向对应的服务器接口发起请求,并将响应的数据解析成返回值声明的类型。这就使得我们可以用更加面向对象的思维来进行网络操作。

Retrofit的基本设计思想差不多就是这些,下面就让我们通过一个具体的例子来快速体验一下Retrofit的用法。

  1. 要想使用Retrofit,我们需要先在项目中添加必要的依赖库。编辑app/build.gradle文件,在dependencies闭包中添加如下内容:
    dependencies {
    ...
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    }
    
    由于Retrofit是基于OkHttp开发的,因此添加上述第一条依赖会自动将Retrofit、OkHttp和Okio这几个库一起下载,我们无须再手动引入OkHttp库。另外,Retrofit还会将服务器返回的JSON数据自动解析成对象,因此上述第二条依赖就是一个Retrofit的转换库,它是借助GSON来解析JSON数据的,所以会自动将GSON库一起下载下来,这样我们也不用手动引入GSON库了。除了GSON之外,Retrofit还支持各种其他主流的JSON解析库,包括Jackson、Moshi等,不过毫无疑问GSON是最常用的。
  2. 这里我们打算继续使用章提供的JSON数据接口。由于Retrofit会借助GSON将JSON数据转换成对象,因此这里同样需要新增一个App类,并加入id、name和version这3个字段,如下所示:
    class App(val id: String, val name: String, val version: String)
    
  3. 接下来,我们可以根据服务器接口的功能进行归类,创建不同种类的接口文件,并在其中定义对应具体服务器接口的方法。不过由于我们只有一个获取JSON数据的接口,因此这里只需要定义一个接口文件,并包含一个方法即可。新建AppService接口,代码如下所示:
    interface AppService {
     @GET("安卓第一行代码解析json数据.json")
     fun getAppData(): Call<List<App>>
    }
    
    通常Retrofit的接口文件建议以具体的功能种类名开头,并以Service结尾,这是一种比较好的命名习惯。
    上述代码中有两点需要我们注意。第一就是在getAppData()方法上面添加的注解,这里使用了一个@GET注解,表示当调用getAppData()方法时Retrofit会发起一条GET请求,请求的地址就是我们在@GET注解中传入的具体参数。注意,这里只需要传入请求地址的相对路径即可,根路径我们会在稍后设置。
    第二就是getAppData()方法的返回值必须声明成Retrofit中内置的Call类型,并通过泛型来指定服务器响应的数据应该转换成什么对象。由于服务器响应的是一个包含App数据的JSON数组,因此这里我们将泛型声明成List<App>。当然,Retrofit还提供了强大的Call Adapters功能来允许我们自定义方法返回值的类型,比如Retrofit结合RxJava使用就可以将返回值声明成Observable、Flowable等类型,不过这些内容就不在本节的讨论范围内了。
  4. 定义好了AppService接口之后,接下来的问题就是该如何使用它。为了方便测试,我们还得在界面上添加一个按钮才行。修改activity_main.xml中的代码,如下所示:
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:orientation="vertical"
     android:layout_width="match_parent"
     android:layout_height="match_parent" >
     <Button
         android:id="@+id/getAppDataBtn"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:text="获得App数据" />
    </LinearLayout>
    
    很简单,这里在布局文件中增加了一个Button控件,我们在它的点击事件中处理具体的网络请求逻辑即可。
  5. 现在修改MainActivity中的代码,如下所示:

    class MainActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
    
         val getAppDataBtn:Button = findViewById(R.id.getAppDataBtn)
         getAppDataBtn.setOnClickListener {
             // 构建一个Retrofit对象
             val retrofit = Retrofit.Builder().apply {
                 baseUrl("https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/")
                 // 指定解析数据用的转换库,这里指定为gson解析json
                 addConverterFactory(GsonConverterFactory.create())
             }.build()
             // 创建代理对象
             val appService = retrofit.create(AppService::class.java)
             // 异步发送请求并将其响应通知回调,或者如果发生了错误,则通知回调与服务器通信、创建请求或处理响应。 
             appService.getAppData().enqueue(object : Callback<List<App>>{
                 override fun onResponse(call: Call<List<App>>, response: Response<List<App>>) {
                     val list = response.body()
                     list?.let {
                         for (app in list) {
                             Log.d("MainActivity", "id is ${app.id}")
                             Log.d("MainActivity", "name is ${app.name}")
                             Log.d("MainActivity", "version is ${app.version}")
                         }
    
                     }
                 }
    
                 override fun onFailure(call: Call<List<App>>, t: Throwable) = t.printStackTrace()
             })
         }
     }
    }
    

    可以看到,在获得app数据按钮的点击事件当中,首先使用了Retrofit.Builder来构建一个Retrofit对象,其中baseUrl()方法用于指定所有Retrofit请求的根路径,addConverterFactory()方法用于指定Retrofit在解析数据时所使用的转换库,这里指定成GsonConverterFactory。注意这两个方法都是必须调用的。
    有了Retrofit对象之后,我们就可以调用它的create()方法,并传入具体Service接口所对应的Class类型,创建一个该接口的动态代理对象。如果你并不熟悉什么是动态代理也没有关系,你只需要知道有了动态代理对象之后,我们就可以随意调用接口中定义的所有方法,而Retrofit会自动执行具体的处理就可以了。
    对应到上述的代码当中,当调用了AppService的getAppData()方法时,会返回一个Call<List<App>>对象,这时我们再调用一下它的enqueue()方法,Retrofit就会根据注解中配置的服务器接口地址去进行网络请求了,服务器响应的数据会回调enqueue()方法中传入的Callback实现里面。需要注意的是,当发起请求的时候,Retrofit会自动在内部开启子线程,当数据回调到Callback中之后,Retrofit又会自动切换回主线程,整个操作过程中我们都不用考虑线程切换问题。在Callback的onResponse()方法中,调用response.body()方法将会得到Retrofit解析后的对象,也就是List<App>类型的数据,最后遍历List,将其中的数据打印出来即可。

  6. 然后修改AndroidManifest.xml中的代码,给予网络权限,代码如下:
    <?xml version="1.0" encoding="utf-8"?>
    <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.RetrofitTest"
         tools:targetApi="31">
         <activity
             android:name=".MainActivity"
             android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
             <meta-data
                 android:name="android.app.lib_name"
                 android:value="" />
         </activity>
     </application>
    </manifest>
    
    接下来就可以进行一下测试了,如果使用的是http链接,仍然需要对网络安全配置才行
  7. 然后点击获得app数据按钮,观察Logcat中的打印日志,如图11.10所示。
    博客第一行代码3学习使用Retrofit请求和解析出的数据
    可以看到,服务器响应的数据已经被成功解析出来了,说明我们编写的代码确实已经正常工作了。

处理复杂的接口地址类型

在上一小节中,我们通过示例程序向一个非常简单的服务器接口地址发送请求,然而在真实的开发环境当中,服务器所提供的接口地址不可能一直如此简单。如果你在使用浏览器上网时观察一下浏览器上的网址,你会发现这些网址可能会是千变万化的,那么本小节我们就来学习一下如何使用Retrofit来应对这些千变万化的情况。

为了方便举例,这里先定义一个Data类,并包含idcontent这两个字段,如下所示:

class Data(val id: String, val content: String)

然后我们先从最简单的看起,比如服务器的接口地址如下所示(假接口,不可访问):

GET http://example.com/get_data.json

这是最简单的一种情况,接口地址是静态的,永远不会改变。那么对应到Retrofit当中,使用如下的写法即可:

interface ExampleService {
    @GET("get_data.json")
    fun getData(): Call<Data>
}

这也是我们在上一小节中已经学过的部分,理解起来应该非常简单吧。

但是显然服务器不可能总是给我们提供静态类型的接口,在很多场景下,接口地址中的部分内容可能会是动态变化的,比如如下的接口地址:

GET http://example.com/<page>/get_data.json

在这个接口当中,<page>部分代表页数,我们传入不同的页数,服务器返回的数据也会不同。这种接口地址对应到Retrofit当中应该怎么写呢?其实也很简单,如下所示:

interface ExampleService {
    @GET("{page}/get_data.json")
    fun getData(@Path("page") page: Int): Call<Data>
}

在@GET注解指定的接口地址当中,这里使用了一个{page}的占位符,然后又在getData()方法中添加了一个page参数,并使用@Path("page")注解来声明这个参数。这样当调用getData()方法发起请求时,Retrofit就会自动将page参数的值替换到占位符的位置,从而组成一个合法的请求地址。

另外,很多服务器接口还会要求我们传入一系列的参数,格式如下:

GET http://example.com/get_data.json?u=<user>&t=<token>

这是一种标准的带参数GET请求的格式。接口地址的最后使用问号来连接参数部分,每个参数都是一个使用等号连接的键值对,多个参数之间使用&符号进行分隔。那么很显然,在上述地址中,服务器要求我们传入usertoken这两个参数的值。对于这种格式的服务器接口,我们可以使用刚才所学的@Path注解的方式来解决,但是这样会有些麻烦,Retrofit针对这种带参数的GET请求,专门提供了一种语法支持:

interface ExampleService {
 @GET("get_data.json")
 fun getData(@Query("u") user: String, @Query("t") token: String): Call<Data>
}

这里在getData()方法中添加了user和token这两个参数,并使用@Query注解对它们进行声明。这样当发起网络请求的时候,Retrofit就会自动按照带参数GET请求的格式将这两个参数构建到请求地址当中。

学习了以上内容之后,现在你在一定程度上已经可以应对千变万化的服务器接口地址了。不过HTTP并不是只有GET请求这一种类型,而是有很多种,其中比较常用的有GET、POST、PUT、PATCH、DELETE这几种。它们之间的分工也很明确,简单概括的话,GET请求用于从服务器获取数据,POST请求用于向服务器提交数据,PUT和PATCH请求用于修改服务器上的数据,DELETE请求用于删除服务器上的数据。
而Retrofit对所有常用的HTTP请求类型都进行了支持,使用@GET、@POST、@PUT、@PATCH、@DELETE注解,就可以让Retrofit发出相应类型的请求了。

比如服务器提供了如下接口地址:

DELETE http://example.com/data/<id>

这种接口通常意味着要根据id删除一条指定的数据,而我们在Retrofit当中想要发出这种请求就可以这样写:

interface ExampleService {
 @DELETE("data/{id}")
 fun deleteData(@Path("id") id: String): Call<ResponseBody>
}

这里使用了@DELETE注解来发出DELETE类型的请求,并使用了@Path注解来动态指定id,这些都很好理解。但是在返回值声明的时候,我们将Call的泛型指定成了ResponseBody,这是什么意思呢?
由于POST、PUT 、PATCH、DELETE这几种请求类型与GET请求不同,它们更多是用于操作服务器上的数据,而不是获取服务器上的数据,所以通常它们对于服务器响应的数据并不关心。这个时候就可以使用ResponseBody,表示Retrofit能够接收任意类型的响应数据,并且不会对响应数据进行解析

那么如果我们需要向服务器提交数据该怎么写呢?比如如下的接口地址:

POST http://example.com/data/create
{"id": 1, "content": "The description for this data."}

使用POST请求来提交数据,需要将数据放到HTTP请求的body部分,这个功能在Retrofit中可以借助@Body注解来完成:

interface ExampleService {
 @POST("data/create")
 fun createData(@Body data: Data): Call<ResponseBody>
}

可以看到,这里我们在createData()方法中声明了一个Data类型的参数,并给它加上了@Body注解。这样当Retrofit发出POST请求时,就会自动将Data对象中的数据转换成JSON格式的文本,并放到HTTP请求的body部分,服务器在收到请求之后只需要从body中将这部分数据解析出来即可。这种写法同样也可以用来给PUT、PATCH、DELETE类型的请求提交数据。

最后,有些服务器接口还可能会要求我们在HTTP请求的header中指定参数,比如:

GET http://example.com/get_data.json
User-Agent: okhttp
Cache-Control: max-age=0

这些header参数其实就是一个个的键值对,我们可以在Retrofit中直接使用@Headers注解来对它们进行声明。

interface ExampleService {
 @Headers("User-Agent: okhttp", "Cache-Control: max-age=0")
 @GET("get_data.json")
 fun getData(): Call<Data>
}

但是这种写法只能进行静态header声明,如果想要动态指定header的值,则需要使用@Header注解,如下所示:

interface ExampleService {
 @GET("get_data.json")
 fun getData(@Header("User-Agent") userAgent: String,
 @Header("Cache-Control") cacheControl: String): Call<Data>
}

现在当发起网络请求的时候,Retrofit就会自动将参数中传入的值设置到User-Agent和Cache-Control这两个header当中,从而实现了动态指定header值的功能。
好了,这样我们就将使用Retrofit处理复杂接口地址类型的内容基本学完了,现在不管服务器给你提供什么样类型的接口,相信你都可以从容面对了吧?

Retrofit构建器的最佳写法

学到这里,其实还有一个问题我们没有正视过,就是获取Service接口的动态代理对象实在是太麻烦了。先回顾一下之前的写法吧,大致代码如下所示:

val retrofit = Retrofit.Builder().apply {
    baseUrl("https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/")
    // 指定解析数据用的转换库,这里指定为gson解析json
    addConverterFactory(GsonConverterFactory.create())
}.build()
// 创建代理对象
val appService = retrofit.create(AppService::class.java)

我们想要得到AppService的动态代理对象,需要先使用Retrofit.Builder构建出一个Retrofit对象,然后再调用Retrofit对象的create()方法创建动态代理对象。如果只是写一次还好,每次调用任何服务器接口时都要这样写一遍的话,肯定没有人能受得了。

事实上,确实也没有每次都写一遍的必要,因为构建出的Retrofit对象全局通用的,只需要在调用create()方法时针对不同的Service接口传入相应的Class类型即可。因此,我们可以将通用的这部分功能封装起来,从而简化获取Service接口动态代理对象的过程。

新建一个ServiceCreator单例类,代码如下所示:

object ServiceCreator {
    private const val BASE_URL = "https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/"

    private val retrofit = Retrofit.Builder().apply {
        baseUrl(BASE_URL)
        addConverterFactory(GsonConverterFactory.create())
    }.build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
}

这里我们使用object关键字让ServiceCreator成为了一个单例类,并在它的内部定义了一个BASE_URL常量,用于指定Retrofit的根路径。然后同样是在内部使用Retrofit.Builder构建一个Retrofit对象,注意这些都是用private修饰符来声明的,相当于对于外部而言它们都是不可见的。

最后,我们提供了一个外部可见create()方法,并接收一个Class类型的参数。当在外部调用这个方法时,实际上就是调用了Retrofit对象的create()方法,从而创建出相应Service接口的动态代理对象。

经过这样的封装之后,Retrofit的用法将会变得异常简单,比如我们想获取一个AppService接口的动态代理对象,只需要使用如下写法即可:

val appService = ServiceCreator.create(AppService::class.java)

之后就可以随意调用AppService接口中定义的任何方法了。

不过上述代码其实仍然还有优化空间,还记得我们在上一章的Kotlin课堂中学习的泛型实化功能吗?这里立马就可以应用起来了。修改ServiceCreator中的代码,如下所示:

object ServiceCreator {
    private const val BASE_URL = "https://jsdelivr.007666.xyz/gh/1802024110/GitHub_Oss@main/file/"

    private val retrofit = Retrofit.Builder().apply {
        baseUrl(BASE_URL)
        addConverterFactory(GsonConverterFactory.create())
    }.build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
    inline fun <reified T> create(): T = create(T::class.java)
}

可以看到,我们又定义了一个不带参数的create()方法,并使用inline关键字来修饰方法,使用reified关键字来修饰泛型,这是泛型实化的两大前提条件。接下来就可以使用T::class.java这种语法了,这里调用刚才定义的带有Class参数的create()方法即可。

那么现在我们就又有了一种新的方式来获取AppService接口的动态代理对象,如下所示:

val appService = ServiceCreator.create<AppService>()

代码是不是变得更加简洁了?

好了,关于Retrofit的使用就先讲到这里,我们会在第15章的实战环节学习如何在实际的项目当中应用Retrofit。那么接下来,又该进入本章的Kotlin课堂了,这次我们来学习一项特别神奇的技术——协程。

retrofit小节代码

Kotlin课堂:使用协程编写高效的并发程序

协程可以看我的另一篇文章

评论