4. 使用 @Insert(onConflict=REPLACE) 实现覆盖更新
当客户端需要将本地变更同步到远端缓存,或从服务端批量拉取数据写入 Room 时,理想情况是所有插入操作都不会遇上主键或唯一索引冲突。但实际场景中,同一主键的缓存记录可能已被其他线程更新,或本地存在待合并的脏数据。Room 在 @Insert 注解上提供了 onConflict 参数,用来声明冲突时的处理策略。OnConflictStrategy.REPLACE 是最常用的覆盖更新方案——它等价于 SQL 层的 INSERT OR REPLACE,让插入操作在冲突时先删除旧行、再插入新行,从而实现“覆盖更新”的效果。
4.1 注解参数说明
@Insert 注解的完整签名为:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.CLASS)
public @interface Insert {
OnConflictStrategy onConflict() default OnConflictStrategy.ABORT;
}
onConflict 默认值为 ABORT,即遇到冲突时直接中止事务并抛出异常(SQLiteConstraintException)。Room 支持的 OnConflictStrategy 选项如下表所示:
| 策略常量 | SQL 等价 | 行为描述 |
|---|---|---|
OnConflictStrategy.ABORT | ON CONFLICT ABORT | 冲突时中止当前语句,回滚事务(若在事务中),抛出异常。 |
OnConflictStrategy.FAIL | ON CONFLICT FAIL | 冲突时中止当前语句,但保留先前语句影响(不常用)。 |
OnConflictStrategy.IGNORE | ON CONFLICT IGNORE | 跳过冲突行(不插入不改动),继续执行后续插入。 |
OnConflictStrategy.REPLACE | ON CONFLICT REPLACE | 删除冲突的旧行,再插入新行。 |
OnConflictStrategy.ROLLBACK | ON CONFLICT ROLLBACK | 冲突时回滚整个事务(Room 不推荐,通常被 ABORT 替代)。 |
实际缓存增量更新中最常用的是 REPLACE 和 IGNORE。本书章节聚焦于 REPLACE,因为它能原子化地完成“存在即覆盖”的语义,适合从远端拉取最新数据直接替换本地缓存。
4.2 替换行为深度解析
当 DAO 方法声明为 @Insert(onConflict = OnConflictStrategy.REPLACE) 时,Room 在编译阶段生成对应的 SQL 语句为 INSERT OR REPLACE INTO。SQLite 的 INSERT OR REPLACE 执行流程如下:
- 检测冲突:尝试插入新行,发现唯一约束(主键或 UNIQUE 索引)冲突。
- 删除旧行:先删除数据库中已存在的冲突行(包括该行的所有字段)。
- 插入新行:将新提供的整行数据插入表中。
- 自增主键影响:如果表使用了
INTEGER PRIMARY KEY AUTOINCREMENT,旧行被删除后新插入行的自增 ID 可能会发生变化(通常比旧 ID 大,取决于当前序列号)。这是REPLACE与@Update最大的差异点。 - 外键关联:如果旧行被其他表引用,且外键约束为
ON DELETE CASCADE,则删除旧行会级联删除关联行,导致数据丢失。使用REPLACE时务必确认外键配置。
与 @Update 不同,REPLACE 会覆盖整行(所有列),而不是仅更新传入的非空字段。这意味着即使新实体对象中某些字段为 null(或默认值),数据库中该列也会被置为 null。因此在使用 REPLACE 时,必须保证实体对象中的每个字段都包含了期望的最终值。
@Update 只会更新传入实体非 null 且与数据库不一致的列;@Insert(onConflict=REPLACE) 会完全替换整行,包括 null 字段。
4.3 DAO 定义与代码示例
以下是一个典型缓存表的实体和 DAO 定义:
@Entity(tableName = "repo_cache")
public class RepoCache {
@PrimaryKey
@NonNull
public String id; // 用业务主键代替自增ID
public String name;
public String description;
public long lastUpdated; // 时间戳,用于后续增量判断
}
@Dao
public interface RepoCacheDao {
// 使用 onConflict=REPLACE 实现覆盖更新
@Insert(onConflict = OnConflictStrategy.REPLACE)
void upsertRepo(RepoCache repo);
// 批量覆盖更新
@Insert(onConflict = OnConflictStrategy.REPLACE)
void upsertRepos(List repos);
@Query("SELECT * FROM repo_cache WHERE id = :id")
RepoCache getRepoById(String id);
}
注意这里实体使用 String id 作为 @PrimaryKey,避免了自增 ID 变化带来的问题。如果必须使用自增主键,REPLACE 后自增 ID 会递增,可能导致之前缓存的外键引用失效。
4.4 测试用例
下面编写一个基于 Room 的 MigrationTestHelper 的测试,验证 REPLACE 的覆盖行为:
@RunWith(AndroidJUnit4.class)
public class RepoCacheDaoTest {
private RepoCacheDao dao;
private RepoCache originalRepo;
private RepoCache updatedRepo;
@Before
public void setUp() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
RepoCacheDatabase db = Room.inMemoryDatabaseBuilder(context, RepoCacheDatabase.class)
.allowMainThreadQueries()
.build();
dao = db.repoCacheDao();
// 插入原始数据
originalRepo = new RepoCache();
originalRepo.id = "repo_java_1";
originalRepo.name = "Old Name";
originalRepo.description = "Old description";
originalRepo.lastUpdated = 100L;
dao.upsertRepo(originalRepo);
}
@Test
public void testReplace_shouldOverwriteAllFields() {
// 准备新实体,只改变 name 和 lastUpdated,description 故意设为 null
updatedRepo = new RepoCache();
updatedRepo.id = "repo_java_1"; // 相同主键
updatedRepo.name = "New Name";
updatedRepo.description = null; // 为 null 将覆盖旧 description
updatedRepo.lastUpdated = 200L;
dao.upsertRepo(updatedRepo);
// 验证数据库中的整行数据
RepoCache result = dao.getRepoById("repo_java_1");
assertEquals("New Name", result.name);
assertNull("description should be null after replace", result.description);
assertEquals(200L, result.lastUpdated);
// 注意:如果使用了自增主键,result.id 不会被覆盖,但此处我们使用业务主键
assertEquals("repo_java_1", result.id);
}
}
上述测试运行后,description 字段变为 null,验证了 REPLACE 是用新对象完整替换旧行,而不是选择性更新。
REPLACE,而应使用 @Update 或编写自定义的“合并查询”。REPLACE 更适用于服务端数据完全覆盖本地缓存的场景。
4.5 覆盖更新流程示意
下图展示了 @Insert(onConflict=REPLACE) 在一次冲突后的完整执行路径:
4.6 常见错误与避坑
- 自增主键不可靠:使用
AUTOINCREMENT时,REPLACE后自增 ID 会递增,可能导致之前缓存中的外键关系断裂。解决方案:使用业务主键(如 UUID、组合键)代替自增主键。 - 非冲突列被清空:实体中未显式赋值的字段(或为 null 的字段)会覆盖数据库对应列的值。务必在传入前将实体所有字段填充完整。
- 触发器与级联删除:如果表上定义了
DELETE触发器,REPLACE会触发该触发器(因为先删后插)。对于外键ON DELETE CASCADE,可能导致关联表数据丢失。 - 与
@Upsert的混淆:Room 2.5.0 之后提供了@Upsert注解,它内部也是使用REPLACE策略,但行为相同。建议新项目直接使用@Upsert获得更清晰的语义。
@Transaction 包裹批量 REPLACE 操作,避免每次替换都触发独立事务。Room 默认每个 @Insert 调用都是独立事务,批量时性能会明显下降。