20、网络请求与数据加载:Retrofit集成、Ktor客户端、加载状态管理、缓存策略

网络请求,说白了就是App跟服务器打交道的那座桥。我做了这么多年Android开发,见过太多因为网络层没写好导致App卡顿、崩溃、甚至被用户骂上天的案例。今天咱们就把这块彻底讲透。

本章核心脉络:网络请求框架选型 → 加载状态管理 → 缓存策略落地。这三步走完,你的App网络层才算真正能打。

网络请求与数据加载知识体系 网络请求框架 加载状态管理 缓存策略 Retrofit Ktor Client Loading / Error / Success 内存缓存 磁盘缓存 三者协同工作:框架发起请求 → 状态管理反馈UI → 缓存策略减少重复请求

20.1 Retrofit集成:老牌劲旅,稳如老狗

Retrofit,我用了快8年了。从最早的1.0版本到现在,它一直是Android网络请求的标配。为什么?因为它简单、稳定、生态好。

先看依赖怎么加。在build.gradle.kts里:

// Retrofit + OkHttp + Gson
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

然后定义接口。我个人习惯把API接口都放在一个ApiService里:

interface ApiService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: Int): User

    @POST("users")
    suspend fun createUser(@Body user: User): User

    @GET("posts")
    suspend fun getPosts(
        @Query("page") page: Int,
        @Query("limit") limit: Int = 20
    ): List<Post>
}

创建Retrofit实例时,我建议把OkHttpClient单独拎出来配置。这样方便统一管理拦截器、超时时间等:

val okHttpClient = OkHttpClient.Builder()
    .connectTimeout(30, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .addInterceptor(HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    })
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

val apiService = retrofit.create(ApiService::class.java)

小技巧:调试阶段把日志拦截器级别设成BODY,上线前记得改成NONE或者HEADERS。我曾经见过有人把BODY日志带到生产环境,结果用户敏感信息全打印出来了……

20.2 Ktor客户端:Kotlin原生,协程友好

Ktor是JetBrains出品的HTTP客户端,完全用Kotlin写的。如果你项目里全是Kotlin,用Ktor会感觉特别顺滑。它天然支持协程,没有回调地狱。

依赖配置:

implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-okhttp:2.3.7")  // Android平台引擎
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
implementation("io.ktor:ktor-client-logging:2.3.7")

创建客户端:

val client = HttpClient(OkHttp) {
    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true
            isLenient = true
        })
    }
    install(Logging) {
        level = LogLevel.BODY
    }
    defaultRequest {
        url("https://api.example.com/")
        contentType(ContentType.Application.Json)
    }
}

发起请求的方式也很直观:

suspend fun getUser(userId: Int): User {
    return client.get("users/$userId").body()
}

suspend fun createUser(user: User): User {
    return client.post("users") {
        setBody(user)
    }.body()
}

注意:Ktor的HttpClient是重量级对象,建议用依赖注入或者单例模式管理。别在每次请求时都new一个,那性能会很难看。

20.3 加载状态管理:别让用户干等

网络请求最怕什么?怕用户点了个按钮,然后界面一动不动。我早期做项目时就犯过这个错——请求发出去,没给任何反馈,用户以为App死了。

在Jetpack Compose里,我习惯用sealed class来定义状态:

sealed class UiState<out T> {
    object Idle : UiState<Nothing>()
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

ViewModel里这样用:

class UserViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState<User>>(UiState.Idle)
    val uiState: State<UiState<User>> = _uiState

    fun loadUser(userId: Int) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = apiService.getUser(userId)
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "未知错误")
            }
        }
    }
}

Compose里根据状态渲染不同UI:

@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
    val state = viewModel.uiState.value

    when (state) {
        is UiState.Idle -> { /* 初始状态,啥也不显示 */ }
        is UiState.Loading -> {
            CircularProgressIndicator()
        }
        is UiState.Success -> {
            UserCard(user = state.data)
        }
        is UiState.Error -> {
            ErrorView(message = state.message) {
                viewModel.loadUser(1)  // 重试
            }
        }
    }
}

核心原则:每个网络请求都必须覆盖Loading、Success、Error三种状态。少一个,用户体验就掉一截。

20.4 缓存策略:少请求,快响应

缓存这东西,说白了就是用空间换时间。我见过最极端的案例——一个新闻App,每次打开都重新请求所有图片,流量哗哗地跑,用户直接卸载。

常用的缓存策略有三种:

策略 适用场景 实现方式
内存缓存 频繁访问的小数据 LruCache / HashMap
磁盘缓存 图片、文件等大资源 OkHttp Cache / Room
网络缓存 HTTP协议缓存 Cache-Control / ETag

OkHttp自带的磁盘缓存配置起来很简单:

val cache = Cache(
    directory = File(context.cacheDir, "http_cache"),
    maxSize = 10 * 1024 * 1024  // 10MB
)

val okHttpClient = OkHttpClient.Builder()
    .cache(cache)
    .build()

配合服务端的Cache-Control头,可以实现智能缓存:

// 服务端返回:Cache-Control: max-age=3600
// 客户端在1小时内不会再发起网络请求,直接走缓存

// 如果需要强制刷新,可以加请求头:
@GET("users/{id}")
suspend fun getUser(
    @Path("id") userId: Int,
    @Header("Cache-Control") cacheControl: String = "no-cache"
): User

避坑指南:我曾经在做一个电商项目时,商品列表用了30分钟缓存。结果运营改了价格,用户看到的还是旧价格,投诉电话被打爆。从那以后,凡是涉及金额、库存的数据,我都用no-cache

对于需要更精细控制的场景,可以用Room做本地缓存。比如把网络返回的数据存到数据库,下次先读数据库,再异步刷新网络:

class UserRepository(
    private val apiService: ApiService,
    private val userDao: UserDao
) {
    suspend fun getUser(userId: Int): User {
        // 先读本地
        val cachedUser = userDao.getUser(userId)
        if (cachedUser != null) {
            return cachedUser
        }
        // 本地没有,请求网络
        val remoteUser = apiService.getUser(userId)
        userDao.insertUser(remoteUser)  // 缓存到本地
        return remoteUser
    }
}

这种「本地优先」的策略,用户体验最好。用户即使没网,也能看到旧数据,而不是白屏。

20.5 综合实战:一个完整的网络请求流程

把上面所有知识点串起来,一个典型的网络请求流程是这样的:

  1. 用户触发操作(比如下拉刷新)
  2. ViewModel设置状态为Loading
  3. Repository检查本地缓存,有数据直接返回
  4. 没有缓存或缓存过期,发起网络请求(Retrofit或Ktor)
  5. 请求成功,更新本地缓存,设置状态为Success
  6. 请求失败,设置状态为Error,显示错误信息
  7. Compose根据状态渲染对应UI

这个流程看起来简单,但每一步都有坑。比如网络请求失败时,要不要显示旧缓存?我个人建议:如果用户是主动刷新,显示错误;如果是后台静默刷新,继续显示旧数据,等下次成功再更新。

最后提醒:别把所有请求都放在UI线程。虽然Retrofit和Ktor都支持协程,但如果你忘了切线程,或者用了runBlocking,主线程卡住是分分钟的事。记住:网络请求永远在Dispatchers.IO上执行。

好了,网络请求这块就讲到这里。从框架选型到状态管理,再到缓存策略,每一步都关系到App的稳定性和用户体验。你在实际项目中遇到什么问题,欢迎随时交流。


公众号:蓝海资料掘金营,微信deep3321