18、数据持久化:Room数据库、SharedPreferences、媒体库元数据存储

各位同学,今天我们来聊聊车载应用里一个绕不开的话题——数据持久化。

说白了,就是怎么把用户的数据、配置、播放记录这些东西,稳稳当当地存下来。车机不像手机,它可能随时断电、重启,数据丢了可不是闹着玩的。我个人习惯把车载的数据持久化分成三个层次:轻量配置、结构化数据、媒体元数据。咱们一个一个来拆。

核心观点:选对存储方式,比会写代码更重要。SharedPreferences 存配置,Room 存业务数据,MediaStore 管媒体文件。混着用,会出大问题。

车载数据持久化三层架构 SharedPreferences 轻量键值对存储 Room 数据库 结构化关系型数据 MediaStore 媒体库元数据 适用场景 • 用户设置(亮度、音量) • 上次播放的电台/歌曲ID • 引导页是否已展示 • 主题/语言偏好 适用场景 • 播放列表/歌单 • 收藏/历史记录 • 用户账号信息 • 复杂的查询与关联数据 适用场景 • 音乐文件标签 • 专辑封面 • 播放次数/时长 • 系统媒体扫描

18.1 SharedPreferences:轻量级配置存储

先聊最简单的。SharedPreferences,本质上就是个 XML 文件,存一些 key-value 对。适合放什么?音量大小、上次选中的音源、夜间模式开关这类。

嗯,这里要注意:千万别往 SharedPreferences 里塞大对象或者列表。我在项目中遇到过有人把整个歌单 JSON 序列化后塞进去,结果读取时 ANR 了。你想想看,车机主线程读一个几百 KB 的 XML,不卡才怪。

我的习惯:用 SharedPreferences 只存基本类型和短字符串。如果需要存复杂结构,请用 Room。

// 保存配置
val prefs = context.getSharedPreferences("car_settings", Context.MODE_PRIVATE)
prefs.edit().putInt("last_volume", 15).apply()

// 读取配置
val volume = prefs.getInt("last_volume", 10) // 默认音量10

这里有个小坑:commit()apply() 的区别。commit 是同步写入,会阻塞线程;apply 是异步写入。我建议在车载场景下,非关键配置一律用 apply。为什么?因为车机启动时,很多服务在抢资源,同步写文件可能会拖慢启动速度。

18.2 Room 数据库:结构化数据的正确打开方式

Room 是 Google 官方推荐的 ORM 框架,底层封装了 SQLite。说实话,在车载开发里,Room 是我用得最多的持久化方案。

为什么不用原生 SQLite?因为 Room 帮你做了三件事:编译时校验 SQL 语法、自动生成模板代码、与 LiveData/Flow 无缝集成。说白了,就是让你少写很多重复代码,而且不容易写错。

18.2.1 定义实体与 DAO

@Entity(tableName = "playlists")
data class Playlist(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val createTime: Long = System.currentTimeMillis(),
    val songCount: Int = 0
)

@Dao
interface PlaylistDao {
    @Query("SELECT * FROM playlists ORDER BY createTime DESC")
    fun getAllPlaylists(): Flow<List<Playlist>>

    @Insert
    suspend fun insert(playlist: Playlist)

    @Delete
    suspend fun delete(playlist: Playlist)
}

看到 Flow 了吗?这是 Room 的亮点。数据一变,UI 自动更新。我在做车载音乐播放器时,歌单列表就是用 Flow 观察的。用户新建歌单后,列表立刻刷新,体验非常顺滑。

18.2.2 数据库升级:避坑指南

我曾经在项目上线后,发现需要给播放记录表加一个字段。当时图省事,直接改了实体类,没写 Migration。结果用户升级后,数据库直接崩溃了。

警告:生产环境中,绝对不要用 fallbackToDestructiveMigration()。这会导致用户数据全部丢失。一定要写 Migration 脚本。

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE play_history ADD COLUMN play_duration INTEGER DEFAULT 0")
    }
}

Room.databaseBuilder(context, AppDatabase::class.java, "car_media.db")
    .addMigrations(MIGRATION_1_2)
    .build()

18.3 媒体库元数据存储:MediaStore 的正确用法

车载多媒体应用,核心就是管理音乐、视频这些媒体文件。Android 系统提供了 MediaStore,用来统一管理媒体库的元数据。

你可能会问:为什么不用 Room 自己存?因为 MediaStore 是系统级的,其他应用(比如语音助手、系统音乐播放器)也能访问。而且系统媒体扫描服务会自动更新文件变化,省去你自己监听文件目录的麻烦。

18.3.1 查询媒体文件

val projection = arrayOf(
    MediaStore.Audio.Media._ID,
    MediaStore.Audio.Media.TITLE,
    MediaStore.Audio.Media.ARTIST,
    MediaStore.Audio.Media.ALBUM,
    MediaStore.Audio.Media.DURATION
)

val cursor = contentResolver.query(
    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
    projection,
    null,
    null,
    MediaStore.Audio.Media.TITLE + " ASC"
)

cursor?.use {
    while (it.moveToNext()) {
        val title = it.getString(it.getColumnIndex(MediaStore.Audio.Media.TITLE))
        val artist = it.getString(it.getColumnIndex(MediaStore.Audio.Media.ARTIST))
        // 处理歌曲信息
    }
}

嗯,这里有个性能问题。如果媒体库里有几千首歌,一次性查询全部数据会占用大量内存。我建议分页查询,或者只查询当前需要展示的数据。

18.3.2 写入自定义元数据

有时候我们需要给媒体文件打上自己的标签,比如「我喜欢这首歌」。MediaStore 允许写入自定义字段,但要注意权限。

val values = ContentValues().apply {
    put(MediaStore.Audio.Media.IS_FAVORITE, 1) // 标记为喜欢
}

contentResolver.update(
    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
    values,
    "${MediaStore.Audio.Media._ID} = ?",
    arrayOf(songId.toString())
)

小技巧:如果只是应用内部使用的标记(比如「我的收藏」),我建议存在 Room 里,不要污染 MediaStore。MediaStore 的字段是全局共享的,其他应用也可能修改。

18.4 三种存储方式的选型对比

特性 SharedPreferences Room MediaStore
数据类型 基本类型 + String 任意对象(通过实体映射) 媒体文件元数据
查询能力 按 key 查询 SQL 查询,支持复杂条件 按列查询,支持排序
线程安全 不保证 支持协程/Flow 需要自行处理
数据共享 仅本应用 仅本应用 系统级共享
推荐场景 配置、偏好 业务数据、列表 音乐、视频标签

最后说一句:数据持久化没有银弹。选型时多想想你的数据会被谁用、多久用一次、丢了能不能接受。想清楚这些,代码怎么写都是对的。

总结:SharedPreferences 存配置,Room 存业务,MediaStore 管媒体。三者各司其职,别混着用。记住我在项目里踩过的坑,你就能少走弯路。

公众号:蓝海资料掘金营,微信deep3321