Retrofit:从入门到最佳实践

1,460 阅读9分钟

前言

Retrofit 同样是由 Square 公司开发的网络库。但它和 OkHttp 的定位不同,OkHttp 侧重于实现底层的 HTTP 通信,而 Retrofit 则侧重于上层接口的封装。

实际上,Retrofit 就是在 OkHttp 的基础上开发的一个应用层网络库。它能让我们以一种更加面向对象和声明式的方式进行网络操作,大大提高了开发效率和代码可读性。

Retrofit 的项目地址:retrofit on github

Retrofit 的核心设计思想

我们先来看看 Retrofit 的设计思想,其设计主要基于以下三点:

  1. 同一个应用发起的网络请求,绝大多数都指向同一个服务器域名。

  2. 服务器提供的接口通常可按照功能模块进行划分。

  3. 开发者习惯于像调用一个方法那样,直接调用服务器接口、传递参数并获取返回值。并不想关心底层的通信细节。

Retrofit 的用法正好对应了以上三点:

  1. 我们配置一个 Base Url 根路径。这样后续在指定接口地址时,只需使用相对路径。

  2. 我们可以将功能相近的接口定义到同一个接口文件中,通过注解来描述 HTTP 请求的行为。

  3. 我们只需像调用普通方法一样调用这些接口方法,Retrofit 会自动发送网络请求,并将服务器响应的数据解析成我们指定的类型。

基础用法:发起网络请求

我们通过一个具体的例子来演示一下 Retrofit 的基本用法。

使用回调方式

首先新建一个名为 RetrofitTest 的 Empty Views Activity 项目。在 app/build.gradle.kts 文件中,添加对应的库依赖。如下所示:

dependencies {
    // Retrofit 核心库(它会自动引入其依赖的 OkHttp 和 Okio 库)
    implementation("com.squareup.retrofit2:retrofit:2.6.1")
    // Retrofit 的 GSON 转换器,用于 JSON 数据的序列化和反序列化
    implementation("com.squareup.retrofit2:converter-gson:2.6.1")
}

假设本地服务器接口 (http://10.0.2.2/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" }
]

由于 Retrofit 会借助 GSON 将上面的 JSON 数据转为对象,所以我们需要定义一个对应的数据类:

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

接着,定义一个 AppService 接口,在其中定义网络请求接口方法。代码如下:

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

Retrofit 的接口文件名建议以功能开头,Service 结尾,比如 UserService。

代码中的 @GET 注解表示调用 getAppData() 方法时,会发送 GET 请求。注解的参数 get_data.json 为请求路径 ,这只是个相对于 Base Url 根路径的路径。

在使用回调方式时,方法的返回值必须声明为 Retrofit 内置的 Call<T> 类型,我们通过泛型 T 来指定服务器响应数据转换后的类型。

定义好接口后,我们就可以使用了。首先在布局中放置一个按钮,用于触发请求,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/getAppDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Get App Data" />

</LinearLayout>

然后,在 MainActivity 中实现按钮的点击逻辑。代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    // 构建 Retrofit 对象
    private val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl("http://10.0.2.2/") // 设置请求路径的根路径
            .addConverterFactory(GsonConverterFactory.create())  // 设置解析数据时,使用的转换库
            .build()
    }

    // 创建 AppService 接口的动态代理对象,用于调用接口中的方法
    private val appService: AppService by lazy {
        retrofit.create(AppService::class.java)
    }

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

        binding.getAppDataBtn.setOnClickListener {

            // 调用 enqueue() 方法,进行异步的网络请求
            appService.getAppData().enqueue(object : Callback<List<App>> {
                // 请求成功后回调
                override fun onResponse(
                    call: Call<List<App>>,
                    response: Response<List<App>>,
                ) {
                    // HTTP 状态码是否在 200-299 之间
                    if (response.isSuccessful) {
                        val appList = response.body()
                        appList?.let { list ->
                            list.forEach { app ->
                                Log.d(
                                    "MainActivity",
                                    "id is ${app.id},name is ${app.name},version is ${app.version}"
                                )
                            }
                        }
                    } else {
                        // 处理服务器返回的错误
                        Log.e("MainActivity", "Response failed with code: ${response.code()}")
                    }
                }

                // 在网络异常或数据解析异常时回调
                override fun onFailure(call: Call<List<App>>, t: Throwable) {
                    Log.e("MainActivity", "Request failed", t)
                    t.printStackTrace()
                }
            })

        }
    }
}

然后,别忘了声明网络权限,并且我们使用明文的 http 进行网络请求,需要添加网络安全配置。

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

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>

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

    <application
        ...
        android:networkSecurityConfig="@xml/network_security_config">
        ...
    </application>

</manifest>

res/xml 目录中创建 network_security_config.xml 文件,其内容如下:

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

现在运行应用并点击按钮,Logcat 日志信息如下:

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

可以看到,服务器响应的数据成功被解析出来了。

使用协程方式

虽然使用回调的方式能够工作,但存在多个互相依赖的请求时,很容易形成“回调地狱”。现在,我们更多会使用协程来完成,Retrofit 对协程提供了原生支持。

首先,我们在 app/build.gradle.kts 文件中添加协程的依赖:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}

然后修改之前的 AppService 接口。在方法声明前加上 suspend 关键字,将返回值类型指定为我们期望的类型。

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

MainActivity 中使用协程来调用这个方法:

binding.getAppDataBtn.setOnClickListener {
    // 启动一个协程
    lifecycleScope.launch {
        try {
            val appList = appService.getAppData()
            // 请求成功,获得解析后的数据
            for (app in appList) {
                Log.d("MainActivity", "[Coroutine] id is ${app.id}, name is ${app.name}")
            }
        } catch (e: Exception) {
            // 捕获所有的异常(网络异常、解析失败、服务器错误)
            Log.e("MainActivity", "[Coroutine] Request failed", e)
            e.printStackTrace()
        }
    }
}

可以看到,使用协程后,代码非常简洁,逻辑非常清晰。

进阶用法:动态参数与请求类型

之前的接口地址是静态的,不会改变。而在真实项目中,接口地址中的部分内容可能是动态变化的,请求也需要携带各种参数。

动态替换 URL 路径:@Path

就比如根据文章 id 获取文章内容的接口地址可能为:https://api.example.com/articles/<id>,其中 <id> 是占位符,代表文章 id。这种接口地址对应到 Retrofit 中,只需这么写:

data class Article(val id: String, val title: String, val content: String)

interface ArticleService {
    @GET("articles/{id}")
    suspend fun getArticle(@Path("id") articleId: String): Article
}

在接口地址中,我们使用了 {id} 的占位符。然后给 getArticle() 方法定义了一个 articleId 参数,并使用了 @Path("id") 注解将其与路径中的 {id} 占位符进行绑定。这样当调用 getArticle() 方法时,Retrofit 会自动将 articleId 参数的值传给 {id} 占位符,从而生成一个请求地址。

添加查询参数:@Query

另外,我们可能会在请求路径后通过查询参数来传递信息,比如 GET https://api.example.com/search?q=<keyword>&sort_by=<date>。接口地址中 ? 的后面就是查询参数,每个参数都是键值对的形式,参数之间使用 & 分隔。

虽然我们可以使用 @Path 拼接字符串来完成,但这稍微有点繁琐还容易出错。Retrofit 针对于这种情况,提供了@Query 注解。

interface SearchService {
    @GET("search")
    suspend fun searchArticles(
        @Query("q") keyword: String,
        @Query("sort_by") sortBy: String = "date" // 还可以提供默认值
    ): List<Article>
}

我们使用 @Query 注解来声明 keywordsortBy 参数。这样当调用该方法时,会自动将这两个参数填入到请求地址中。

HTTP 请求类型

我们看完了最常见的 GET 请求,但 HTTP 的请求方法有很多,常用的有 GETPOSTPUTPATCHDELETE

  • GET 用于从服务器中获取数据。

  • POST 用于向服务器提交数据。

  • PUTPATCH 用于修改服务器上的数据,不过前者用于完整替换,后者用于部分更新。

  • DELETE 用于删除服务器中的数据。

Retrofit 对以上的 HTTP 请求类型都做了支持,分别对应 @GET@POST@PUT@PATCH@DELETE 注解。

删除数据:@DELETE

删除操作需要在路径中指定要删除的 id:

interface ArticleService {
    @DELETE("articles/{id}")
    suspend fun deleteArticle(@Path("id") articleId: String): Response<ResponseBody>
}

返回值类型我们指定为了 Response<ResponseBody>,这是因为删除操作通常不关心服务器返回的具体数据内容,只关心是否成功。而 ResponseBody 是一个通用的类型,能够接收任意类型的响应体。使用它,能够避免 Retrofit 不必要的 JSON 数据解析。

另外,将 ResponseBody 包装在 Response<> 中,能让我们获取完整的响应信息(如 HTTP 状态码)。

提交数据:@POST 与 @Body

如果要向服务器提交数据,我们需要将数据放到 HTTP 请求的请求体(Request Body)中,通过 @Body 注解来指定哪个对象作为请求体。

data class NewArticle(val title: String, val content: String, val authorId: String)

interface ArticleService {
    // 创建成功后会返回新创建的文章对象
    @POST("articles")
    suspend fun createArticle(@Body article: NewArticle): Response<Article>
}

当调用 createArticle() 方法时,会将参数传入的 NewArticle 对象转为 JSON 格式的字符串,并放入到 HTTP 请求的请求体中发送给服务器。

添加请求头:@Headers 与 @Header

最后,服务器接口可能会要求在 HTTP 请求的请求头(Headers)中包含特定信息。我们可以通过 @Headers 注解在方法上声明:

interface DataService {
    @Headers(
        "User-Agent: MyCoolAndroidApp/1.0",
        "Cache-Control: max-age=60"
    )
    @GET("data.json")
    suspend fun getData(): Data
}

这种写法只适用于请求头的值是固定的情况。如果要动态指定请求头的值,需要用到 @Header 注解,将其与方法参数进行绑定。

interface DataService {
    @GET("user/profile")
    suspend fun getUserProfile(
        @Header("Authorization") token: String,
        @Header("Accept-Language") lang: String
    ): UserProfile
}

当调用 getUserProfile() 方法时,会自动将参数的值添加到请求头中。

创建统一的 Service 入口

在前面的例子中,我们直接在 Activity 中创建了 Retrofit 实例。在真实项目中,我们并不会这样做,因为:

  1. 每个 Retrofit 实例,尤其是它内部的 OkHttpClient,都管理着自己的连接池和线程池。创建多个实例将无法复用这些资源,会导致性能下降。

  2. 如果需要修改服务器根路径,或是为所有请求统一添加请求头,你必须要在项目中找到并修改每一个 Retrofit 实例。

为此,我们需要一个统一、全局的入口来创建和管理我们的网络请求服务。这里我们通过一个 ServiceCreator 单例对象来实现:

首先,需要添加 OkHttp 的日志拦截器依赖:implementation("com.squareup.okhttp3:logging-interceptor:4.9.3")

object ServiceCreator {
    private const val BASE_URL = "http://10.0.2.2/"

    // 创建一个 OkHttpClient 实例,并配置日志拦截器和超时
    private val httpClient = OkHttpClient.Builder()
        .addInterceptor(
            HttpLoggingInterceptor().apply {
                // 打印请求体和响应体
                level = HttpLoggingInterceptor.Level.BODY
            }
        )
        .connectTimeout(15, TimeUnit.SECONDS) // 连接超时
        .readTimeout(20, TimeUnit.SECONDS)    // 读取超时
        .build()

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(httpClient) // 设置自定义的 OkHttpClient
        .addConverterFactory(GsonConverterFactory.create())
        .build()

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

    // 借助泛型实化,我们可以进一步优化上述代码
    // 因为泛型 T 的类型信息在运行时依旧保留,在函数内部,我们可以直接访问 T 的 Class 对象 (T::class.java),无需手动传入 Class 对象。
    inline fun <reified T> create(): T = create(T::class.java)
}

现在,获取一个 Service 接口的动态代理对象只需这样:

val appService = ServiceCreator.create(AppService::class.java)
// 或者
val appService = ServiceCreator.create<AppService>()