大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
本地存储
这一课你将能做到
- 根据场景选择正确的存储方案:DataStore(偏好)、Room(结构化)、文件(大文件)
- 使用 Room 完成数据库定义、DAO 操作、迁移策略
- 实现离线优先架构:网络数据缓存到本地,本地数据驱动 UI
为什么要学
移动网络不稳定,用户可能在地铁、电梯、偏远地区使用 App。本地存储决定了离线体验:是白屏转圈,还是流畅浏览缓存内容。Room 是 Google 官方的 SQLite 封装,DataStore 是 SharedPreferences 的现代替代。掌握它们,才能写出「有网没网都能用」的应用。
人话:本地存储就像给 App 装了一个「冰箱」。网络数据是外卖,到了就吃掉(展示);本地存储是冰箱存货,外卖送不到时也能吃上饭。DataStore 放「调料」(设置),Room 放「正餐」(结构化数据),文件系统放「大纸箱」(图片、视频)。
面试中,「Room 迁移」、「DataStore vs SharedPreferences」、「缓存策略」是高频考点。
先建立图像
┌─────────────────────────────────────────┐
│ 本地存储选型决策树 │
│ │
│ 数据类型? │
│ ├── 键值对(设置/Token) │
│ │ └── DataStore (首选) │
│ │ └── 替代 SharedPreferences
│ ├── 结构化数据(列表/关系) │
│ │ └── Room (SQLite) │
│ └── 大文件(图片/视频/日志) │
│ └── 内部/外部存储 │
│ │
│ 离线优先流程: │
│ 网络 → 存入 Room → UI 读取 Room → 显示 │
│ (网络是源头,但 UI 不直接依赖网络) │
└─────────────────────────────────────────┘
跟着做
步骤 1:DataStore 保存用户设置
// 定义 DataStore
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings"
)
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private object Keys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val FONT_SIZE = intPreferencesKey("font_size")
val LAST_SYNC = longPreferencesKey("last_sync")
}
// 读取:Flow 自动更新
val isDarkMode: Flow<Boolean> = context.dataStore.data
.map { it[Keys.DARK_MODE] ?: false }
val fontSize: Flow<Int> = context.dataStore.data
.map { it[Keys.FONT_SIZE] ?: 16 }
// 写入:挂起函数
suspend fun setDarkMode(enabled: Boolean) {
context.dataStore.edit { it[Keys.DARK_MODE] = enabled }
}
suspend fun setFontSize(size: Int) {
context.dataStore.edit { it[Keys.FONT_SIZE] = size }
}
suspend fun updateLastSync() {
context.dataStore.edit {
it[Keys.LAST_SYNC] = System.currentTimeMillis()
}
}
}
步骤 2:Room 数据库定义
// 1. Entity
@Entity(tableName = "tracks")
data class TrackEntity(
@PrimaryKey val slug: String,
val title: String,
val description: String,
val lessonCount: Int,
val coverUrl: String?,
val updatedAt: Long = System.currentTimeMillis(),
val isFavorite: Boolean = false
)
@Entity(
tableName = "lessons",
foreignKeys = [
ForeignKey(
entity = TrackEntity::class,
parentColumns = ["slug"],
childColumns = ["trackSlug"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("trackSlug")]
)
data class LessonEntity(
@PrimaryKey val slug: String,
val trackSlug: String,
val title: String,
val content: String,
val order: Int,
val duration: Int
)
// 2. DAO
@Dao
interface TrackDao {
@Query("SELECT * FROM tracks ORDER BY updatedAt DESC")
fun getAllTracks(): Flow<List<TrackEntity>>
@Query("SELECT * FROM tracks WHERE slug = :slug")
suspend fun getTrackBySlug(slug: String): TrackEntity?
@Query("SELECT * FROM tracks WHERE isFavorite = 1")
fun getFavoriteTracks(): Flow<List<TrackEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTracks(tracks: List<TrackEntity>)
@Query("UPDATE tracks SET isFavorite = NOT isFavorite WHERE slug = :slug")
suspend fun toggleFavorite(slug: String)
@Delete
suspend fun deleteTrack(track: TrackEntity)
@Transaction
@Query("SELECT * FROM tracks WHERE slug = :slug")
suspend fun getTrackWithLessons(slug: String): TrackWithLessons?
}
// 关系查询数据类
data class TrackWithLessons(
@Embedded val track: TrackEntity,
@Relation(
parentColumn = "slug",
entityColumn = "trackSlug"
)
val lessons: List<LessonEntity>
)
// 3. Database
@Database(
entities = [TrackEntity::class, LessonEntity::class],
version = 2,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun trackDao(): TrackDao
abstract fun lessonDao(): LessonDao()
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"coderoadmap.db"
)
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration() // 仅开发阶段使用
.build()
INSTANCE = instance
instance
}
}
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tracks ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0")
}
}
}
}
步骤 3:Repository 整合 Room 与网络
@Singleton
class TrackRepository @Inject constructor(
private val api: TrackApiService,
private val trackDao: TrackDao,
private val lessonDao: LessonDao,
private val settingsRepo: SettingsRepository
) {
// 单一数据源:UI 只读 Room
val tracks: Flow<List<TrackUiModel>> = trackDao.getAllTracks()
.map { entities ->
entities.map { it.toUiModel() }
}
// 刷新:网络 → Room
suspend fun refreshTracks(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val response = api.getTracks()
val entities = response.data.map { dto ->
TrackEntity(
slug = dto.slug,
title = dto.title,
description = dto.description,
lessonCount = dto.lessonCount,
coverUrl = dto.coverUrl
)
}
trackDao.insertTracks(entities)
settingsRepo.updateLastSync()
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
// 详情:先读缓存,再更新
suspend fun getTrackDetail(slug: String): TrackDetailUiModel? {
// 先返回缓存
val cached = trackDao.getTrackWithLessons(slug)
// 后台刷新
launch {
try {
val detail = api.getTrackDetail(slug)
lessonDao.insertLessons(detail.lessons.map { it.toEntity(slug) })
} catch (e: Exception) {
// 静默失败,用缓存
}
}
return cached?.toDetailUiModel()
}
}
步骤 4:文件存储(大文件)
class FileStorageManager @Inject constructor(
@ApplicationContext private val context: Context
) {
// 内部存储:App 私有,卸载删除
fun saveLogToInternal(content: String): File {
val file = File(context.filesDir, "app_log.txt")
file.writeText(content)
return file
}
// 缓存目录:系统可能清理
fun cacheImage(bitmap: Bitmap, name: String): File {
val file = File(context.cacheDir, "$name.png")
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
return file
}
// 外部存储(需要权限,Android 10+ 推荐 MediaStore)
fun saveToDownloads(content: String, fileName: String): Uri? {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, "text/plain")
}
return context.contentResolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, values
)?.also { uri ->
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(content.toByteArray())
}
}
}
}
步骤 5:Compose 中观察数据
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val isDarkMode by viewModel.isDarkMode.collectAsState(initial = false)
val fontSize by viewModel.fontSize.collectAsState(initial = 16)
Column(modifier = Modifier.padding(16.dp)) {
// 深色模式开关
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("深色模式")
Switch(
checked = isDarkMode,
onCheckedChange = { viewModel.setDarkMode(it) }
)
}
Spacer(modifier = Modifier.height(16.dp))
// 字体大小滑块
Text("字体大小: ${fontSize}sp")
Slider(
value = fontSize.toFloat(),
onValueChange = { viewModel.setFontSize(it.toInt()) },
valueRange = 12f..24f
)
}
}
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepo: SettingsRepository
) : ViewModel() {
val isDarkMode = settingsRepo.isDarkMode
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val fontSize = settingsRepo.fontSize
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 16)
fun setDarkMode(enabled: Boolean) {
viewModelScope.launch {
settingsRepo.setDarkMode(enabled)
}
}
fun setFontSize(size: Int) {
viewModelScope.launch {
settingsRepo.setFontSize(size)
}
}
}
讲清楚要点
-
Room 是 SQLite 的「类型安全封装」:编译期检查 SQL 语法,自动生成实体映射,支持 Flow/LiveData 响应式查询。但底层仍是 SQLite,复杂查询仍需手写 SQL。
-
DataStore 分两种:Preferences DataStore(键值对,替代 SharedPreferences)和 Proto DataStore(类型安全,基于 Protocol Buffers)。前者易用,后者高效。
-
数据库版本升级必须谨慎:每次修改 Schema 都要
version + 1并提供Migration。开发阶段可用fallbackToDestructiveMigration(),生产环境绝对禁止。 -
Flow 让数据「活」起来:Room 返回
Flow时,数据变化自动通知 UI。这是「单一数据源」的关键:网络写入 Room,UI 读 Room,自动同步。 -
文件存储注意权限与生命周期:内部存储无需权限,卸载删除;外部存储需要权限(Android 10+ 分区存储限制);缓存目录系统可能清理,重要数据不要放这里。
你可能会懵的地方
-
Room 和 SQLiteOpenHelper 什么关系? Room 底层还是 SQLiteOpenHelper,但封装了编译期检查、LiveData/Flow 支持、更简洁的 API。除非维护遗留代码,否则不要直接用 SQLiteOpenHelper。
-
@Transaction 注解有什么用? 确保多表操作的原子性。比如插入 Track 和它的 Lessons,要么全部成功,要么全部回滚,避免数据不一致。
-
DataStore 能存对象吗? Preferences DataStore 只能存基本类型。存对象用 Proto DataStore(需定义 .proto 文件),或序列化成 JSON 存字符串。
-
数据库操作必须在子线程吗? Room 的 suspend 函数自动在
Dispatchers.IO执行。如果返回Flow,查询在收集时执行。非 suspend 的 DAO 方法必须在子线程调用,否则编译报错。 -
大量数据分页加载怎么做? 用 Paging 3 库。Room 支持
PagingSource,与 Compose 的LazyColumn集成,自动处理分页加载和占位。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
fallbackToDestructiveMigration() 用于生产 | 升级丢数据 | 提供 Migration 或 createFromAsset 预置 |
| 在 UI 线程直接调用 DAO | 卡顿/崩溃 | suspend 或返回 Flow |
忽略 @Transaction | 多表操作部分失败,数据不一致 | 加注解确保原子性 |
| 用 SharedPreferences 存大量数据 | 性能差,ANR | 迁移到 DataStore 或 Room |
| 数据库 Entity 直接暴露给 UI | 耦合,难维护 | 转换为 UiModel 再暴露 |
卡点急救
-
Room 编译报错「cannot find symbol」:检查 kapt 是否配置,或 Entity 字段是否有默认值(非空字段必须有默认值或构造函数传入)。
-
Flow 不更新:检查是否
@Insert(onConflict = REPLACE)替换了所有字段,导致@Query条件匹配失败。或检查是否忘记@Transaction导致关系查询不触发。 -
DataStore 读取慢:首次读取是磁盘 IO,不要阻塞 UI。用
stateIn或collectAsState的初始值先显示默认值。
面试常问
-
Room 相比原生 SQLite 的优势? 编译期 SQL 验证、自动实体映射、Flow/LiveData 支持、更简洁的 DAO 定义、与 Paging 3 集成。但 Room 有学习成本,简单场景可能过度设计。
-
DataStore 为什么替代 SharedPreferences? SP 是同步阻塞(apply 虽异步但可能丢数据)、不支持类型安全、无法观察变化。DataStore 基于协程和 Flow,异步、类型安全、支持事务、可观察。
-
如何设计离线优先架构?
- UI 只读本地数据库;2. 网络数据写入本地;3. 后台同步策略(WorkManager 定期同步);4. 冲突解决(服务器优先或客户端优先,根据业务定)。
自测三问
- 什么场景选择 Preferences DataStore,什么场景选择 Proto DataStore?
- Room 的
@Transaction注解解决什么问题?举一个实际例子。 - 描述「用户打开 App → 显示课程列表 → 下拉刷新」的完整数据流。
小结一句话
本地存储是离线体验的基础:Room 管结构化数据,DataStore 管偏好设置,文件系统管大文件;离线优先的核心是「UI 只读本地,网络写入本地」。
延伸
- Room 官方文档
- DataStore 官方文档
- Paging 3 库
- 下一课:网络层——Retrofit、OkHttp 与 Apollo GraphQL。