7、插入操作详解:insert() 方法实现、返回 URI、批量插入优化

插入操作,说白了就是往 Content Provider 里塞数据。这活儿看起来简单,但坑不少。我见过太多人把 insert() 当成简单的 SQL INSERT 来写,结果要么性能拉胯,要么返回的 URI 格式不对,导致客户端拿不到正确的数据路径。

今天咱们就把 insert() 的里里外外掰扯清楚。从最基础的实现,到返回 URI 的规范,再到批量插入的优化策略,一条龙讲透。

7.1 insert() 方法的标准实现

先看一个最基础的 insert() 实现长什么样。我习惯把模板代码先摆出来,再讲细节。

@Override
public Uri insert(Uri uri, ContentValues values) {
    final SQLiteDatabase db = mDbHelper.getWritableDatabase();
    final int match = sUriMatcher.match(uri);
    Uri returnUri;

    switch (match) {
        case CODE_ITEMS:
            long id = db.insert(TABLE_ITEMS, null, values);
            if (id > 0) {
                returnUri = ContentUris.withAppendedId(
                    ItemsEntry.CONTENT_URI, id);
            } else {
                throw new SQLException("插入失败,uri: " + uri);
            }
            break;
        case CODE_ITEMS_WITH_CATEGORY:
            // 带分类参数的插入,稍后讲
            throw new UnsupportedOperationException(
                "暂不支持子路径插入");
        default:
            throw new IllegalArgumentException(
                "未知 URI: " + uri);
    }

    getContext().getContentResolver().notifyChange(
        returnUri, null);
    return returnUri;
}

这段代码有几个关键点,我一个个说。

7.1.1 参数校验不能省

很多新手会忽略 values 为 null 的情况。你想想看,如果客户端传了个 null 进来,db.insert() 会直接返回 -1。这时候你返回的 URI 就是错的。

我个人习惯在方法开头加一道校验:

if (values == null || values.size() == 0) {
    throw new IllegalArgumentException("values 不能为空");
}

别嫌麻烦。我在项目中遇到过因为 values 为空导致批量插入全部失败的情况,排查了半天才发现是某个业务方传了空值。

7.1.2 返回 URI 的规范

insert() 返回的 URI 不是随便拼的。它必须遵循 content://authority/path/id 这个格式。为什么?因为客户端拿到这个 URI 后,可以直接用它做后续的查询、更新、删除操作。

举个例子:

// 正确做法
Uri insertedUri = ContentUris.withAppendedId(
    ItemsEntry.CONTENT_URI, newId);

// 错误做法
Uri wrongUri = Uri.parse(
    "content://com.example.provider/items/" + newId);

用 ContentUris.withAppendedId() 的好处是,它会自动处理 URI 的编码问题。你自己拼字符串,万一 id 里带个特殊字符,就炸了。

7.2 批量插入的三种姿势

批量插入是性能优化的重灾区。我见过有人用 for 循环一条条 insert(),结果插入 1000 条数据花了十几秒。这显然不行。

下面我列出三种常见的批量插入方案,你根据场景选。

方案 适用场景 性能 复杂度
ContentProviderOperation 中等数据量(几百条) 中等
SQLite 事务 + 预编译 大数据量(几千条以上)
bulkInsert() 重写 框架级批量插入

7.2.1 方案一:ContentProviderOperation

这是 Android 官方推荐的方式。客户端用 ContentProviderOperation 构建一个操作列表,然后通过 ContentResolver.applyBatch() 一次性提交。

// 客户端代码
ArrayList<ContentProviderOperation> ops = 
    new ArrayList<>();

for (ItemData item : itemList) {
    ops.add(ContentProviderOperation
        .newInsert(ItemsEntry.CONTENT_URI)
        .withValues(item.toContentValues())
        .build());
}

ContentProviderResult[] results = 
    getContentResolver().applyBatch(
        AUTHORITY, ops);

这个方案的好处是,Provider 端不需要做额外处理。applyBatch() 默认会在一个事务里执行所有操作。但注意,如果数据量太大(比如上万条),这个方案会有点吃力,因为所有操作都在内存里构建。

7.2.2 方案二:SQLite 事务 + 预编译

这是我自己最常用的方案。说白了就是绕过 ContentProvider 的逐条调用,直接在 Provider 内部用 SQLite 事务批量处理。

@Override
public int bulkInsert(Uri uri, ContentValues[] valuesArray) {
    final SQLiteDatabase db = mDbHelper.getWritableDatabase();
    final int match = sUriMatcher.match(uri);
    
    if (match != CODE_ITEMS) {
        throw new IllegalArgumentException(
            "bulkInsert 不支持该 URI");
    }

    db.beginTransaction();
    try {
        String sql = "INSERT INTO " + TABLE_ITEMS + 
            " (name, price, category) VALUES (?, ?, ?)";
        SQLiteStatement stmt = db.compileStatement(sql);

        for (ContentValues values : valuesArray) {
            stmt.bindString(1, 
                values.getAsString("name"));
            stmt.bindDouble(2, 
                values.getAsDouble("price"));
            stmt.bindString(3, 
                values.getAsString("category"));
            stmt.executeInsert();
            stmt.clearBindings();
        }

        db.setTransactionSuccessful();
    } finally {
        db.endTransaction();
    }

    getContext().getContentResolver().notifyChange(
        uri, null);
    return valuesArray.length;
}

这里有个细节:用 SQLiteStatement 预编译,比每次调用 db.insert() 快得多。我实测过,插入 5000 条数据,预编译方案比逐条 insert 快了将近 10 倍。

注意:bulkInsert() 默认不会触发 notifyChange()。你必须在方法末尾手动调用。否则客户端收不到数据变化通知,界面不会刷新。我曾经因为这个 bug 被测试追着打了三天。

7.2.3 方案三:重写 bulkInsert()

如果你需要更精细的控制,比如插入前做数据校验、去重,或者插入后更新缓存,那就重写 bulkInsert()。

@Override
public int bulkInsert(Uri uri, ContentValues[] valuesArray) {
    int insertedCount = 0;
    SQLiteDatabase db = mDbHelper.getWritableDatabase();
    
    db.beginTransaction();
    try {
        for (ContentValues values : valuesArray) {
            // 先检查是否已存在
            if (!exists(db, values)) {
                long id = db.insert(TABLE_ITEMS, null, values);
                if (id > 0) {
                    insertedCount++;
                }
            }
        }
        db.setTransactionSuccessful();
    } finally {
        db.endTransaction();
    }
    
    if (insertedCount > 0) {
        getContext().getContentResolver().notifyChange(
            uri, null);
    }
    return insertedCount;
}

这个方案的好处是灵活。你可以控制哪些数据真正被插入,哪些被跳过。但注意,不要在循环里做太重的操作,比如网络请求。事务会一直持有数据库锁,搞太久会影响其他线程的读写。

7.3 插入操作的避坑指南

嗯,这里我要说几个我踩过的坑。

坑一:忘记处理并发。多个线程同时 insert(),如果没有同步机制,可能会出现主键冲突或者数据错乱。我建议在 Provider 的 onCreate() 里启用 WAL 模式:db.enableWriteAheadLogging()
坑二:返回的 URI 没有包含正确的 ID。客户端拿到 URI 后,如果直接用它做 update 或 delete,会因为找不到记录而失败。记得用 ContentUris.withAppendedId()。
坑三:批量插入时没有关闭游标。如果你在 bulkInsert() 里做了查询操作,一定要确保游标被关闭。否则会触发 StrictMode 警告,严重时会导致应用崩溃。

7.4 知识体系总览

下面这张图把 insert() 的核心逻辑串起来了。你可以看到从客户端调用到 Provider 处理,再到数据库写入的完整链路。

insert() 操作核心流程 客户端调用 ContentResolver.insert() Provider 处理 insert() / bulkInsert() 数据库写入 SQLiteDatabase.insert() 批量插入分支 bulkInsert() 重写 ContentProviderOperation applyBatch() SQLite 事务 + 预编译 SQLiteStatement 自定义 bulkInsert() 去重 + 校验 返回 URI 规范 content://authority/path/id 通知数据变化 notifyChange()

从图上你可以看到,insert() 不是简单的「客户端→Provider→数据库」单向流程。它还包括 URI 的构建、数据变化的通知,以及批量插入时的多种优化路径。

我的建议:如果你的应用数据量不大(几百条以内),直接用 ContentProviderOperation 就够了。如果数据量上千,一定要用 bulkInsert() + 预编译。别偷懒,性能差距真的很大。

好了,insert() 的内容就讲到这里。记住三个核心点:URI 要规范、批量要用事务、通知不能忘。下一节我们聊 update() 和 delete(),这两个操作也有不少门道。


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