8、Room进阶:TypeConverter、关系映射、Migration、预填充数据库、测试、实战:复杂数据存储

Room 的基础用法,说白了就是增删改查。但真实项目里,数据哪有那么规矩?你存个 Date 对象,存个 List,甚至存个自定义的 Java 对象,Room 直接就懵了——它只认识基本类型和它们的一层包装。

嗯,这时候就得请出 TypeConverter 了。我个人习惯把 TypeConverter 看作是 Room 和复杂类型之间的翻译官。你告诉它怎么把复杂对象转成 Room 能存的基本类型,再告诉它怎么读回来,就完事了。

8.1 TypeConverter:让 Room 理解复杂类型

举个例子,你有个 User 实体,里面有个 Date 类型的 birthday 字段。Room 不认识 Date,怎么办?写个转换器:

public class Converters {
    @TypeConverter
    public static Long fromDate(Date date) {
        return date == null ? null : date.getTime();
    }

    @TypeConverter
    public static Date toDate(Long timestamp) {
        return timestamp == null ? null : new Date(timestamp);
    }
}

然后在 Database 类上加上注解:

@Database(entities = {User.class}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
    // ...
}

搞定。Room 看到 Date 字段,会自动调用你的转换器。我在项目中遇到过有人把整个 Bitmap 对象存进数据库,结果转换器里写的是 Base64 编码——性能差到离谱。后来我建议只存图片路径,或者用 FileProvider 管理,才把问题解决。

注意:TypeConverter 方法必须是静态的,且不能有重载。Room 通过方法参数和返回值类型来匹配,写错了它不会报编译错误,但运行时会直接崩。

8.2 关系映射:别被“关系”吓到

Room 不支持直接的对象引用,比如你有个 Order 实体,里面想直接放一个 List,不行。Room 的设计哲学是“你只管存数据,关系你自己维护”。

那怎么处理一对多、多对多的关系?用嵌套查询或者 @Relation 注解。

举个例子,一个用户有多条订单:

public class UserWithOrders {
    @Embedded
    public User user;

    @Relation(
        parentColumn = "uid",
        entityColumn = "userId"
    )
    public List<Order> orders;
}

然后在 DAO 里写个查询:

@Transaction
@Query("SELECT * FROM user")
public List<UserWithOrders> getUsersWithOrders();

@Transaction 是关键。不加它,Room 会报错,因为关联查询需要在一个事务里执行。我记得有次上线前测试,发现某个页面数据老是少几条,排查了半天,就是忘了加 @Transaction。

小技巧:@Relation 支持中间表。比如多对多关系,你可以在 @Relation 里指定 associateBy,通过中间表来关联两个实体。

8.3 Migration:数据库升级不丢数据

App 发出去之后,数据库结构不可能一成不变。加个字段、改个表名,都是常事。Room 的 Migration 机制就是用来处理这个的。

假设你的数据库从版本 1 升到版本 2,要加一个 age 字段:

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE user ADD COLUMN age INTEGER NOT NULL DEFAULT 0");
    }
};

然后在构建 Database 时加上:

Room.databaseBuilder(context, AppDatabase.class, "my-db")
    .addMigrations(MIGRATION_1_2)
    .build();

如果你没写 Migration,Room 会直接抛异常,或者根据 fallbackToDestructiveMigration() 策略把表删了重建。我曾经在测试环境里手滑,没写 Migration 就升级了版本,结果测试数据全没了,被测试妹子追着骂了一下午。

避坑指南:ALTER TABLE 只能加字段,不能改字段类型,也不能加 NOT NULL 约束(除非有默认值)。如果你要改结构,只能新建表,把数据迁过去,再删旧表。

8.4 预填充数据库:App 安装就有数据

有些 App 第一次打开就需要展示一些基础数据,比如城市列表、分类标签。你不可能让用户等网络请求。Room 支持从 assets 目录下的预置数据库文件直接加载。

做法很简单:

  1. 在 assets 目录下放一个 databases/ 文件夹,里面放你的 .db 文件
  2. 构建时调用 createFromAsset()
Room.databaseBuilder(context, AppDatabase.class, "my-db")
    .createFromAsset("databases/my-preloaded.db")
    .build();

注意,预填充数据库的版本号必须和 Room 的版本号一致,否则 Room 会认为数据不匹配,直接重建。我建议你用一个脚本生成预填充数据库,每次改结构都重新生成一次,避免版本对不上。

提示:如果你用的是 Room 2.3.0 以上版本,还可以用 createFromFile() 从文件系统加载预填充数据库,适合那些需要从服务器下载初始数据的场景。

8.5 测试:Room 代码必须测

数据库代码不测试,等于裸奔。Room 的测试其实很好写,因为它是纯本地操作,不依赖 Android 组件。

我一般用 AndroidX Test 里的 Room.inMemoryDatabaseBuilder 来创建内存数据库:

@RunWith(AndroidJUnit4.class)
public class UserDaoTest {
    private AppDatabase database;
    private UserDao userDao;

    @Before
    public void setUp() {
        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
        database = Room.inMemoryDatabaseBuilder(context, AppDatabase.class).build();
        userDao = database.userDao();
    }

    @After
    public void tearDown() {
        database.close();
    }

    @Test
    public void insertAndQuery() {
        User user = new User("张三", 25);
        userDao.insert(user);
        List<User> users = userDao.getAll();
        assertEquals(1, users.size());
        assertEquals("张三", users.get(0).getName());
    }
}

内存数据库的好处是快,每次测试完自动销毁,不会污染数据。我在 CI 流水线里跑这些测试,每次提交代码都会自动跑一遍,确保 DAO 没写错。

核心要点:测试要覆盖增删改查、关联查询、Migration 升级。特别是 Migration,你可以在测试里模拟从版本 1 到版本 2 的升级,验证数据是否完整迁移。

8.6 实战:复杂数据存储

最后,我们把这些知识点串起来,做一个实战案例:存储一个“用户订单详情”,里面包含用户信息、订单列表、每个订单的商品列表。

结构是这样的:

  • User 表:uid, name, birthday
  • Order 表:orderId, userId, totalPrice, createTime
  • Product 表:productId, orderId, name, price, count

我们用 TypeConverter 处理 Date 和 BigDecimal:

public class Converters {
    @TypeConverter
    public static Long fromDate(Date date) {
        return date == null ? null : date.getTime();
    }

    @TypeConverter
    public static Date toDate(Long timestamp) {
        return timestamp == null ? null : new Date(timestamp);
    }

    @TypeConverter
    public static String fromBigDecimal(BigDecimal value) {
        return value == null ? null : value.toPlainString();
    }

    @TypeConverter
    public static BigDecimal toBigDecimal(String value) {
        return value == null ? null : new BigDecimal(value);
    }
}

然后定义关联查询的 POJO:

public class UserWithOrdersAndProducts {
    @Embedded
    public User user;

    @Relation(
        parentColumn = "uid",
        entityColumn = "userId"
    )
    public List<OrderWithProducts> orders;
}

public class OrderWithProducts {
    @Embedded
    public Order order;

    @Relation(
        parentColumn = "orderId",
        entityColumn = "orderId"
    )
    public List<Product> products;
}

DAO 里写一个查询:

@Transaction
@Query("SELECT * FROM user WHERE uid = :userId")
public UserWithOrdersAndProducts getUserDetail(String userId);

这样一次查询就能拿到用户、他的所有订单、每个订单的商品。性能上,Room 会帮你做嵌套查询,但如果你数据量特别大,建议分页加载,别一次性全查出来。

嗯,这个实战案例我在一个电商 App 里用过,效果还不错。唯一要注意的是,嵌套层级不要太深,三层以上基本就该考虑用其他方案了,比如直接查两次再手动组装。

总结一下:TypeConverter 解决类型问题,@Relation 解决关联查询,Migration 解决版本升级,预填充解决初始数据,测试保证代码质量。把这几个点吃透,Room 在复杂场景下也能游刃有余。
Room 进阶知识体系 Room 进阶 TypeConverter Date / BigDecimal / 自定义对象 @TypeConverter 注解 关系映射 @Embedded + @Relation 一对多 / 多对多 Migration 版本升级不丢数据 ALTER TABLE / 数据迁移 预填充数据库 createFromAsset() 预置 .db 文件 测试 inMemoryDatabaseBuilder DAO / Migration 测试 实战:复杂数据存储 用户 + 订单 + 商品 嵌套关联查询

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