数据库迁移与类型转换器——Room 里的“变形金刚”
Room 是个好东西,但数据库不可能一成不变。我见过太多项目,初期设计时只放了几个简单字段,等到版本迭代、需求变更,数据库结构说改就得改。这时候如果不懂迁移策略,轻则用户数据全丢,重则 app 直接闪退。今天我们就聊聊 Room 里的数据库迁移(Migration)和类型转换器(@TypeConverter)。说白了,就是让数据结构能“安全进化”,同时能把复杂对象塞进 SQLite 那个只认基本类型的“小盒子”里。
这两招配合使用,你的离线缓存才算真正稳了。
1. 为什么需要迁移?
你想想看,App 发版后,用户手机上的数据库还是老版本。如果新版本 Room 实体类加了字段、改了类型、甚至整个表结构重构了,直接打开旧数据库就会报 IllegalStateException。Room 的做法很简单:它会把旧数据库版本号和当前实体定义的版本号对比,如果不一致,就找对应版本的 Migration 对象去执行 SQL 升级。
我曾经接手过一个项目,开发同学每次发版都直接 fallbackToDestructiveMigration(),结果用户数据被清空了好几次。这种“暴力升级”只适合开发阶段,线上不能这么干。
2. Migration 的基本写法
Room 的迁移策略其实就三步:
- 定义迁移对象,指明从哪个版本升到哪个版本
- 在
@Database注解里通过migrations参数注册 - 更新数据库版本号
// 举个实际例子:从版本 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");
}
};
然后这样注册:
@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);
}
}
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. 核心知识图谱
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 |
好啦,关于 Room 的数据库迁移和类型转换器就聊到这儿。记住:迁移是让数据结构安全进化的“桥梁”,类型转换器是让 Room 理解复杂对象的“翻译官”。这两样东西用好了,你的离线缓存方案才算具备了真正的工业级可靠性。