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、文件读写各自的应用场景和关系:
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 中执行