CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Android 开发入门/课时/本地存储
RawBlame
课时目录12 节

Android 开发入门

  1. 01Android 开发概览
  2. 02四大组件
  3. 03传统 View UI
  4. 04Jetpack Compose
  5. 05架构模式
  6. 06依赖注入
  7. 07本地存储
  8. 08网络层
  9. 09异步与响应式
  10. 10Firebase 生态
  11. 11测试与质量
  12. 12综合练习

本地存储

Room/DataStore/文件。

30 分钟

本地存储

这一课你将能做到

  1. 根据场景选择正确的存储方案:DataStore(偏好)、Room(结构化)、文件(大文件)
  2. 使用 Room 完成数据库定义、DAO 操作、迁移策略
  3. 实现离线优先架构:网络数据缓存到本地,本地数据驱动 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)
        }
    }
}

讲清楚要点

  1. Room 是 SQLite 的「类型安全封装」:编译期检查 SQL 语法,自动生成实体映射,支持 Flow/LiveData 响应式查询。但底层仍是 SQLite,复杂查询仍需手写 SQL。

  2. DataStore 分两种:Preferences DataStore(键值对,替代 SharedPreferences)和 Proto DataStore(类型安全,基于 Protocol Buffers)。前者易用,后者高效。

  3. 数据库版本升级必须谨慎:每次修改 Schema 都要 version + 1 并提供 Migration。开发阶段可用 fallbackToDestructiveMigration(),生产环境绝对禁止。

  4. Flow 让数据「活」起来:Room 返回 Flow 时,数据变化自动通知 UI。这是「单一数据源」的关键:网络写入 Room,UI 读 Room,自动同步。

  5. 文件存储注意权限与生命周期:内部存储无需权限,卸载删除;外部存储需要权限(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 再暴露

卡点急救

  1. Room 编译报错「cannot find symbol」:检查 kapt 是否配置,或 Entity 字段是否有默认值(非空字段必须有默认值或构造函数传入)。

  2. Flow 不更新:检查是否 @Insert(onConflict = REPLACE) 替换了所有字段,导致 @Query 条件匹配失败。或检查是否忘记 @Transaction 导致关系查询不触发。

  3. DataStore 读取慢:首次读取是磁盘 IO,不要阻塞 UI。用 stateIn 或 collectAsState 的初始值先显示默认值。

面试常问

  • Room 相比原生 SQLite 的优势? 编译期 SQL 验证、自动实体映射、Flow/LiveData 支持、更简洁的 DAO 定义、与 Paging 3 集成。但 Room 有学习成本,简单场景可能过度设计。

  • DataStore 为什么替代 SharedPreferences? SP 是同步阻塞(apply 虽异步但可能丢数据)、不支持类型安全、无法观察变化。DataStore 基于协程和 Flow,异步、类型安全、支持事务、可观察。

  • 如何设计离线优先架构?

    1. UI 只读本地数据库;2. 网络数据写入本地;3. 后台同步策略(WorkManager 定期同步);4. 冲突解决(服务器优先或客户端优先,根据业务定)。

自测三问

  1. 什么场景选择 Preferences DataStore,什么场景选择 Proto DataStore?
  2. Room 的 @Transaction 注解解决什么问题?举一个实际例子。
  3. 描述「用户打开 App → 显示课程列表 → 下拉刷新」的完整数据流。

小结一句话

本地存储是离线体验的基础:Room 管结构化数据,DataStore 管偏好设置,文件系统管大文件;离线优先的核心是「UI 只读本地,网络写入本地」。

延伸

  • Room 官方文档
  • DataStore 官方文档
  • Paging 3 库
  • 下一课:网络层——Retrofit、OkHttp 与 Apollo GraphQL。
加载本节练习…
← 上一课依赖注入下一课 →网络层