网络与本地存储为什么要分层
几乎所有应用都要做两件事:从网络拿数据,把数据存到本地。如果直接在界面层写网络请求又直接写文件,代码很快就会变成一团——网络挂了怎么办、缓存怎么失效、数据格式变了怎么改,全都缠在一起。
正确的做法是分层:Retrofit 负责和网络对话,Room 负责本地落盘,中间用一个 Repository 统一决定"这次数据从哪来"。对界面而言,它只看见一个永远返回数据的接口,至于数据是来自网络还是缓存,界面并不关心。
把"数据从哪来"这个问题集中到 Repository,界面就永远不会因为网络策略调整而改动。这就是分层的回报。
Retrofit:声明式网络请求
Retrofit 用接口加注解的方式描述网络请求,把样板代码降到最低。你定义一个接口,方法上加 GET、POST、Path、Query 等注解,Retrofit 在编译期生成实现。
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Long): UserDto
@GET("news")
suspend fun fetchNews(
@Query("page") page: Int,
@Query("size") size: Int = 20
): List<NewsDto>
}
配合 Kotlin 协程,suspend 方法让网络请求像同步代码一样书写。Retrofit 会自动把它切到后台线程,你在 viewModelScope 里调用即可,不用自己管线程切换。
数据模型与错误处理
网络返回的 DTO 和界面用的实体不要混为一谈。DTO 跟服务端字段一一对应,实体是界面真正需要的样子,两者之间写一层映射,服务端改字段时只动映射,不污染业务。
- HttpException非 2xx 的状态码,转成可读的业务错误。
- IOException断网、超时,统一提示"网络不可用"。
- 序列化异常字段对不上,别让它直接崩,兜底处理。
- 统一封装在 Repository 里把异常转成 UiState.Error,界面只展示。
千万别在界面层 try/catch 一大堆网络细节,把这些都收敛进 Repository,界面只面对"成功的数据"和"一句错误提示"。
拦截器与公共参数
OkHttp 的拦截器是处理公共逻辑的好地方:加请求头、附 token、打印日志、统一处理过期重登。这些横切关注点不该写在每个接口里。
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.build()
chain.proceed(req)
}
.connectTimeout(10, TimeUnit.SECONDS)
.build()
日志拦截器只在 debug 版本打开,release 包里关掉——线上打印接口内容既费性能又可能泄露用户数据。
Room:本地数据库 ORM
Room 是安卓官方的本地数据库 ORM,在 SQLite 之上提供编译期校验的查询接口。它由三部分组成:Entity(表)、Dao(数据访问)、Database(持有者)。
@Entity(tableName = "news")
data class NewsEntity(
@PrimaryKey val id: Long,
val title: String,
val summary: String,
val updatedAt: Long
)
@Dao
interface NewsDao {
@Query("SELECT * FROM news ORDER BY updatedAt DESC")
fun observeNews(): Flow<List<NewsEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(list: List<NewsEntity>)
}
DAO 方法返回 Flow 是 Room 的杀手锏:数据库一变,Flow 自动发射新值,界面随之刷新。这让"数据库即单一数据源"变得非常自然。
缓存优先与离线可用
缓存优先的思路是:界面先订阅 Room 的 Flow,数据立刻有显示;后台再从网络拉最新数据,写回 Room;Room 一变,界面自动刷新。这样即使断网,上次的数据也还在。
- 单一数据源:界面只订阅 Room,不直接碰网络。
- 网络成功才写库:网络失败时保留旧缓存,不清空。
- 过期策略:给缓存加时间戳,超过阈值才强制刷新。
- 分页数据:本地表按页或按列表维护,避免越攒越大。
离线可用不是加个缓存就完事,而是把 Room 当成界面唯一的数据来源,网络只负责回填。这个顺序颠倒,体验就差一截。
实战:一个缓存优先的列表
把 Retrofit 与 Room 串起来:Repository 暴露数据库 Flow,同时触发一次网络刷新。
class NewsRepository(
private val api: ApiService,
private val dao: NewsDao,
) {
// 界面只订阅这个:数据库一变就自动更新
val cachedNews: Flow<List<NewsEntity>> = dao.observeNews()
suspend fun refresh() {
try {
val remote = api.fetchNews(page = 0)
dao.upsert(remote.map { it.toEntity() })
} catch (e: Exception) {
// 失败就保留旧缓存,不清空
}
}
}
ViewModel 里订阅 cachedNews 渲染,下拉刷新时调 refresh()。整个页面从来不知道网络的存在——它只看数据库。这个结构在弱网和离线场景下体验提升非常明显。
常见坑与最佳实践
最后整理高频问题。
- 主线程访问数据库:Room 默认禁止主线程查询,要在协程里做。
- 网络失败清缓存:一断网白屏,失败应保留旧数据。
- DTO 当实体用:服务端改字段界面就崩,务必加一层映射。
- 日志拦截器线上开着:泄露数据且拖慢性能。
- 无分页拉全量:数据量一大列表就卡,列表必须分页。
网络与本地存储是每个应用的地基。地基稳了,上面的业务怎么堆都不容易塌。配合上一篇的架构组件,就是一套完整的现代安卓数据方案。