Kotlin第五章: android网络编程
Kotlin第五章:使用Okhttp与Retrofit进行网络编程,实现自定义拦截器对请求和响应进行处理
1. Android网络编程
- OkHttp
OkHttp是一个高效的HTTP客户端,它的横空出世,让其他的网络请求框架都变得黯然失色。
- Retrofit
Retrofit是一个基于OkHttp的RESTful网络请求框架,功能强大、简洁易用及高可拓展性。Retrofit说起来相当简单,简单到源码只有37个文件,其中22个文件是注解,还都和HTTP有关,真正暴露给用户的类并不多。
- 封装
Retrofit其实就是一个基于OKHttp的网络请求框架的封装。使请求接口和数据解析更加简洁明了。为什么需要封装呢?说白了,就是为了解耦,为了方便日后切换到不同框架实现,而无需到处修改调用的地方。
比如我们项目当中经常会用到一些之前比较流行的的网络框架,后期这个框架停止维护或者功能无法满足业务需求,我们想切换到新的框架,可能调用的地方会非常多,如果不做封装,直接切换的话,改动量将非常非常大,而且还很有可能会有遗漏,风险度非常高。
OkHttp是一个HTTP骑牛引擎 ,负责任何底层网络操作,缓存,请求和响应操作等
Retrofit是在OkHttp之上构建的高级REST抽象。使请求接口和数据解析更加简洁明了
2. OkHttp
1. 出现背景
在okhttp出现以前,android上发起网络请求要么使用系统自带的HttpClient
、HttpURLConnection
、要么使用google开源的Volley
、要么使用第三方开源的AsyncHttpClient
, 随着互联网的发展,APP的业务发展也越来越复杂,APP的网络请求数量急剧增加,但是上述的网络请求框架均存在难以性能和并发数量的限制
OkHttp
流行得益于它的良好的架构设计,强大的拦截器(intercepts)
使得操纵网络十分方便;OkHttp现在已经得到Google官方认可,大量的app都采用OkHttp做网络请求,其源码详见OkHttp Github。
也得益于强大的生态,大量的流行库都以OkHttp
作为底层网络框架或提供支持,比如Retrofit
、Glide
、Fresco
、Moshi
、Picasso
等。
当OKhttp面世之后,瞬间成为各个公司的开发者的新宠,常年霸占github star榜单,okhttp可以说是为高效而生,迎合了互联网高速发展的需要
2. 特点
1.同时支持HTTP1.1与支持HTTP2.0;
2.同时支持同步与异步请求;
3.同时具备HTTP与WebSocket功能;
4.拥有自动维护的socket连接池,减少握手次数;
5.拥有队列线程池,轻松写并发;
6.拥有Interceptors(拦截器),轻松处理请求与响应额外需求(例:请求失败重试、响应内容重定向等等);
3. 使用
1. 添加网络访问权限
在AndroidManifest.xml 中添加网络访问权限
<uses-permission android:name="android.permission.INTERNET" />
2. 添加相关的依赖
在
app/build.gradle
的dependencies
下添加依赖
implementation("com.squareup.okhttp3:okhttp:4.9.0")
// 网络请求日志打印
implementation("com.squareup.okhttp3:logging-interceptor:4.9.0")
3. Get请求
1. 同步Get请求
创建一个
OkHttpDemoTest.kt
文件,采用object
关键字使本类在整个程序运行期间只有一个示例,相当于是单例模式,然后安卓规定网络请求不能在主线程,所以我们的get请求需要新起一个线程运行
- 创建kt文件
package com.example.myapplication.http
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
// 程序运行的时候只有一份,相当于是单例模式
// 可以直接使用 OkHttpDemoTest1.get()调用
object OkHttpDemoTest1{
private val client = OkHttpClient.Builder()
.connectTimeout(10,TimeUnit.SECONDS) // 连接超时时间
.readTimeout(10,TimeUnit.SECONDS) //读取超时
.writeTimeout(10,TimeUnit.SECONDS) // 请求超时
.build()
fun get(url: String){
Thread(Runnable {
val request = Request.Builder()
.url(url)
.build()
// 构造请求对象
val call = client.newCall(request)
// 发起同步请求
val response = call.execute()
// 获取请求的返回信息
val body = response.body?.string()
// Log.e是安卓自带的一个打印日志的方法,日志信息会打印到logcat里
Log.e("OkHttp $this","get response: $body")
}).start()
}
}
- 在
MainActivity
中调用
package com.example.myapplication
import android.os.Bundle
import com.example.myapplication.databinding.ActivityMainBinding
import com.example.myapplication.http.OkHttpDemoTest1
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 中间代码省略
val url = "http://123.56.232.18:8080/serverdemo/user/query?userId=1"
OkHttpDemoTest1.get(url)
}
}
- 如果创建应用的时候是高版本的安卓的话是不支持http请求的,必须要使用https,在
AndroidManifest.xml
的application
标签中添加相应开关
<application
android:usesCleartextTraffic="true">
</application>
- 运行模拟器,运行应用
2. 异步Get请求
异步请求是没有返回值的,需要实现callback方法来操作
- 创建方法
// 异步请求
fun getAsync(url: String){
val request = Request.Builder()
.url(url)
.build()
// 构造请求对象
val call = client.newCall(request)
// 使用enqueue 发起异步请求
val response = call.enqueue(object: Callback{
// 失败的话会回调此方法
override fun onFailure(call: Call, e: IOException) {
Log.e("OkHttp Get Async $this","")
}
// 接口调用成功之后调用此方法
override fun onResponse(call: Call, response: Response) {
// 获取请求的返回信息
val body = response.body?.string()
Log.e("OkHttp Get Async $this","get response: $body")
}
})
}
- 在
MainActivity
的onViewCreated
中测试
val url = "http://123.56.232.18:8080/serverdemo/user/query?userId=1"
//OkHttpDemoTest1.get(url)
OkHttpDemoTest1.getAsync(url)
- 当然也可以将获取call对象的方法封装一下
fun getClientCall(url: String): Call{
val request = Request.Builder()
.url(url)
.build()
// 构造请求对象
return client.newCall(request)
}
3. Get请求总结
异步请求的步骤和同步请求类似,只是调用了
Call
的enqueue
方法异步请求,结果通过回调Callback
的onResponse
方法及onFailure
方法处理。看了两种不同的Get请求,基本流程都是先创建一个
OkHttpClient
对象,然后通过Request.Builder()
创建一个Request
对象,OkHttpClient
对象调用newCall()
并传入Request
对象就能获得一个Call
对象。而同步和异步不同的地方在于
execute()
和enqueue()
方法的调用,调用
execute()
为同步请求并返回Response
对象;调用
enqueue()
方法测试通过callback的形式返回Response
对象。注意:无论是同步还是异步请求,接收到
Response
对象时均在子线程中,onFailure
,onResponse
的回调是在子线程中的,我们需要切换到主线程才能操作UI控件
4. Post请求
POST请求与GET请求不同的地方在于
Request.Builder
的post()
方法,post()
方法需要一个RequestBody
的对象作为参数
1. 同步Post请求
- 书写请求
fun post(url: String): Unit{
val body = FormBody.Builder()
.add("userId","123")
.add("key","value")
.add("tagId","71")
.build()
val request = Request.Builder()
.url(url)
.post(body)
.build()
val call = client.newCall(request)
// 因为传入的是一个函数,直接简略写法写到大括号里完事
Thread {
val response = call.execute()
Log.e("OkHttp Post formData ${body.toString()}","")
Log.e("OkHttp Post response","response $response")
}.start()
}
MainActivity
调用
val baseUrl = "http://123.56.232.18:8080/serverdemo"
OkHttpDemoTest1.post("$baseUrl/tag/toggleTagFollow")
2. 异步Post表单提交
与Get请求一样,只需要将execute()换成enqueue()即可
- 书写方法
fun postAsyncForm(url: String){
val body = FormBody.Builder()
.add("userId","123")
.add("key","value")
.add("tagId","71")
.build()
val request = Request.Builder()
.url(url)
.post(body)
.build()
val call = client.newCall(request)
call.enqueue(object :Callback{
override fun onFailure(call: Call, e: IOException) {
Log.e("Post异步提交表单数据失败")
}
override fun onResponse(call: Call, response: Response) {
Log.i("Post异步提交表单成功","response $response")
}
})
}
- 调用
val baseUrl = "http://123.56.232.18:8080/serverdemo"
OkHttpDemoTest1.postAsyncForm("$baseUrl/tag/toggleTagFollow")
3. Post请求文件上传
读取存储卡的文件需要在清单文件中声明权限
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
/**
* 异步post上传文件
* 在android6.0以后,读取外部存储卡的文件需要动态申请权限
* 即时声明了权限也需要动态授权的
*/
fun postAsyncMultipart(context: Context,url: String){
val file = File(Environment.getExternalStorageDirectory(),"1.png")
if(!file.exists()){
Toast.makeText(context, "文件不存在", Toast.LENGTH_SHORT).show()
return
}
val multipartBody = MultipartBody.Builder()
.addFormDataPart("key", "value")
.addFormDataPart(
"file", "file.png",
RequestBody.create("application/octet-stream".toMediaType(), file)
)
.build()
val request = Request.Builder().url(url)
.post(multipartBody)
.build()
val call = client.newCall(request)
call.enqueue(object :Callback{
override fun onFailure(call: Call, e: IOException) {
Log.e("异步post请求上传文件失败","$e")
}
override fun onResponse(call: Call, response: Response) {
Log.e("异步post请求上传文件成功","response $response")
}
})
}
4. Post提交字符串
fun postAsyncString(url: String){
val jsonObject = JSONObject()
jsonObject.put("'key1","value1")
jsonObject.put("'key2","value2")
// 这里如果想要提交纯文本的话需要指定的请求头为 text/plain;charset=utf-8
val body = RequestBody.create(
"application/json;charset=utf-8".toMediaType(),
jsonObject.toString()
)
val request = Request.Builder().url(url)
.post(body)
.build()
val call = client.newCall(request)
call.enqueue(object :Callback{
override fun onFailure(call: Call, e: IOException) {
Log.e("OkHttp Post发送json参数失败","错误信息 $e")
}
override fun onResponse(call: Call, response: Response) {
Log.e("OkHttp Post发送json参数成功","返回结果 $response")
}
})
}
5. 拦截器
拦截器是OkHttp当中的一个比较强大的机制,可以监视,重写和重试调用请求.
本次例子书写一个拦截请求记录并日志信息输出的拦截器
- 创建拦截器
package com.example.myapplication.interceptor
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.ResponseBody
import okio.Buffer
// 自定义拦截器需要实现 okhttp中的接口
class LogInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// 获取请求执行时的时间戳
val timeStart = System.nanoTime()
// 获取调用链中的request对象
val request = chain.request()
var buffer = Buffer()
request.body?.writeTo(buffer)
val requestBodyStr = buffer.readUtf8()
Log.e("OkHttp",
String.format("Sending request %s with params 5s",request.url,requestBodyStr))
val response = chain.proceed(request)
// response 只能读取一次,后续再次读取body的时候就会报错
val responseData = response.body?.string()?: "response body null"
// 构建新的responseBody
val responseBody = ResponseBody.create(response.body?.contentType(), responseData)
val endTime = System.nanoTime()
Log.e("OkHttp","接口请求地址为 ${request.url},接口返回的数据是 $responseData,用时${(endTime - timeStart) / 1e6}ms ")
// 返回新的 response
return response.newBuilder().body(responseBody).build()
}
}
- 使用拦截器
在上边创建client的时候添加上拦截器即可
private val client = OkHttpClient.Builder()
.connectTimeout(10,TimeUnit.SECONDS) // 连接超时时间
.readTimeout(10,TimeUnit.SECONDS) //读取超时
.writeTimeout(10,TimeUnit.SECONDS) // 请求超时
.addInterceptor(LogInterceptor())
.build()
- 优化日志输出
可以看到,上边的例子输出的日志中间有很多的无用信息,所以可以优化一下日志输出,将interceptor的级别设置为Body,这样输出的日志就会好看一点
// 将client对象提取到外边
private val client : OkHttpClient
// 初始化类的时候加载这个方法
init {
// 使用okhttp自带的拦截器
val httpLoggingInterceptor = HttpLoggingInterceptor()
// 设置拦截器级别为 Body
httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
// 给client添加 interceptor
client = OkHttpClient.Builder()
.connectTimeout(10,TimeUnit.SECONDS) // 连接超时时间
.readTimeout(10,TimeUnit.SECONDS) //读取超时
.writeTimeout(10,TimeUnit.SECONDS) // 请求超时
.addInterceptor(httpLoggingInterceptor)
.build()
}
3. 使用Gson
1. 添加依赖
添加在
app/build.gradle
中
dependencies{
implementation 'com.google.code.gson:gson:2.8.6'
}
2. 解析json到对象
这里类对象需要用
Account::class.java
而不是Account.class
package com.example.myapplication.http
import com.google.gson.Gson
class Account {
var uid: String = ""
var userName: String = "Freeman"
var password: String = "pwd"
var phone: String = "17663333333"
override fun toString(): String {
return "Account(uid='$uid', userName='$userName', password='$password', phone='$phone')"
}
}
fun main() {
val jsonStr = """
{
"uid": "123",
"userName": "test",
"password": "pwd",
"phone": "16666666666"
}
""".trimIndent()
var gson = Gson()
var fromJson = gson.fromJson<Account>(jsonStr, Account::class.java)
println(fromJson.toString())
}
3. 对象转Json
var toJson = gson.toJson(fromJson)
println(toJson)
4. 集合转json
val jsonArrayStr = """
[{
"uid": "123",
"userName": "test",
"password": "pwd",
"phone": "16666666666"
}]
""".trimIndent()
var fromJsonArray: List<Account> =
gson.fromJson(jsonArrayStr, object : TypeToken<List<Account>>(){}.type)
println(fromJsonArray)
5. 优化实体类
使用class 定义类的时候,类里边的字段需要有初始值,不是很方便使用,所以这个时候可以用
data class
,而且还不用写toString()方法
data class Account2(
val uid: String = "111",
val userName: String,
val password: String,
val phone: String
)
fun main(){
var fromJson2 = gson.fromJson(jsonStr, Account2::class.java)
println(fromJson2)
}
6. JsonToKotlinClass插件
File --> plugins–> JsonToKotlinClass插件下载,快捷键是 alt + K,或者右键找到 generate然后选择
kotlinm data class from JSON
{
"status": 200,
"message": "成功",
"data": {
"data": {
"id": 3117,
"userId": 160093269,
"name": "qvelychubby",
"avatar": "https://pipijoke.oss-cn-hangzhou.aliyuncs.com//ajsdfksjakfjasklfjkasfas_54757db023a4c2fE5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20200214204431.jpg",
"description": "更多android进阶课程请在慕课授索lovelychubby",
"likeCount": 985,
"topCommentCount": 200,
"followCount": 100,
"followerCount": 10,
"qqOpenId": "A8747C32A5D614281E65DA5B473D1F31",
"expires_time": 1640266383000,
"score": 1000,
"historyCount": 10,
"commentCount": 3,
"favoriteCount": 0,
"feedCount": 10,
"hasFollow": false
}
}
}
- 生成实体类
// 自动生成的是没有泛型的,这里可以直接添加上泛型
// 后续直接改泛型就行了,就不需要改实体类了
data class Result<T>(
val data: Data<T>,
val message: String,
val status: Int
)
data class Data<T>(
val data: T
)
data class UserInfo(
val avatar: String,
val commentCount: Int,
val description: String,
val expires_time: Long,
val favoriteCount: Int,
val feedCount: Int,
val followCount: Int,
val followerCount: Int,
val qqOpenId: String,
val hasFollow: Boolean,
val historyCount: Int,
val id: Int,
val likeCount: Int,
val name: String,
val score: Int,
val topCommentCount: Int,
val userId: Int
)
fun main(){
val responseJson = """ 上边的json """
var result = gson.fromJson<Result<UserInfo>>(responseStr, Result::class.java)
println(result)
}
4. Retrofit
Retrofit
是一个高质量高效率的HTTP请求库,是一个restful的请求库,和OkHttp
同样出自Square公司。Retrofit内部依赖于OkHttp,它将OKHttp底层的代码和细节都封装了起来,功能上做了更多的扩展,比如返回结果的自动解析,网络引擎的切换,拦截器…有了Retrofit之后对于一些请求我们就只需要一行代码或者一个注解、大大简化了网络请求的代码量。
1. 注解
etrofit注解驱动型上层网络请求框架,使用注解来简化请求,大体分为以下几类:
- 用于标注网络请求方式的注解
- 标记网络请求参数的注解
- 用于标记网络请求和响应格式的注解
interface ApiService{
@GET("user/query")
Call<User> queryUser(@Query("userId") String userId);
}
val mApi = retrofit.create(ApiService.class);
val response = mApi.queryUser("100086").execute()
1. 请求方法注解
序号 | 注解 | 说明 |
---|---|---|
1 | @GET | get请求 |
2 | @POST | post请求 |
3 | @PUT | put请求 |
4 | @DELETE | delete请求 |
5 | @PATCH | patch请求,该请求是对put请求的补充,用于更新局部资源 |
6 | @HEAD | head请求 |
7 | @OPTIONS | option请求 |
8 | @HTTP | 通用注解,可以替换以上所有的注解,其拥有三个属性:method,path,hasBody |
2. 请求头注解
注解 | 说明 |
---|---|
@Headers | 用于添加固定请求头,可以同时添加多个。通过该注解添加的请求头不会相互覆盖,而是共同存在 |
@Header | 作为方法的参数传入,用于添加不固定值的Header,该注解会更新已有的请求头 |
3. 请求参数注解
名称 | 说明 |
---|---|
@Body | 多用于post请求发送非表单数据,比如想要以post方式传递json格式数据 |
@Filed | 多用于post请求中表单字段,Filed和FieldMap需要FormUrlEncoded结合使用 |
@FiledMap | 和@Filed作用一致,用于不确定表单参数 |
@Part | 用于表单字段,Part和PartMap与Multipart注解结合使用,适合文件上传的情况 |
@PartMap | 用于表单字段,默认接受的类型是Map,可用于实现多文件上传 |
@Path | 用于url中的占位符 |
@Query | 用于Get中指定参数 |
@QueryMap | 和Query使用类似 |
@Url | 指定请求路径 |
4. 请求和响应格式注解
名称 | 说明 |
---|---|
@FormUrlEncoded | 表示请求发送编码表单数据,每个键值对需要使用@Field注解 |
@Multipart | 表示请求发送multipart数据,需要配合使用@Part |
@Streaming | 表示响应用字节流的形式返回.如果没使用该注解,默认会把数据全部载入到内存中.该注解在在下载大文件的特别有用 |
2. 使用
1. 引入依赖
在
app/build.gradle
中添加
// 引入 retrofit框架
implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
2. 初始化
- 创建工具类
baseUrl必须以 / 结尾,否则会报错
package com.example.myapplication.http
import com.example.myapplication.interceptor.LogInterceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitUtil {
// 创建 okhttp的client,可以在这里添加拦截器等
private val client = OkHttpClient.Builder()
.connectTimeout(60,TimeUnit.SECONDS)
.readTimeout(60,TimeUnit.SECONDS)
.writeTimeout(60,TimeUnit.SECONDS)
.addInterceptor(LogInterceptor())
.build()
// 使用okhttp自带的日志输出
private var clientWithHttpLoggingInterceptor = OkHttpClient()
init {
val httpLoggingInterceptor = HttpLoggingInterceptor()
httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
clientWithHttpLoggingInterceptor = OkHttpClient.Builder()
.connectTimeout(10,TimeUnit.SECONDS) // 连接超时时间
.readTimeout(10,TimeUnit.SECONDS) //读取超时
.writeTimeout(10,TimeUnit.SECONDS) // 请求超时
.addInterceptor(httpLoggingInterceptor)
.build()
}
// 创建出来 retrofit的对象
private var retrofit = Retrofit.Builder()
// 这里可以使用自己定义的client
.client(clientWithHttpLoggingInterceptor)
// 注意,这里必须以 / 结尾,否则会报错
.baseUrl("http://123.56.232.18:8080/serverdemo/")
.addConverterFactory(GsonConverterFactory.create())
.build()
fun <T> create(clazz: Class<T>): T{
return retrofit.create(clazz)
}
}
- 创建接口
package com.example.myapplication.http
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
interface ApiServiceKotlin {
@GET(value = "user/query")
fun queryUser(
@Query(value = "userId", encoded = true) userId: String): Call<Result<UserInfo>>
}
- 在
MainActivity
中使用
retrofit框架已经帮助我们完成了线程切换,在这里可以直接操作主线程了
var serviceKotlin: ApiServiceKotlin = RetrofitUtil.create(ApiServiceKotlin::class.java)
serviceKotlin.queryUser("123456")
.enqueue(object: Callback<Result<UserInfo>> {
override fun onResponse(
call: retrofit2.Call<Result<UserInfo>>,
response: retrofit2.Response<Result<UserInfo>>
) {
Log.e("Retrofit Success","'$response")
}
override fun onFailure(call: retrofit2.Call<Result<UserInfo>>, t: Throwable) {
Log.e("Retrofit",t.message?: "unknown reason")
}
})
3. 测试用例
- java格式
public interface ApiService {
@GET("user/query")
Call<User> queryUser(@Query("userId") String userId);
//使用@Headers添加多个请求头
@Headers({"User-Agent:android", "apikey:123456789", })
@GET("user/query")
Call<User> queryUser(@Query("userId") String userId);
// 多个参数的情况下可以使用@QueryMap,但只能用在GET请求上
@GET("user/query"")
Call<User> queryUser(@QueryMap Map<String, String> params);
/**
* 很多情况下,我们需要上传json格式的数据。当我们注册新用户的时候,因为用户注册时的数据相对较多
* 并可能以后会变化,这时候,服务端可能要求我们上传json格式的数据。此时就要@Body注解来实现。
* 直接传入实体,它会自行转化成Json, @Body只能用在POST请求上
*
* 字符串提交
*/
@POST("user/update")
Call<User> update(@Body News post);
/**
* 表单提交(键值对提交)
*/
@POST()
@FormUrlEncoded
Call<User> executePost(@FieldMap Map<String, Object> maps);
/**
* 表单上传文件(键值对提交、同时上传文件)
*/
@Multipart
@POST("upload/upload")
Call<> register(@Field("openId") String openId, @PartMap Map<String, MultipartBody.Part> map);
}
- kotlin 版本
interface ApiServiceKotlin {
@GET(value = "user/query")
fun queryUser(
@Query(value = "userId", encoded = true) userId: String): Call<Result<UserInfo>>
}
tring> params);
/**
* 很多情况下,我们需要上传json格式的数据。当我们注册新用户的时候,因为用户注册时的数据相对较多
* 并可能以后会变化,这时候,服务端可能要求我们上传json格式的数据。此时就要@Body注解来实现。
* 直接传入实体,它会自行转化成Json, @Body只能用在POST请求上
*
* 字符串提交
*/
@POST("user/update")
Call<User> update(@Body News post);
/**
* 表单提交(键值对提交)
*/
@POST()
@FormUrlEncoded
Call<User> executePost(@FieldMap Map<String, Object> maps);
/**
* 表单上传文件(键值对提交、同时上传文件)
*/
@Multipart
@POST("upload/upload")
Call<> register(@Field("openId") String openId, @PartMap Map<String, MultipartBody.Part> map);
}
- kotlin 版本
```kotlin
interface ApiServiceKotlin {
@GET(value = "user/query")
fun queryUser(
@Query(value = "userId", encoded = true) userId: String): Call<Result<UserInfo>>
}
更多推荐
所有评论(0)