Android开发之使用网络技术解析XML和JSON数据

解析XML格式数据

通常情况下,每个需要访问网络的应用程序都会有一个自己的服务器,可以向服务器提交数据,也可以从服务器上获取数据。一般在网络上传输数据是一些格式化后的,这种数据会有一定的结构规则和语义,当另一方收到数据消息之后,就可以按照相同的结构规则进行解析,从而取出想要的那部分内容。
在网络上传输数据时最常用的格式有两种:XML和JSON,先对xml格式进行解析

使用Apache搭建一个最简单的Web服务器(Apache下载安装看这里)
在下载好的Apache找到Apache\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>

访问http://127.0.0.1/get_data.xml网址会出现下面页面
在这里插入图片描述

Pull解析方式

打开NetworkTest项目(项目的其他内容看这里),
修改MainActivity中的代码

class MainActivity : AppCompatActivity() {
	 ...
	 private fun sendRequestWithOkHttp() {
		 thread {
			 try {
				 val client = OkHttpClient()
				 val request = Request.Builder()
				 // 指定访问的服务器地址是计算机本机
				 .url("http://10.0.2.2/get_data.xml")
				 .build()
				 val response = client.newCall(request).execute()
				 val responseData = response.body?.string()
				 if (responseData != null) {
				 parseXMLWithPull(responseData)
				 }
			 } catch (e: Exception) {
			 	e.printStackTrace()
			 }
		 }
	 }
	 
	 ...
	 private fun parseXMLWithPull(xmlData: String) {
		 try {
			 val factory = XmlPullParserFactory.newInstance()
			 val xmlPullParser = factory.newPullParser()
			 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 ("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) {
		 	e.printStackTrace()
		 }
	 }
}

首先将HTTP请求的地址改成了http://10.0.2.2/get_data.xml,10.0.2.2对于模拟器来说就是计算机本机的IP地址。在得到了服务器返回的数据后,调用了parseXMLWithPull()方法来解析服务器返回的数据
下面看下parseXMLWithPull()方法。这里首先要创建一个XmlPullParserFactory的实例,并借助这个实例得到XmlPullParser对象,然后调用XmlPullParser的setInput()方法将服务器返回的XML数据设置进去,之后就可以开始解析了
解析的过程也非常简单,通过getEventType()可以得到当前的解析事件,然后在一个while循环中不断地进行解析,如果当前的解析事件不等于XmlPullParser.END_DOCUMENT,说明解析工作还没完成,调用next()方法后可以获取下一个解析事件
在while循环中,通过getName()方法得到了当前节点的名字。如果发现节点名等于id、name或version,就调用nextText()方法来获取节点内具体的内容,每当解析完一个app节点,就将获取到的内容打印出来

由于从Android9.0系统开始,应用程序默认只允许使用HTTPS类型的网络请求,HTTP类型的网络请求因为有安全隐患默认不再被支持,而搭建的Apache服务器现在使用的就是HTTP
为了能让程序使用HTTP,还要进行如下配置才可以。找到项目中res下的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>

然后在改AndroidManifest.xml中启动上面的配置文件,在application中添加下面一句话

android:networkSecurityConfig="@xml/network_config"

运行程序,点击“Send Request”按钮,Logcat日志如下
在这里插入图片描述

SAX解析方式

SAX解析也是一种特别常用的XML解析方式,虽然它的用法比Pull解析要复杂一些,但在语义方面会更加清楚。

要使用SAX解析,通常情况下会新建一个类继承自DefaultHandler,并重写下面父类的5个方法
startDocument()方法会在开始XML解析的时候调用
startElement()方法会在开始解析某个节点的时候调用
characters()方法会在获取节点中内容的时候调用,需要注意的是,在获取节点中的内容时,被调用多次,一些换行符也被当作内容解析出来
endElement()方法会在完成解析某个节点的时候调用,
endDocument()方法会在完成整个XML解析的时候调用
其中,startElement()、characters()和endElement()这3个方法是有参数的,从XML中解析出的数据就会以参数的形式传入这些方法中

在NetworkTest项目中,新建一个ContentHandler类继承自DefaultHandler,并重写父类的5个方法

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) {
        // 根据当前节点名判断将内容添加到哪一个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) {
        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)
        }
    }

    override fun endDocument() {
    }
}

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

接着修改MainActivity中的代码

class MainActivity : AppCompatActivity() {

	 ...
	 private fun sendRequestWithOkHttp() {
		 thread {
			 try {
				 val client = OkHttpClient()
				 val request = Request.Builder()
				 	 // 指定访问的服务器地址是计算机本机
					 .url("http://10.0.2.2/get_data.xml")
					 .build()
				 val response = client.newCall(request).execute()
				 val responseData = response.body?.string()
				 if (responseData != null) {
				 	parseXMLWithSAX(responseData)
				 }
			 } catch (e: Exception) {
			 	 e.printStackTrace()
			 }
		 }
	 }
	 
	 ...
	 private fun parseXMLWithSAX(xmlData: String) {
		 try {
			 val factory = SAXParserFactory.newInstance()
			 val xmlReader = factory.newSAXParser().XMLReader
			 val handler = ContentHandler()
			 // 将ContentHandler的实例设置到XMLReader中
			 xmlReader.contentHandler = handler
			 // 开始执行解析
			 xmlReader.parse(InputSource(StringReader(xmlData)))
		 } catch (e: Exception) {
		 	 e.printStackTrace()
		 }
	 }
}

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

运行程序,点击“Send Request”按钮,然后查看Lodcat日志。日志内会和Pull解析结果一样

解析JSON格式数据

准备JSON格式的数据
继续在Apache\htdocs目录中新建一个get_data.json的文件,然后编辑这个文件,并加入如下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.json这个网址会出现下面页面
在这里插入图片描述

使用JSONObject

还是修改NetworkTest项目中得到MainActivity代码

class MainActivity : AppCompatActivity() {
	 ...
	 
	 private fun sendRequestWithOkHttp() {
		 thread {
			 try {
				 val client = OkHttpClient()
				 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()
				 if (responseData != null) {
				 	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请求的地址改成http://10.0.2.2/get_data.json。然后在得到服务器返回的数据后调用parseJSONWithJSONObject()方法来解析数据
由于在服务器中定义的是一个JSON数组,因此这里首先将服务器返回的数据传入一个JSONArray对象中
然后循环遍历这个JSONArray,从中取出的每一个元素都是一个JSONObject对象,每个JSONObject对象中又会包含id、name和version这些数据
接下来只需要调用getString()方法将这些数据取出,并打印出来即可

运行程序,,并点击“Send Request”按钮,打开Lodcat日志,日志内容如下
在这里插入图片描述

使用GSON

如果感觉使用JSONObject来解析JSON数据非常简单,而使用GSON会更简单
不过使用GSON需要先添加GSON库的依赖,编辑app/build.gradle文件,在dependencies闭包中添加如下内容:

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

GOSN简单之处在于可以将一段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 gson = Gson()
val typeOf = object : TypeToken<List<Person>>() {}.type
val people = gson.fromJson<List<Person>>(jsonData, typeOf)

在NetworkTest项目中新增一个App类,加入id、name和version这3个字段

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

然后修改MainActivity中的代码

class MainActivity : AppCompatActivity() {
	 ...
		 private fun sendRequestWithOkHttp() {
			 thread {
				 try {
					 val client = OkHttpClient()
					 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()
					 if (responseData != null) {
					 	parseJSONWithGSON(responseData)
				 }
			 } catch (e: Exception) {
			 	e.printStackTrace()
			 }
		 }
	 }
	 
	 ...
	 private fun parseJSONWithGSON(jsonData: String) {
		 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}")
		 }
	 }
}

运行程序,点击“Send Request”按钮,查看Logcat中的打印日志,日志内容会JSONObject内容一样的

最好用的网络库:Retrofit

Retrofit的用法的设计
首先可以配置好一个根路径,然后在指定服务器接口地址时只需要使用相对路径即可,这样就不用每次都指定完整的URL地址了
另外,Retrofit允许对服务器接口进行归类,将功能同属一类的服务器接口定义到同一个接口文件当中,从而让代码结构变得更加合理
最后,也完全不用关心网络通信的细节,只需要在接口文件中声明一系列方法和返回值,然后通过注解的方式指定该方法对应哪个服务器接口,以及需要提供哪些参数
当在程序中调用该方法时,Retrofit会自动向对应的服务器接口发起请求,并将响应的数据解析成返回值声明的类型。这就使得可以用更加面向对象的思维来进行网络操作

Retrofit的基本用法

新一个RetrofitTest项目

要想使用Retrofit,需要先在项目中添加必要的依赖库
在app/build.gradle文件中的dependencies闭包添加如下内容:

implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation 'com.squareup.retrofit2:converter-gson:2.6.1'

第一条依赖会自动将Retrofit、OkHttp和Okio这几个库一起下载
第二条依赖是一个Retrofit的转换库,它是借助GSON来解析JSON数据的,所以会自动将GSON库一起下载下来

由于Retrofit会借助GSON将JSON数据转换成对象,因此需要先新增一个App类,并加入id、name和version这3个字段

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

接下来,根据服务器接口的功能进行归类,创建不同种类的接口文件,并在其中定义对应具体服务器接口的方法
不过这里Apache服务器上其实只有一个获取JSON数据的接口,因此这里只需要定义一个接口文件,并包含一个方法即可
通常Retrofit的接口文件建议以具体的功能种类名开头,并以Service结尾,这是一种比较好的命名习惯
新建AppService接口

interface AppService {
	 @GET("get_data.json")
	 fun getAppData(): Call<List<App>>
}

这里在getAppData()方法上面使用了一个@GET注解,表示当调用getAppData()方法时Retrofit会发起一条GET请求,请求的地址就是在@GET注解中传入的具体参数
注意,这里只需要传入请求地址的相对路径即可,根路径会在稍后设置
getAppData()方法的返回值必须声明成Retrofit中内置的Call类型,并通过泛型来指定服务器响应的数据应该转换成什么对象
由于服务器响应的是一个包含App数据的JSON数组,因此这里将泛型声明成List<App>

接着修改activity_main.xml中的代码,添加一个 名为:Get App Data的按钮,用来处理具体的网络请求逻辑
然后修改MainActivity中的代码

class MainActivity : AppCompatActivity() {

	 override fun onCreate(savedInstanceState: Bundle?) {
		 super.onCreate(savedInstanceState)
		 setContentView(R.layout.activity_main)
		 getAppDataBtn.setOnClickListener {
			 val retrofit = Retrofit.Builder()
				 .baseUrl("http://10.0.2.2/")
				 .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()
					 if (list != null) {
						 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()
				 }
			 })
		 }
	 }
}

在“Get App Data”按钮的点击事件当中,首先使用了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,将其中的数据打印出来即可

由于这里使用的服务器接口仍然是HTTP,因此还要按照上面步骤来进行网络安全配置才行
先从前面NetworkTest项目中复制network_config.xml文件到RetrofitTest项目的res\xml目录当中,然后依然在改AndroidManifest.xml中启动上面的配置文件,在application闭包中添加下面一句话

android:networkSecurityConfig="@xml/network_config

运行程序、查看Lodcat日志,日志内容如下
在这里插入图片描述

处理复杂的接口地址类型

前面通过示例程序向一个非常简单的服务器接口地址发送请求:http://10.0.2.2/get_data.json,不过在真实的开发环境当中,服务器所提供的接口地址可能会是千变万化的,那么看一下如何使用Retrofit来应对这些千变万化的情况

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

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

1

先看一下最简单接口地址,这种接口地址是静态的,比如前面的:

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

使用Retrofit时就可以写成下面这样:

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

2

接口地址中的部分内容会动态变化,比如如下的接口地址:

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

对应到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参数的值替换到占位符的位置,从而组成一个合法的请求地址

3

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

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

这是一种标准的带参数GET请求的格式。接口地址的最后使用问号来连接参数部分,每个参数都是一个使用等号连接的键值对,多个参数之间使用“&”符号进行分隔,在上述地址中,服务器要求传入user和token这两个参数的值
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请求的格式将这两个参数构建到请求地址当中

4

HTTP并不是只有GET请求这一种类型,而是有很多种,其中比较常用的有GET、POST、PUT、PATCH、DELETE这几种
它们之间的分工也很明确,简单概括的话,GET请求用于从服务器获取数据,POST请求用于向服务器提交数据,PUT和PATCH请求用于修改服务器上的数据,DELETE请求用于删除服务器上的数据

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

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
由于POST、PUT 、PATCH、DELETE这几种请求类型与GET请求不同,它们更多是用于操作服务器上的数据,而不是获取服务器上的数据,所以通常它们对于服务器响应的数据并不关心
这个时候就可以使用ResponseBody,表示Retrofit能够接收任意类型的响应数据,并且不会对响应数据进行解析

5

需要向服务器提交数据,例如如下的接口地址:

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类型的请求提交数据

6

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

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

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

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

当发起网络请求的时候,Retrofit就会自动将参数中传入的值设置到User-Agent和CacheControl这两个header当中,从而实现了指定header值的功能

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值