协程速通 · Android

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 全列一遍,最后专门讲多域名这种真实项目里几乎必踩的场景。

现代分层(对照你熟悉的 XML 开发)

传统做法常把网络请求直接写在 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.xRetrofit 3.x
包坐标 groupIdcom.squareup.retrofit2(3.x 只把版本号升到 3.0.0,groupId 不变;网上写的 retrofit3 是错的)
底层 OkHttp3.14(Java)4.12(Kotlin 重写,带来传递性 Kotlin 依赖)
协程 suspend2.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)。别把两者混在一起。

非 2xx 是否自动抛异常?

这是很多博客的误解:从 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) }
}
为什么共享 OkHttpClient

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。

Kotlin 协程速通 · Android 开发 · 内容基于 kotlinx.coroutines 1.7+