11. 实战:ViewModel + Retrofit + Flow
Retrofit 是 Android 里事实标准的 HTTP 客户端(替代手写 HttpURLConnection 或老旧的 Volley)。本章基于最新稳定版 Retrofit 3.0.0(2025-05 发布,底层升到 OkHttp 4.12、用 Kotlin 重写)讲解;suspend 原生支持从 2.6 起就有,3.x 继续保留并清理了错误用法,配合协程 + ViewModel + StateFlow 就是现在最主流的网络层写法。本章先给一个完整可跑的骨架,再按使用频率把 Retrofit 的官方 API 全列一遍,最后专门讲多域名这种真实项目里几乎必踩的场景。
传统做法常把网络请求直接写在 Activity 里;现代 Android 把它拆开——Activity/Fragment 只管界面、ViewModel 管状态与生命周期、Repository 管数据与线程、Retrofit 接口只定义 API。下面所有代码都严格按这个边界写。
一、完整骨架:拉取用户并刷新 UI
// ① 数据契约(普通 data class,由 JSON 自动反序列化)
data class User(val id: String, val name: String, val avatar: String)
// ② 网络层:Retrofit 接口(suspend 函数,retrofit2 原生支持协程)
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): User
}
// ③ Repository:切线程 + 异常兜底(这里不碰 UI,也不持有 Context)
class UserRepository(private val api: UserApi) {
fun userStream(id: String): Flow<User> = flow {
emit(api.getUser(id)) // 在调用方上下文执行
}
.flowOn(Dispatchers.IO) // 网络请求放 IO
.catch { e ->
Log.e("Repo", e.message ?: "")
emit(User.EMPTY) // 兜底值,UI 据此显示错误
}
}
// ④ ViewModel:暴露 StateFlow 给 UI,viewModelScope 自动绑定生命周期
class UserViewModel(private val repo: UserRepository) : ViewModel() {
private val _ui = MutableStateFlow<UiState>(UiState.Loading)
val ui: StateFlow<UiState> = _ui.asStateFlow()
fun load(id: String) = viewModelScope.launch {
_ui.value = UiState.Loading
repo.userStream(id).collect { user ->
_ui.value = if (user == User.EMPTY) UiState.Error("加载失败")
else UiState.Success(user)
}
}
}
// ⑤ UI(Compose)收集,绝不阻塞主线程
@Composable
fun UserScreen(vm: UserViewModel, id: String) {
val state by vm.ui.collectAsStateWithLifecycle()
when (state) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> Text((state as UiState.Success).user.name)
is UiState.Error -> Text((state as UiState.Error).msg)
}
LaunchedEffect(id) { vm.load(id) }
}
边界:Repository 里用 flowOn(Dispatchers.IO) 把网络移出主线程;不要在 Retrofit 接口函数上加 @UiThread,也不要在 Activity 里直接 runBlocking 等网络结果——会卡死主线程 ANR。
二、Retrofit 2.x 与 3.x 的关键差异
先说清它和 2.x 到底改了什么,免得你拿老教程对不上号(下面结论以官方 release notes 为准,网上不少博客把版本号写错了):
| 维度 | Retrofit 2.x | Retrofit 3.x |
|---|---|---|
| 包坐标 groupId | com.squareup.retrofit2(3.x 只把版本号升到 3.0.0,groupId 不变;网上写的 retrofit3 是错的) | |
| 底层 OkHttp | 3.14(Java) | 4.12(Kotlin 重写,带来传递性 Kotlin 依赖) |
| 协程 suspend | 2.6+ 原生支持 | 继续支持,并清理了错误用法 |
| 向后兼容 | — | 与 2.x 二进制兼容:老的 Call/Response/create 代码不用改也能跑 |
| 转换器 | Gson/Moshi 等需第三方 | 新增官方首发 converter-kotlinx-serialization、converter-jaxb3;转换器支持 withStreaming() 流式序列化 |
| 返回类型 | — | 新增支持 Unit(丢弃响应体,适合 HEAD) |
| RxJava 适配器 | 支持背压 | 移除 RxJava 2/3 适配器背压支持 |
3.x 唯一的「破坏性」改动(最容易被坑):Retrofit 3.0 会在初始化时直接拒绝「suspend fun 却返回 Call<T>」这种写法。2.x 只是不推荐,3.x 直接报错。正确写法是让 suspend 直接返回实体类型:
// ❌ 3.x 会直接拒绝(eagerly rejected)
@GET("users")
suspend fun getUsers(): Call<List<User>>
// ✅ 3.x 推荐:suspend 直接返回实体
@GET("users")
suspend fun getUsers(): List<User>
一句话:3.x 里协程接口就两种正确形态——① suspend fun 返回实体(最常用);② 非协程的 fun ...(): Call<T>(自己 enqueue)。别把两者混在一起。
这是很多博客的误解:从 2.6 起,suspend 接口在非 2xx 时就已经抛 HttpException,并不是 3.x 才新增。所以后面 2.x / 3.x 的错误处理写法完全一致,不用改。
三、Retrofit API 全家福(按使用频率)
下面把 Retrofit 官方提供的所有注解、返回类型与构建 API按真实开发频率分层列出。先记高频,低频用到再查。
| 频率 | API | 作用 | 典型场景 |
|---|---|---|---|
| 🔥 极高 | @GET / @POST | 声明请求方法与相对路径 | 几乎所有接口 |
| 🔥 极高 | @Path | 替换 URL 路径中的 {占位} | /users/{id} |
| 🔥 极高 | @Query | 拼接 URL 查询参数 ?k=v | 分页、筛选、搜索词 |
| 🔥 极高 | suspend fun | 协程式接口(2.6+/3.x 原生;3.x 禁止返回 Call<T>) | 所有挂起网络请求 |
| 🔥 极高 | @Body | 把对象序列化成请求体(JSON) | POST/PUT 提交表单对象 |
| 🔥 极高 | Retrofit.Builder | 构建 Retrofit 实例 | 初始化网络层 |
| 🔥 极高 | baseUrl(...) | 设置基础地址 | 必填,必须以 / 结尾 |
| 🔥 极高 | addConverterFactory(...) | 设置序列化器 | GsonConverterFactory / MoshiConverterFactory |
| 🔥 极高 | create(Api::class) | 把接口变成可调用对象 | 拿到 api 实例 |
| 🔥 极高 | Response<T> | 带 HTTP 状态码的响应包装 | 需要判断 4xx/5xx 时 |
| 🔥 极高 | Call<T> | 传统回调式(非挂起)返回(3.x 仍支持,但别和 suspend 混用) | 需要手动 enqueue/cancel |
| 高 | @Header / @Headers | 设置请求头(单个 / 多个静态) | 鉴权 Token、UA |
| 高 | @FormUrlEncoded + @Field | 表单提交(application/x-www-form-urlencoded) | 登录、传统表单接口 |
| 高 | @Multipart + @Part | 文件上传(multipart/form-data) | 头像、附件上传 |
| 高 | @QueryMap | 批量查询参数(Map) | 不定数量的筛选条件 |
| 高 | @Url | 用完整/动态 URL 覆盖 baseUrl | 多域名、代理地址 |
| 高 | OkHttpClient.Builder | 配置底层 HTTP(超时/拦截器) | 统一加 Token、日志 |
| 高 | .client(okHttp) | 给 Retrofit 挂自定义 OkHttp | 复用连接池与拦截器 |
| 高 | Call.await() | 把 Call<T> 挂起化(协程扩展) | 已有 Call 想用协程收 |
| 中 | @PUT / @DELETE / @PATCH | 更新 / 删除 / 局部更新 | REST 资源操作 |
| 中 | @HeaderMap / @FieldMap / @PartMap | 成批头 / 表单 / 多部件 | 动态头集合、多字段提交 |
| 中 | @Streaming | 流式下载,不一次性读进内存 | 大文件 / 视频下载 |
| 中 | authenticator(...) | Token 失效自动刷新重试 | 登录态续期 |
| 中 | cache(...) / Dispatcher | 响应缓存 / 并发连接控制 | 弱网、限流 |
| 中 | ScalarsConverterFactory | 直接返回 String/Int 等基础类型 | 返回纯文本/HTML 的接口 |
| 低 | @HEAD / @OPTIONS | 探活 / 协议探测 | 健康检查、CORS 预检 |
| 低 | addNetworkInterceptor | 网络层拦截(能看到真实请求) | 统一加密、压缩 |
| 低 | Callback<T> + enqueue | 最原始的异步回调(无协程) | 兼容极老代码 |
| 低 | Unit 返回类型 | 丢弃响应体(不反序列化) | HEAD / 不需要 body 的接口 |
| 低 | converter-kotlinx-serialization | 官方首发类型安全序列化(3.x 新增) | kotlinx.serialization 项目(推荐新项目) |
四、常用写法逐一看
4.1 GET:路径 + 查询参数 + 错误处理
interface ArticleApi {
// 相对路径里的 {id} 由 @Path 填;?page= 由 @Query 填
@GET("articles/{id}")
suspend fun getArticle(
@Path("id") id: Long,
@Query("page") page: Int = 1,
@Query("size") size: Int = 20
): Article
// 需要区分 200 / 401 / 500 时,返回值包一层 Response<T>
@GET("profile")
suspend fun getProfile(): Response<Profile>
}
// 调用处:用 Response 判断业务成功还是 HTTP 错误
val resp = api.getProfile()
when {
resp.isSuccessful -> handle(resp.body()!!)
resp.code() == 401 -> gotoLogin()
else -> showError(resp.errorBody()?.string() ?: "未知错误")
}
4.2 POST:JSON Body 与表单
interface UserApi {
// ① JSON 请求体(最常见):对象自动序列化成 JSON
@POST("users")
suspend fun createUser(@Body req: CreateUserReq): User
// ② 表单提交:方法必须标 @FormUrlEncoded,参数用 @Field
@FormUrlEncoded
@POST("login")
suspend fun login(
@Field("username") name: String,
@Field("password") pwd: String
): LoginResult
}
4.3 文件上传(@Multipart)
interface FileApi {
@Multipart
@POST("upload")
suspend fun uploadAvatar(
@Part("desc") desc: RequestBody, // 文本部件
@Part file: MultipartBody.Part // 文件部件
): UploadResult
}
// 调用:把本地文件包成 MultipartBody.Part
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadAvatar("头像".toRequestBody(), part)
4.4 大文件下载(@Streaming)
interface DownloadApi {
@Streaming
@GET
suspend fun download(@Url url: String): Response<ResponseBody>
}
// 用 @Streaming 时 response.body().source() 是流式,边读边写文件,不会 OOM
4.5 统一加 Token / 日志(OkHttp 拦截器)
class AuthInterceptor(private val tokenHolder: TokenHolder) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val req = chain.request().newBuilder()
.header("Authorization", "Bearer ${tokenHolder.token}") // 统一注入
.build()
return chain.proceed(req)
}
}
// 调试日志用官方 HttpLoggingInterceptor(level=BODY 仅 debug 包)
val okHttp = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenHolder))
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
五、实战重点:接口多域名
真实项目很少只有一个域名:主接口 api.xxx.com、支付走第三方 pay.third.com、图片/文件走 cdn.xxx.com、调试还有 mock 域名。Retrofit 的 baseUrl 是写死在实例上的,一个 Retrofit 实例只能对应一个 baseUrl,所以多域名要专门处理。下面两种方案最常用。
方案 A:多 Retrofit 实例 + 工厂(推荐,最清晰)
共享同一个 OkHttpClient(拦截器、超时、连接池都复用),按域名各建一个 Retrofit 实例,用工厂统一产出 API 接口。未来要加域名,只加一行。
object ApiFactory {
// 共享 OkHttp:所有域名共用一套拦截器与连接池
private val okHttp = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(TokenHolder))
.connectTimeout(15, TimeUnit.SECONDS)
.build()
private val gson = GsonConverterFactory.create()
// 核心:传入不同 baseUrl,产出不同 Retrofit 实例
private fun build(baseUrl: String): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttp)
.addConverterFactory(gson)
.build()
// 各域名 API,懒加载
val userApi: UserApi by lazy { build("https://api.example.com/").create(UserApi::class.java) }
val payApi: PayApi by lazy { build("https://pay.third.com/").create(PayApi::class.java) }
val cdnApi: CdnApi by lazy { build("https://cdn.example.com/").create(CdnApi::class.java) }
}
OkHttp 内部有连接池和线程调度,每个实例独立、不共享。若每个域名都 new 一个 OkHttpClient,连接无法复用、内存翻倍。把 okHttp 提出来共享是最优解。
方案 B:@Url 动态地址(单实例,绕过 baseUrl)
当请求地址完全不固定(如配置的代理地址、用户粘贴的链接),可以用 @Url 传入完整 URL,Retrofit 会忽略 baseUrl 直接用你给的地址。适合「只有一个通用 Retrofit,但偶尔要打不同域名」的场景。
interface RawApi {
// 注意:方法上不能写相对路径,@Url 提供完整地址
@GET
suspend fun getByFullUrl(@Url url: String): String // 返回纯文本用 ScalarsConverterFactory
}
// 调用:完整 URL 直接传
val html = rawApi.getByFullUrl("https://some-other-domain.com/feed")
// 同一 Retrofit 也能继续打自己的 baseUrl 接口,互不影响
@Url 的坑:用了 @Url 的接口,方法注解上的路径必须留空;且它绕过了 baseUrl,如果传的是相对路径会直接报错。多域名但有固定几套地址时,优先用方案 A,@Url 留给真正「地址完全动态」的零星需求。
方案 C:运行时切 baseUrl(进阶)
若同一套接口要在「正式/测试/mock」环境间切换,可把 baseUrl 做成可变的(用自定义拦截器在发送前改写 request.url 的 host),但实现较重,小项目不推荐。多数情况方案 A 已经够用。
多域名 + 协程 + 错误处理整合示例
class OrderRepository(
private val userApi: UserApi = ApiFactory.userApi,
private val payApi: PayApi = ApiFactory.payApi
) {
// 一次操作跨两个域名:主域查订单,支付域下单
suspend fun placeOrder(orderId: String): OrderResult = withContext(Dispatchers.IO) {
val order = userApi.getOrder(orderId) // api.example.com
val pay = payApi.pay(order.id, order.amount) // pay.third.com
OrderResult(order, pay)
}
}
六、依赖与避坑
// build.gradle (Module)
dependencies {
// Retrofit 3.0.0(注意:groupId 仍是 com.squareup.retrofit2,只升版本号)
// 底层 OkHttp 升到 4.12,suspend 自 2.6 起原生,3.x 继续保留
implementation "com.squareup.retrofit2:retrofit:3.0.0"
// JSON 转换器(Gson 或 Moshi 二选一;版本与 Retrofit 对齐)
implementation "com.squareup.retrofit2:converter-gson:3.0.0"
// 若想拿到原始 String/Int 返回值,再加 scalars
implementation "com.squareup.retrofit2:converter-scalars:3.0.0"
// 3.x 官方首发:kotlinx.serialization 转换器(类型安全,推荐新项目)
implementation "com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0"
// OkHttp(Retrofit 3.x 依赖它 4.12,通常显式引入以便配置)
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
}
常见坑:① baseUrl 必须以 / 结尾,否则路径拼接会丢一段。② 接口返回 Call<T> 时不能同时是 suspend,二选一。③ 想用协程又拿到 Call<T>,用 call.await()(来自 retrofit2.HttpException 体系 / kotlinx-coroutines-retrofit2)。④ 大文件务必 @Streaming,否则整个响应进内存会 OOM。