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.ABORTON CONFLICT ABORT冲突时中止当前语句,回滚事务(若在事务中),抛出异常。
OnConflictStrategy.FAILON CONFLICT FAIL冲突时中止当前语句,但保留先前语句影响(不常用)。
OnConflictStrategy.IGNOREON CONFLICT IGNORE跳过冲突行(不插入不改动),继续执行后续插入。
OnConflictStrategy.REPLACEON CONFLICT REPLACE删除冲突的旧行,再插入新行。
OnConflictStrategy.ROLLBACKON CONFLICT ROLLBACK冲突时回滚整个事务(Room 不推荐,通常被 ABORT 替代)。

实际缓存增量更新中最常用的是 REPLACE 和 IGNORE。本书章节聚焦于 REPLACE,因为它能原子化地完成“存在即覆盖”的语义,适合从远端拉取最新数据直接替换本地缓存。

4.2 替换行为深度解析

当 DAO 方法声明为 @Insert(onConflict = OnConflictStrategy.REPLACE) 时,Room 在编译阶段生成对应的 SQL 语句为 INSERT OR REPLACE INTO。SQLite 的 INSERT OR REPLACE 执行流程如下:

  1. 检测冲突:尝试插入新行,发现唯一约束(主键或 UNIQUE 索引)冲突。
  2. 删除旧行:先删除数据库中已存在的冲突行(包括该行的所有字段)。
  3. 插入新行:将新提供的整行数据插入表中。
  4. 自增主键影响:如果表使用了 INTEGER PRIMARY KEY AUTOINCREMENT,旧行被删除后新插入行的自增 ID 可能会发生变化(通常比旧 ID 大,取决于当前序列号)。这是 REPLACE 与 @Update 最大的差异点。
  5. 外键关联:如果旧行被其他表引用,且外键约束为 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) 在一次冲突后的完整执行路径:

@Insert(onConflict=REPLACE) 冲突处理流程 1. 执行 INSERT 2. 检测唯一约束冲突 无冲突: 直接插入成功 3. 删除冲突旧行 4. 插入新行 覆盖成功

4.6 常见错误与避坑

  • 自增主键不可靠:使用 AUTOINCREMENT 时,REPLACE 后自增 ID 会递增,可能导致之前缓存中的外键关系断裂。解决方案:使用业务主键(如 UUID、组合键)代替自增主键。
  • 非冲突列被清空:实体中未显式赋值的字段(或为 null 的字段)会覆盖数据库对应列的值。务必在传入前将实体所有字段填充完整。
  • 触发器与级联删除:如果表上定义了 DELETE 触发器,REPLACE 会触发该触发器(因为先删后插)。对于外键 ON DELETE CASCADE,可能导致关联表数据丢失。
  • 与 @Upsert 的混淆:Room 2.5.0 之后提供了 @Upsert 注解,它内部也是使用 REPLACE 策略,但行为相同。建议新项目直接使用 @Upsert 获得更清晰的语义。
稳定性提示: 在批量插入大量数据时(例如首次同步全量缓存),建议使用 @Transaction 包裹批量 REPLACE 操作,避免每次替换都触发独立事务。Room 默认每个 @Insert 调用都是独立事务,批量时性能会明显下降。