数据库迁移与类型转换器——Room 里的“变形金刚”

Room 是个好东西,但数据库不可能一成不变。我见过太多项目,初期设计时只放了几个简单字段,等到版本迭代、需求变更,数据库结构说改就得改。这时候如果不懂迁移策略,轻则用户数据全丢,重则 app 直接闪退。今天我们就聊聊 Room 里的数据库迁移(Migration)和类型转换器(@TypeConverter)。说白了,就是让数据结构能“安全进化”,同时能把复杂对象塞进 SQLite 那个只认基本类型的“小盒子”里。

核心要点: 迁移保证旧版本数据不丢失;类型转换器让 Room 识别复杂类型。
这两招配合使用,你的离线缓存才算真正稳了。

1. 为什么需要迁移?

你想想看,App 发版后,用户手机上的数据库还是老版本。如果新版本 Room 实体类加了字段、改了类型、甚至整个表结构重构了,直接打开旧数据库就会报 IllegalStateException。Room 的做法很简单:它会把旧数据库版本号和当前实体定义的版本号对比,如果不一致,就找对应版本的 Migration 对象去执行 SQL 升级。

我曾经接手过一个项目,开发同学每次发版都直接 fallbackToDestructiveMigration(),结果用户数据被清空了好几次。这种“暴力升级”只适合开发阶段,线上不能这么干。

2. Migration 的基本写法

Room 的迁移策略其实就三步:

  1. 定义迁移对象,指明从哪个版本升到哪个版本
  2. 在 @Database 注解里通过 migrations 参数注册
  3. 更新数据库版本号
// 举个实际例子:从版本 1 到版本 2,user 表增加一个 age 列
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(@NonNull SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE user ADD COLUMN age INTEGER NOT NULL DEFAULT 0");
    }
};
小技巧: SQLite 的 ALTER TABLE 只能加列,不能改列名或类型。如果你需要重命名或改类型,就得创建新表、复制数据、删旧表、重命名,挺麻烦的。我会提前设计好扩展字段,比如存一个 JSON 字符串,避免频繁迁移。

然后这样注册:

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

每次升级都要写一个 Migration 对象。如果用户跨多个版本升级(比如从版本 1 直升到版本 3),Room 会自动按顺序执行所有匹配的 Migration:先执行 1→2,再执行 2→3。

千万注意: 迁移代码必须放到 migrate() 方法里手动执行 SQL,Room 不会自动帮你改结构。少写一个 ALTER TABLE,app 打开就会崩。另外,迁移中的 SQL 不要依赖 Room 的编译时检查,你写错 SQL 不会报编译错误,运行时才崩溃。我第一次写迁移时把表名拼错了,测试没测到,发版后线上崩了半小时才反应过来……后来我都加 try-catch 日志了。

3. 类型转换器:把复杂类型“压”进 SQLite

SQLite 只支持 NULL、INTEGER、REAL、TEXT、BLOB 五种基本类型。但 Room 实体类里经常有 Date、List、Bitmap、自定义对象。怎么办?用 @TypeConverter 告诉 Room 怎么把一个 Java 对象转成基本类型(存库),以及怎么从基本类型恢复成对象(读库)。

简单说,就是给 Room 配两个“翻译员”。

3.1 基本使用

// 假设实体里有个 Date 类型的字段
public class User {
    @PrimaryKey
    public long id;
    public String name;
    public Date birthday;  // Room 默认不认识 Date
}

// 需要写一个转换器类,定义两个转换方法
public class DateConverter {
    @TypeConverter
    public static Long dateToTimestamp(Date date) {
        return date == null ? null : date.getTime();
    }

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

然后在 @Database 注解上或者 @Database 内部通过 @TypeConverters 注册:

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

这样就搞定了。Room 编译时会自动给所有用到了 Date 类型的地方插入转换逻辑。

3.2 复杂类型的存储:List、Map、自定义对象

存储一个 List 怎么办?最常见的方式是把 List 序列化成 JSON 字符串存成 TEXT 列,读的时候再反序列化回来。我习惯用 Gson 或者 Moshi 来处理。举个例子:

public class StringListConverter {
    private static final Gson gson = new Gson();

    @TypeConverter
    public static String fromStringList(List<String> list) {
        return gson.toJson(list);
    }

    @TypeConverter
    public static List<String> toStringList(String json) {
        Type type = new TypeToken<List<String>>() {}.getType();
        return gson.fromJson(json, type);
    }
}

自定义对象同理:

public class Address {
    public String city;
    public String street;
    public int zip;
}

public class AddressConverter {
    private static final Gson gson = new Gson();

    @TypeConverter
    public static String addressToJson(Address address) {
        return gson.toJson(address);
    }

    @TypeConverter
    public static Address jsonToAddress(String json) {
        return gson.fromJson(json, Address.class);
    }
}
我的经验: 要小心空值,转换方法里记得判 null。还有一个坑:如果实体字段用了 List<String>,这个 List 本身是接口,Gson 反序列化时需要用 TypeToken 指定具体类型,否则会抛异常。教训啊,我某次发版前没覆盖这个分支,导致部分用户一启动就崩溃。

4. 迁移与类型转换器的配合

有时候你会遇到这种情况:旧版本某个字段存储的是原始类型,新版本想改成复杂类型(比如从存储一个 int 状态码改成存储一个包含状态码和描述的 Json 对象)。这时候不光要改实体类,还得写迁移 SQL 把旧数据转成新格式。

// 假设版本 1 的 user 表有一个 status 字段(INTEGER),版本 2 改为 statusJson(TEXT)
// 迁移代码需要手动将旧整数转为 JSON 字符串
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(@NonNull SupportSQLiteDatabase database) {
        // 1. 创建新表
        database.execSQL("CREATE TABLE user_new (" +
                "id INTEGER PRIMARY KEY NOT NULL, " +
                "name TEXT, " +
                "statusJson TEXT NOT NULL DEFAULT '{\"code\":0,\"desc\":\"unknown\"}')");
        // 2. 复制数据,同时做转换
        database.execSQL("INSERT INTO user_new (id, name, statusJson) " +
                "SELECT id, name, '{\"code\":' || status || ',\"desc\":\"migrated\"}' FROM user");
        // 3. 删旧表
        database.execSQL("DROP TABLE user");
        // 4. 重命名新表
        database.execSQL("ALTER TABLE user_new RENAME TO user");
    }
};

嗯,这里要注意,SQLite 拼接字符串用 ||,和 Java 不一样。如果你数据量很大,建议在 Java 代码里分批处理,而不是在 SQL 里写太复杂的逻辑。我个人更倾向保留旧表字段,新增一个字段来存复杂对象,旧字段直接废弃,这样迁移更简单,也不容易出错。

5. 核心知识图谱

Room 数据库迁移与类型转换器知识结构 Room 数据库迁移 & 类型转换器 Room 数据库版本管理 Migration 迁移策略 版本升级 / 降级方案 @TypeConverter 复杂类型 ↔ 基本类型 ALERT TABLE 加列/加约束 CREATE+INSERT 迁移数据类型 DROP+RENAME 表结构调整 避免破坏性 迁移 测试覆盖 多版本路径 渐进式 逐版本升级 Date/Calendar Long 时间戳 List/Map JSON 字符串 自定义对象 Gson/Moshi 注意空值 判 null TypeToken 泛型处理 性能优化 避免大对象

6. 实际项目中的避坑清单

总结几个我亲身踩过的坑,列成表格,方便你对照自查:

场景 踩坑点 正确做法
多版本迁移顺序 直接写 1→3 的 Migration,跳过 2 写完整的 1→2、2→3,Room 会按顺序执行
类型转换空指针 从数据库读 null 时没判空,转换方法抛 NPE 转换方法都要判 null,返回 null
List 反序列化类型丢失 直接用 gson.fromJson(json, List.class),得到的是 List 使用 TypeToken 保留泛型信息
迁移 SQL 写错表名 线上用户打开 app 直接 crash 迁移代码必须加日志,并在测试环境中覆盖升级测试
实体类字段改名 直接重命名实体字段,没写迁移 SQL,旧数据里的列名对不上 要么写迁移改列名(较复杂),要么保留旧字段并标记 @Deprecated
个人建议: 如果你项目还在快速迭代期,数据库版本号控制从 1 开始就行。每次发版前,记得写一个集成测试用例,模拟从上一个版本升级到当前版本,验证数据完整性。我团队现在每次提 PR 都要跑一遍迁移测试,至少避免了两次线上事故。

好啦,关于 Room 的数据库迁移和类型转换器就聊到这儿。记住:迁移是让数据结构安全进化的“桥梁”,类型转换器是让 Room 理解复杂对象的“翻译官”。这两样东西用好了,你的离线缓存方案才算具备了真正的工业级可靠性。