18、本地存储:Room 数据库集成,DataStore 替代 SharedPreferences,文件读写

本地存储这件事,说实话每个 Android 项目都绕不开。我最早做开发那会儿,数据少就用 SharedPreferences,数据多了就自己写 SQLite,后来 Room 出来了,我才发现原来数据库可以这么优雅。这一章咱们就把本地存储的三大块——Room、DataStore、文件读写——一次性讲透。

18.1 Room 数据库:从建表到查询

Room 是 Google 官方推出的 ORM 框架,说白了就是帮你把 SQLite 的操作封装成 Kotlin 对象。你不需要写 SQL 语句,只需要定义数据类,Room 自动帮你搞定建表、增删改查。

18.1.1 添加依赖

首先在 build.gradle.kts 里加上 Room 的依赖:

// 项目级 build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false
}

// 模块级 build.gradle.kts
plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    ksp("androidx.room:room-compiler:$roomVersion")
}

这里用了 KSP 而不是 KAPT,速度更快。我去年把一个项目从 KAPT 迁移到 KSP,编译时间直接缩短了 40%。

18.1.2 定义实体和 DAO

实体就是数据库里的表,DAO 就是操作表的方法。看个例子:

// 实体类
@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val content: String,
    val createdAt: Long = System.currentTimeMillis()
)

// DAO 接口
@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY createdAt DESC")
    fun getAllNotes(): Flow<List<Note>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertNote(note: Note)

    @Delete
    suspend fun deleteNote(note: Note)
}

注意 getAllNotes 返回的是 Flow,这样当数据库变化时,UI 会自动更新。我在项目中遇到过一个问题:如果返回 List 而不是 Flow,每次查询都要手动刷新,用户体验很差。

18.1.3 创建数据库

@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao

    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,
                    "app_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

这里用了单例模式,避免多次创建数据库实例。synchronized 是为了线程安全,@Volatile 保证可见性。

18.1.4 在 Compose 中使用

@Composable
fun NotesScreen(viewModel: NotesViewModel = viewModel()) {
    val notes by viewModel.notes.collectAsState()

    LazyColumn {
        items(notes) { note ->
            NoteItem(note = note)
        }
    }
}

ViewModel 里注入 DAO,然后通过 Flow 把数据暴露给 UI。这样数据层和 UI 层就完全解耦了。

⚠️ 注意:Room 的数据库操作不能在主线程执行,除非你用了 allowMainThreadQueries()。我个人建议永远不要用这个,养成用协程的习惯。

18.2 DataStore:SharedPreferences 的完美替代

SharedPreferences 的问题大家都知道:不支持异步、类型不安全、容易 ANR。DataStore 就是 Google 给出的解决方案。它基于 Flow,完全异步,而且支持类型安全。

18.2.1 添加依赖

implementation("androidx.datastore:datastore-preferences:1.0.0")

18.2.2 创建 DataStore

val Context.dataStore by preferencesDataStore(name = "settings")

// 定义 key
val THEME_KEY = booleanPreferencesKey("dark_theme")
val USERNAME_KEY = stringPreferencesKey("username")

preferencesDataStore 是一个扩展属性,用委托的方式创建。你只需要定义 key,剩下的 DataStore 帮你搞定。

18.2.3 读写数据

// 写数据
suspend fun saveThemePreference(context: Context, isDark: Boolean) {
    context.dataStore.edit { preferences ->
        preferences[THEME_KEY] = isDark
    }
}

// 读数据
val themeFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
    preferences[THEME_KEY] ?: false
}

读数据返回的是 Flow,所以你可以直接在 Compose 里 collectAsState。写数据是挂起函数,需要在协程里调用。

💡 小技巧:如果你需要存储复杂对象,可以用 Proto DataStore。它基于 Protocol Buffers,性能更好,但需要定义 proto 文件。我个人觉得大部分场景用 Preferences DataStore 就够了。

18.3 文件读写:内部存储与外部存储

文件读写分两种:内部存储(只有你的 App 能访问)和外部存储(其他 App 也能访问)。Android 10 以后,外部存储的访问权限收紧了,推荐用 MediaStore 或者 SAF(存储访问框架)。

18.3.1 内部存储

// 写文件
fun writeToInternalStorage(context: Context, fileName: String, content: String) {
    context.openFileOutput(fileName, Context.MODE_PRIVATE).use { outputStream ->
        outputStream.write(content.toByteArray())
    }
}

// 读文件
fun readFromInternalStorage(context: Context, fileName: String): String {
    return context.openFileInput(fileName).bufferedReader().use { it.readText() }
}

内部存储不需要任何权限,文件存在 /data/data/包名/files/ 目录下。我一般用它来缓存一些临时数据。

18.3.2 外部存储

Android 10 以后,推荐用 MediaStore 来保存图片、视频等媒体文件:

fun saveImageToGallery(context: Context, bitmap: Bitmap) {
    val filename = "IMG_${System.currentTimeMillis()}.jpg"
    val contentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, filename)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
    }

    val uri = context.contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        contentValues
    )

    uri?.let {
        context.contentResolver.openOutputStream(it)?.use { outputStream ->
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
        }
    }
}

这里不需要 READ_EXTERNAL_STORAGE 权限,因为 MediaStore 会自动处理。但如果你要访问其他 App 的文件,还是需要权限的。

⚠️ 注意:千万不要在文件读写操作里用 Thread.sleep() 或者 while(true) 循环。我曾经见过一个同事在文件写入时加了死循环等待,结果直接导致 ANR。用协程的 withContext(Dispatchers.IO) 就好。

18.4 知识体系总览

下面这张图把本章的知识点串起来了,你可以看到 Room、DataStore、文件读写各自的应用场景和关系:

Android 本地存储方案 Room 数据库 DataStore 文件读写 实体 + DAO + 数据库 Flow 响应式查询 协程 + 事务 Preferences DataStore Proto DataStore Flow 异步读写 内部存储 外部存储 (MediaStore) SAF 存储访问框架 选择建议:结构化数据用 Room,键值对用 DataStore,大文件用文件读写

18.5 避坑指南

最后分享几个我踩过的坑:

  • Room 数据库升级:如果你改了实体结构,一定要写 Migration,否则会 crash。我刚开始偷懒用了 fallbackToDestructiveMigration,结果用户数据全丢了,被骂惨了。
  • DataStore 文件冲突:多个协程同时写 DataStore 会抛出异常。解决办法是用 DataStore 自带的 edit 方法,它会自动处理并发。
  • 文件路径硬编码:不要写死路径,用 context.filesDir 或者 Environment.getExternalStorageDirectory()。不同厂商的路径可能不一样。

嗯,本地存储这块内容就这些。你想想看,Room 负责结构化数据,DataStore 负责配置和偏好,文件读写负责大文件和媒体,三者各司其职,基本覆盖了所有场景。实际项目中,我一般会把这三种方案封装成 Repository,上层完全不用关心底层用的是数据库还是文件。

📌 核心要点:

  • Room 用注解定义实体和 DAO,配合 Flow 实现响应式数据
  • DataStore 替代 SharedPreferences,支持异步和类型安全
  • 文件读写分内部和外部,Android 10 以后推荐 MediaStore
  • 所有 IO 操作都要在协程的 Dispatchers.IO 中执行