4. CRUD 操作基础:四大核心方法的实现与参数详解

好,咱们进入 ContentProvider 最核心的部分了。

说白了,ContentProvider 就是个数据中间人。你想想看,A 进程想访问 B 进程的数据,总不能直接连数据库吧?这时候就需要 ContentProvider 站出来,提供一套标准接口。这套接口就是 CRUD——增、删、改、查。

我个人习惯把这四个方法叫做「四大金刚」。今天咱们就把它们拆开揉碎了讲清楚。

4.1 query():查询数据

query() 是使用频率最高的方法。我刚开始做 ContentProvider 时,以为它和 SQLiteDatabase 的 query() 差不多。后来发现,坑还挺多。

先看方法签名:

public abstract Cursor query(Uri uri, String[] projection, String selection,
                             String[] selectionArgs, String sortOrder)

参数一个一个说:

  • uri:操作的数据路径。比如 content://com.example.provider/user/1 表示 id 为 1 的用户。
  • projection:要查询的列名。传 null 表示查所有列。但我建议别传 null,显式指定列名能提升性能。
  • selection:查询条件。比如 "name = ?"
  • selectionArgs:条件参数。对应 selection 里的问号。
  • sortOrder:排序方式。比如 "age DESC"

重点来了:query() 返回的是 Cursor。Cursor 是个游标,指向结果集。用完必须关闭,否则会内存泄漏。我见过太多新手忘记关 Cursor,导致应用越用越卡。

实际项目中,我一般这样写:

@Override
public Cursor query(Uri uri, String[] projection, String selection,
                    String[] selectionArgs, String sortOrder) {
    SQLiteDatabase db = dbHelper.getReadableDatabase();
    int match = uriMatcher.match(uri);
    
    switch (match) {
        case USER_DIR:
            return db.query(TABLE_USER, projection, selection, 
                           selectionArgs, null, null, sortOrder);
        case USER_ITEM:
            String where = "_id = ?";
            String[] whereArgs = new String[]{uri.getLastPathSegment()};
            return db.query(TABLE_USER, projection, where, 
                           whereArgs, null, null, sortOrder);
        default:
            throw new IllegalArgumentException("Unknown URI: " + uri);
    }
}

我的经验:query() 里尽量用 getReadableDatabase(),别用 getWritableDatabase()。读操作没必要拿写锁,能提升并发性能。

4.2 insert():插入数据

insert() 负责把数据写进去。方法签名:

public abstract Uri insert(Uri uri, ContentValues values)

参数就两个:

  • uri:目标路径。注意,插入操作通常用目录型 URI,比如 content://provider/user
  • values:要插入的数据,用 ContentValues 封装。它就是个键值对容器,key 是列名,value 是值。

返回值是 Uri,指向新插入的行。这个设计很巧妙——客户端拿到 Uri 后,可以直接用它去查询刚插入的数据。

我曾经踩过一个坑:ContentValues 里没传主键值,但表的主键是自增的。结果 insert() 返回的 Uri 里包含的主键值,和实际插入的不一致。后来发现是 ContentProvider 实现里忘了调用 ContentUris.withAppendedId()

正确的写法:

@Override
public Uri insert(Uri uri, ContentValues values) {
    SQLiteDatabase db = dbHelper.getWritableDatabase();
    int match = uriMatcher.match(uri);
    
    if (match != USER_DIR) {
        throw new IllegalArgumentException("Insert not supported for URI: " + uri);
    }
    
    long id = db.insert(TABLE_USER, null, values);
    if (id > 0) {
        Uri resultUri = ContentUris.withAppendedId(uri, id);
        getContext().getContentResolver().notifyChange(resultUri, null);
        return resultUri;
    }
    
    throw new SQLException("Failed to insert row into " + uri);
}

注意:insert() 成功后一定要调用 notifyChange()。否则客户端那边收不到数据变化通知,界面不会刷新。这个坑我至少帮三个同事排查过。

4.3 update():更新数据

update() 用来修改已有数据。方法签名:

public abstract int update(Uri uri, ContentValues values, 
                           String selection, String[] selectionArgs)

参数和 query() 很像,多了一个 values:

  • values:要更新的字段和值。只传需要修改的列就行。
  • selection/selectionArgs:定位要更新的行。

返回值是受影响的行数。这个数字很有用——如果返回 0,说明没有匹配到任何行。

我个人习惯在 update() 里做两件事:

  1. 校验 values 不能为空
  2. 更新后通知数据变化
@Override
public int update(Uri uri, ContentValues values, String selection,
                  String[] selectionArgs) {
    SQLiteDatabase db = dbHelper.getWritableDatabase();
    int match = uriMatcher.match(uri);
    int count;
    
    switch (match) {
        case USER_ITEM:
            String id = uri.getLastPathSegment();
            String where = "_id = ?";
            String[] whereArgs = new String[]{id};
            count = db.update(TABLE_USER, values, where, whereArgs);
            break;
        case USER_DIR:
            count = db.update(TABLE_USER, values, selection, selectionArgs);
            break;
        default:
            throw new IllegalArgumentException("Unknown URI: " + uri);
    }
    
    if (count > 0) {
        getContext().getContentResolver().notifyChange(uri, null);
    }
    
    return count;
}

避坑指南:我曾经在 update() 里忘了拼接 WHERE 条件,结果一次更新了整张表。嗯,从那以后我每次写 update() 都会先检查 selection 参数是否合理。对于单行更新,我强烈建议用 USER_ITEM 这种带 ID 的 URI,能避免误操作。

4.4 delete():删除数据

delete() 是四大金刚里最「危险」的一个。方法签名:

public abstract int delete(Uri uri, String selection, String[] selectionArgs)

参数和 update() 类似,但没有 values。返回值是删除的行数。

为什么说它危险?因为一旦 selection 传 null,整张表的数据就没了。我见过生产环境出过这种事故——客户端传了个空 selection,服务端直接清空了用户表。

所以我的实现里,会加一层保护:

@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
    SQLiteDatabase db = dbHelper.getWritableDatabase();
    int match = uriMatcher.match(uri);
    int count;
    
    switch (match) {
        case USER_ITEM:
            String id = uri.getLastPathSegment();
            count = db.delete(TABLE_USER, "_id = ?", new String[]{id});
            break;
        case USER_DIR:
            // 防止误删全表
            if (selection == null || selection.isEmpty()) {
                throw new IllegalArgumentException(
                    "Delete all rows is not allowed. Use a specific URI.");
            }
            count = db.delete(TABLE_USER, selection, selectionArgs);
            break;
        default:
            throw new IllegalArgumentException("Unknown URI: " + uri);
    }
    
    if (count > 0) {
        getContext().getContentResolver().notifyChange(uri, null);
    }
    
    return count;
}

重要提醒:delete() 和 update() 一样,成功后要 notifyChange()。另外,我建议在 ContentProvider 里统一做权限校验——不是所有客户端都能随便删数据的。

4.5 四大方法的共性要点

总结一下,这四个方法有几个共同点:

要点 说明
URI 匹配 每个方法都要先做 UriMatcher 匹配,确定操作的是哪张表、哪行数据
线程安全 ContentProvider 的方法可能被多个线程同时调用。SQLiteDatabase 本身是线程安全的,但如果你用了其他资源,记得加锁
数据通知 insert/update/delete 成功后,必须调用 notifyChange()。query() 不需要
异常处理 遇到非法 URI 或操作失败,抛出 IllegalArgumentException 或 SQLException

最后说一句:这四个方法写起来不难,但细节决定成败。我见过太多 ContentProvider 实现,要么忘了通知,要么 URI 匹配写错,要么 Cursor 没关。你想想看,跨进程数据源一旦出问题,排查起来比单进程麻烦得多。所以,写的时候多留个心眼,总没错。