10、数据存储(下):Room数据库进阶

上一章我们把 Room 的基本用法过了一遍。说实话,那只是冰山一角。真正到了生产环境,你会发现事情远没那么简单——数据迁移、版本管理、还有那个让人又爱又恨的 SharedPreferences。今天我们就来把这些硬骨头啃掉。

10.1 DAO 的进阶玩法

DAO 是 Room 的灵魂。我见过太多人把 DAO 写成简单的 CRUD,其实它能做的事情远不止这些。

10.1.1 复杂查询与返回类型

你想想看,实际项目中哪有那么多简单的查询?往往是多表关联、分组统计、模糊搜索。Room 的 DAO 支持直接返回 LiveData 或 Flow,这样数据变化时 UI 会自动更新。

@Dao
public interface UserDao {
    // 返回 LiveData,自动监听变化
    @Query("SELECT * FROM users WHERE age > :minAge")
    LiveData<List<User>> getUsersOlderThan(int minAge);

    // 返回 Flow,Kotlin 协程友好
    @Query("SELECT * FROM users WHERE name LIKE '%' || :keyword || '%'")
    Flow<List<User>> searchUsers(String keyword);

    // 多表关联查询
    @Query("SELECT u.name, o.orderId, o.totalPrice " +
           "FROM users u INNER JOIN orders o ON u.id = o.userId " +
           "WHERE u.id = :userId")
    LiveData<List<UserOrder>> getUserOrders(int userId);
}

嗯,这里要注意:返回 LiveData 时,Room 会在后台线程自动执行查询,然后通过主线程通知观察者。你不需要自己处理线程切换,这点很贴心。

10.1.2 事务处理

我在项目中遇到过一个问题:同时插入订单和更新用户余额,结果订单写进去了,余额更新失败了。数据就不一致了。这时候就需要事务。

@Dao
public interface OrderDao {
    @Transaction
    @Query("SELECT * FROM users WHERE id = :userId")
    LiveData<UserWithOrders> getUserWithOrders(int userId);

    @Transaction
    default void placeOrder(Order order, User user) {
        insertOrder(order);
        updateUserBalance(user);
    }

    @Insert
    void insertOrder(Order order);

    @Update
    void updateUserBalance(User user);
}

@Transaction 注解保证里面的操作要么全部成功,要么全部回滚。我曾经因为没加这个注解,导致线上数据出了大问题,从那以后我再也不敢偷懒了。

核心要点:DAO 中的 @Transaction 可以保证多个数据库操作的一致性。如果只是单条 SQL,Room 会自动加事务,不需要你手动处理。

10.2 Entity 的高级特性

Entity 不只是简单的数据类。Room 提供了很多注解来帮你处理复杂的数据结构。

10.2.1 嵌套对象与类型转换器

有时候一个字段需要存储复杂对象,比如地址信息。Room 不支持直接存储对象,但你可以用 @TypeConverters 来解决。

public class Address {
    public String city;
    public String street;
    public String zipCode;
}

public class Converters {
    @TypeConverter
    public static Address fromString(String value) {
        // 假设用 JSON 序列化
        return new Gson().fromJson(value, Address.class);
    }

    @TypeConverter
    public static String fromAddress(Address address) {
        return new Gson().toJson(address);
    }
}

@Entity
@TypeConverters(Converters.class)
public class User {
    @PrimaryKey
    public int id;
    public String name;
    public Address address;  // 自动转换
}

说白了,TypeConverter 就是告诉 Room:「这个字段怎么存、怎么读」。我习惯把所有的转换器放在一个类里统一管理,这样代码更清晰。

10.2.2 索引与唯一约束

数据库查询慢,很多时候是因为没加索引。Room 支持在 Entity 上直接定义索引。

@Entity(indices = {
    @Index(value = "name"),
    @Index(value = {"city", "street"}, unique = true)
})
public class User {
    @PrimaryKey
    public int id;
    public String name;
    public String city;
    public String street;
}

unique = true 表示这个组合值不能重复。我在做用户系统时,就用它来保证邮箱和手机号的唯一性,比在代码里判断要靠谱得多。

小技巧:索引不是越多越好。写操作多的表,索引太多反而会拖慢性能。一般只在查询频繁的字段上加索引。

10.3 Database 的版本管理

App 更新了,数据库结构变了,怎么办?Room 提供了版本管理和迁移机制。

10.3.1 定义迁移

每次数据库结构变化,你都需要写一个 Migration 对象,告诉 Room 怎么从旧版本升级到新版本。

@Database(entities = {User.class, Order.class}, version = 2)
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
    public abstract OrderDao orderDao();

    static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            // 添加新表
            database.execSQL(
                "CREATE TABLE IF NOT EXISTS `orders` (" +
                "`orderId` INTEGER PRIMARY KEY AUTOINCREMENT, " +
                "`userId` INTEGER NOT NULL, " +
                "`totalPrice` REAL NOT NULL, " +
                "`createTime` INTEGER NOT NULL, " +
                "FOREIGN KEY(`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE" +
                ")"
            );
            // 添加新列
            database.execSQL("ALTER TABLE `users` ADD COLUMN `avatar` TEXT");
        }
    };
}

创建数据库时,把迁移策略传进去:

Room.databaseBuilder(context, AppDatabase.class, "my_database")
    .addMigrations(AppDatabase.MIGRATION_1_2)
    .build();

10.3.2 破坏性迁移

如果迁移写错了,或者用户从很老的版本直接升级,Room 会抛出异常。这时候可以用 destructiveMigrationFallback 来兜底——直接删掉旧数据重建。

Room.databaseBuilder(context, AppDatabase.class, "my_database")
    .fallbackToDestructiveMigration()
    .build();
警告:fallbackToDestructiveMigration 会清空所有数据!只适合开发阶段或数据不重要的情况。生产环境一定要写完整的 Migration。

我曾经在测试环境用过这个,结果忘了改回来就发布了。用户数据全没了,那叫一个惨。嗯,从那以后我每次发布前都会检查 migration 配置。

10.4 DataStore 替代 SharedPreferences

SharedPreferences 的问题大家都知道:不支持异步、容易 ANR、类型不安全。Google 官方推荐用 DataStore 替代它。

10.4.1 Preferences DataStore

适合存储简单的键值对,比如用户设置、开关状态。

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

// 读取
val themeMode: Flow<Boolean> = context.dataStore.data
    .map { preferences ->
        preferences[booleanPreferencesKey("dark_mode")] ?: false
    }

// 写入
suspend fun setDarkMode(enabled: Boolean) {
    context.dataStore.edit { preferences ->
        preferences[booleanPreferencesKey("dark_mode")] = enabled
    }
}

DataStore 使用 Flow 来暴露数据,天然支持协程和生命周期感知。你想想看,SharedPreferences 的 commit 和 apply 那套东西,是不是瞬间就不香了?

10.4.2 Proto DataStore

如果你需要存储结构化数据,比如用户配置对象,可以用 Proto DataStore。它基于 Protocol Buffers,类型安全,性能更好。

// 定义 proto 文件
message UserPreferences {
    bool dark_mode = 1;
    int32 font_size = 2;
    string language = 3;
}

// 创建 DataStore
val Context.userPreferencesStore by protoDataStore(
    fileName = "user_preferences.pb",
    serializer = UserPreferencesSerializer
)

// 读取
val userPreferences: Flow<UserPreferences> = context.userPreferencesStore.data

// 写入
suspend fun updateFontSize(size: Int) {
    context.userPreferencesStore.updateData { preferences ->
        preferences.toBuilder()
            .setFontSize(size)
            .build()
    }
}
我的建议:简单键值对用 Preferences DataStore,复杂配置用 Proto DataStore。别一上来就用 Proto,它需要定义 proto 文件,学习成本稍高。

10.5 知识体系总览

下面这张图总结了本章的核心知识点,你可以把它当作一个快速参考。

Room 数据库进阶知识体系 DAO 进阶 • 复杂查询与返回类型 • LiveData / Flow 支持 • 多表关联查询 • @Transaction 事务 • 批量操作一致性 Entity 高级 • 嵌套对象处理 • @TypeConverters • 索引与唯一约束 • 性能优化 版本管理 • Migration 定义 • 版本升级策略 • 破坏性迁移兜底 • DataStore 替代 SP • Preferences / Proto 核心原则:类型安全、异步操作、数据一致性、平滑迁移

这张图把 Room 进阶的三个核心模块串起来了。DAO 负责数据操作,Entity 定义数据结构,Database 管理版本和迁移。三者配合好了,你的数据层才能稳如磐石。

DataStore 这块,我建议你尽快把项目里的 SharedPreferences 替换掉。虽然迁移需要一点工作量,但长期来看绝对值得。毕竟,谁也不想因为一个简单的配置读写导致 ANR 吧?

避坑指南:我曾经在迁移 SharedPreferences 到 DataStore 时,忘了处理旧数据的兼容。结果用户升级后,之前的设置全丢了。正确的做法是:先读取旧 SP 数据,写入 DataStore,再删除 SP 文件。这个过程最好在后台线程完成。

好了,这一章的内容就到这里。Room 的进阶用法其实还有很多,比如预填充数据库、多数据库实例、测试辅助等。但掌握了今天讲的这些,你已经能应对绝大多数生产场景了。

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