20、网络请求与数据加载:Retrofit集成、Ktor客户端、加载状态管理、缓存策略
网络请求,说白了就是App跟服务器打交道的那座桥。我做了这么多年Android开发,见过太多因为网络层没写好导致App卡顿、崩溃、甚至被用户骂上天的案例。今天咱们就把这块彻底讲透。
本章核心脉络:网络请求框架选型 → 加载状态管理 → 缓存策略落地。这三步走完,你的App网络层才算真正能打。
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 综合实战:一个完整的网络请求流程
把上面所有知识点串起来,一个典型的网络请求流程是这样的:
- 用户触发操作(比如下拉刷新)
- ViewModel设置状态为
Loading - Repository检查本地缓存,有数据直接返回
- 没有缓存或缓存过期,发起网络请求(Retrofit或Ktor)
- 请求成功,更新本地缓存,设置状态为
Success - 请求失败,设置状态为
Error,显示错误信息 - Compose根据状态渲染对应UI
这个流程看起来简单,但每一步都有坑。比如网络请求失败时,要不要显示旧缓存?我个人建议:如果用户是主动刷新,显示错误;如果是后台静默刷新,继续显示旧数据,等下次成功再更新。
最后提醒:别把所有请求都放在UI线程。虽然Retrofit和Ktor都支持协程,但如果你忘了切线程,或者用了runBlocking,主线程卡住是分分钟的事。记住:网络请求永远在Dispatchers.IO上执行。
好了,网络请求这块就讲到这里。从框架选型到状态管理,再到缓存策略,每一步都关系到App的稳定性和用户体验。你在实际项目中遇到什么问题,欢迎随时交流。