21、本地数据存储:Room数据库集成、DataStore(Preferences与Proto)、SharedPreferences迁移

本地数据存储,说白了就是让你的App在没网的时候也能干活。用户关掉App再打开,数据还在,这才是正经的移动应用。

我个人习惯把本地存储分成两类:结构化数据键值对数据。前者用Room,后者用DataStore。至于SharedPreferences?嗯,它已经过时了,但很多老项目还在用,所以迁移方案也得讲清楚。

21.1 Room数据库:结构化数据的首选

Room是Google官方推出的ORM框架,底层封装了SQLite。我刚开始用的时候觉得它有点重,但用久了发现——真香。编译时检查SQL语句,省了多少运行时崩溃啊。

21.1.1 添加依赖

// build.gradle.kts (Module)
plugins {
    id("kotlin-kapt") // 或者 id("com.google.devtools.ksp")
}

dependencies {
    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    kapt("androidx.room:room-compiler:$roomVersion")
}
小提示: 新项目建议用KSP替代kapt,编译速度快一倍。我迁移过一个项目,从kapt换到KSP,增量编译时间从40秒降到了18秒。

21.1.2 定义实体和DAO

@Entity(tableName = "users")
data class User(
    @PrimaryKey val id: Int,
    val name: String,
    val age: Int,
    val email: String?
)

@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllUsers(): Flow<List<User>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)
}

注意看,getAllUsers()返回的是Flow。这意味着只要数据库里的users表有变化,UI会自动更新。我在项目中就用这个特性做了实时搜索,用户体验非常好。

21.1.3 创建数据库

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao

    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,
                    "my_app_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}
注意: 数据库实例一定要用单例模式。我曾经见过一个项目,每次访问数据库都重新创建实例,结果内存泄漏加上ANR,用户反馈差到爆。

21.2 DataStore:SharedPreferences的现代替代品

DataStore有两种口味:Preferences DataStoreProto DataStore。前者存键值对,后者存类型化对象。我个人更推荐Proto DataStore,因为它类型安全,而且支持数据迁移。

21.2.1 Preferences DataStore

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

// 读取
val themeMode: Flow<String> = context.dataStore.data.map { preferences ->
    preferences[PreferencesKeys.THEME_KEY] ?: "light"
}

// 写入
suspend fun setThemeMode(mode: String) {
    context.dataStore.edit { preferences ->
        preferences[PreferencesKeys.THEME_KEY] = mode
    }
}

private object PreferencesKeys {
    val THEME_KEY = stringPreferencesKey("theme_mode")
}

你看,DataStore天然支持Flow,这意味着你可以直接在Compose里用collectAsState()。SharedPreferences做不到这一点,你得手动注册监听器,麻烦得很。

21.2.2 Proto DataStore

Proto DataStore需要先定义protobuf文件。别怕,其实很简单。

// user_preferences.proto
syntax = "proto3";

option java_package = "com.example.app";
option java_multiple_files = true;

message UserPreferences {
    string theme_mode = 1;
    bool notifications_enabled = 2;
    int32 font_size = 3;
}

然后在build.gradle里配置protobuf插件,生成Java类。接着创建DataStore:

val Context.userPreferencesStore by dataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer
)

class UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences
        get() = UserPreferences.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): UserPreferences {
        return UserPreferences.parseFrom(input)
    }

    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
        t.writeTo(output)
    }
}
核心优势: Proto DataStore的数据是强类型的。你不可能把一个Int存到String字段里,编译期就报错了。SharedPreferences那种"存的时候是String,取的时候当Int用"的坑,再也不会遇到了。

21.3 SharedPreferences迁移

老项目肯定有SharedPreferences。直接删掉?不行,用户数据会丢。我做过一次迁移,踩了不少坑,这里把经验分享给你。

21.3.1 迁移到DataStore

DataStore官方提供了迁移API,可以直接从SharedPreferences迁移过来。

val Context.dataStore by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context,
                "old_shared_prefs_name"
            )
        )
    }
)

就这么简单?嗯,基本是这样。但要注意:迁移是一次性的。一旦迁移完成,SharedPreferences里的数据就不会再被读取了。所以迁移前一定要做好备份。

21.3.2 迁移到Room

如果SharedPreferences里存的是结构化数据(比如用户信息列表),那应该迁移到Room。我一般写一个迁移脚本:

class MigrationTask(private val context: Context) {
    suspend fun migrate() {
        val prefs = context.getSharedPreferences("user_data", Context.MODE_PRIVATE)
        val json = prefs.getString("users", null) ?: return

        val gson = Gson()
        val users: List<User> = gson.fromJson(json, object : TypeToken<List<User>>() {}.type)

        val db = AppDatabase.getInstance(context)
        users.forEach { user ->
            db.userDao().insertUser(user)
        }

        // 迁移完成后删除旧数据
        prefs.edit().remove("users").apply()
    }
}
避坑指南: 我曾经在迁移时忘记处理并发问题。用户同时打开两个页面,都触发了迁移,结果数据重复了。解决方案:用synchronized或者Mutex保证迁移只执行一次。

21.4 知识体系总览

下面这张图帮你理清本地数据存储的选型逻辑:

本地数据存储选型决策树 本地数据存储 结构化数据 键值对数据 Room数据库 DataStore Preferences DataStore Proto DataStore SharedPreferences(已过时) 迁移

21.5 实战建议

说了这么多,到底怎么选?我给你一个简单的判断标准:

  • 数据有关系吗? 比如用户和订单是一对多关系 → 用Room
  • 数据只是简单配置? 比如主题、语言、开关 → 用DataStore
  • 需要类型安全? 用Proto DataStore
  • 老项目有SharedPreferences? 迁移到DataStore,别犹豫
我的习惯: 新项目一律Room + Proto DataStore。Room管业务数据,Proto DataStore管配置。SharedPreferences?让它留在历史里吧。

你想想看,用户的数据安全是第一位的。选对了存储方案,后面维护起来省心得多。我见过太多项目因为一开始图省事用了SharedPreferences存复杂数据,结果后面改得痛不欲生。

好了,本地数据存储就讲到这里。记住:选型比实现更重要。花10分钟想清楚用哪个,能省你后面10小时的改bug时间。


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