Android ContentProvider使用

ContentProvider

  • 前言
  • 一、ContentProvider是什么?
  • 二、使用步骤
    • 1.访问方式
    • 2.创建ContentProvider
      • 2.1. 构建ContentProvider步骤
      • 2.2. Uri讲解
      • 2.3. 实现 ContentProvider 类
    • 3.实现 onCreate() 方法
    • 4.查询(query)
    • 5.插入
    • 6.修改
    • 7.删除
    • 8.实现内容提供程序 MIME 类型(getType())
      • 8.1 表的 MIME 类型
      • 8.1 文件的 MIME 类型
    • 9.给ContentProvider增加权限
    • 9.在清单文件中配置ContentProvider
  • 三、完整示例:
    • 1. 简单的利用SQLite创建数据库
    • 2. ContentProvider的完整实现
    • 3. ContentProvider的客户端使用
  • 总结


前言

我们需要了解ContentProvider最好的方式就是查看Android官方文档:
ContentProvider基础知识.

通常ContentProvider有两种使用场景

  1. 一种是通过实现代码访问其他应用中的现有内容提供程序;
  2. 另一种是在应用中创建新的内容提供程序,从而与其他应用共享数据。

一、ContentProvider是什么?

内容提供程序以一个或多个表的形式将数据呈现给外部应用,这些表与关系型数据库中的表类似。

行表示提供程序收集的某种类型数据的实例,行中的每一列表示为一个实例所收集的单个数据。

内容提供程序有助于应用管理其自身和其他应用所存储数据的访问,并提供与其他应用共享数据的方法。

内容提供程序是一种标准接口,可将一个进程中的数据与另一个进程中运行的代码进行连。通过配置内容提供程序,您可以使其他应用安全地访问和修改您的应用数据

内容提供程序如何管理存储空间访问的概览图
Android ContentProvider使用_第1张图片

二、使用步骤

1.访问方式

访问内容提供程序中的数据,客户端可以使用应用的 Context 中的 ContentResolver 对象与提供程序进行通信。

ContentResolver 对象会与提供程序对象(即实现 ContentProvider 的类的实例)通信。

ContentResolver 方法可提供持久性存储空间的基本“CRUD”(创建、检索、更新和删除)功能。

2.创建ContentProvider

2.1. 构建ContentProvider步骤

1、为数据设计原始存储
内容提供程序以两种方式提供数据:

  • 文件数据:通常存储在文件中的数据,如照片、音频或视频。将文件存储在应用的私有空间内。您的提供程序可以应其他应用发出的文件请求提供文件句柄。
  • “结构化”数据:通常存储在数据库、数组或类似结构中的数据。

2、定义 ContentProvider 类及其所需方法的具体实现。
3、定义提供程序的授权字符串、该字符串的内容 URI 以及列名称。

2.2. Uri讲解

一、内容URI

内容 URI 用来在提供程序中标识数据。内容 URI 包括整个提供程序的符号名称(其授权)和指向表的名称(路径)。

ContentResolver 对象会解析出 URI 的授权,并将该授权与已知提供程序的系统表进行比较,从而“解析”提供程序。

ContentProvider 使用内容 URI 的路径部分选择需访问的表。 通常,提供程序会为其公开的每个表显示一条路径

content://user_dictionary/words

user_dictionary 字符串是提供程序的授权words 字符串是表的路径

字符串 content://(架构)始终显示,并且会将其标识为内容 URI。

二、设计URI

1、设计授权
提供程序通常拥有单一授权,该授权充当其 Android 内部名称。为避免与其他提供程序发生冲突,您应该使用互联网网域所有权(反向)作为提供程序授权的基础

例如:如果您的 Android 软件包名称为 com.example.appname,则应为提供程序提供 com.example.appname.provider 授权。

2、设计路径结构
通常,开发者会追加指向单个表格的路径,从而根据权限创建内容 URI。

例如,如果您有 table1 和 table2 两个表格,则可以通过合并上一示例中的授权来生成内容 URI
com.example.appname.provider/table1 和 com.example.appname.provider/table2。

3、处理内容URI ID
按照约定,提供程序会接受末尾拥有行 ID 值的内容 URI,进而提供对表内单个行的访问。
同样按照约定,提供程序会将该 ID 值与表的 _ID 列进行匹配,并对匹配的行执行请求访问。

4、内容 URI 模式
为帮助您选择对传入的内容 URI 执行的操作,提供程序 API 加入了便利类 UriMatcher,它会将内容 URI“模式”映射为整型值。您可以在 switch 语句中使用这些整型值,为匹配特定模式的一个或多个内容 URI 选择所需操作。

4.1、内容 URI 模式使用以下通配符匹配内容 URI

  • *匹配由任意长度的任何有效字符组成的字符串
  • #匹配由任意长度的数字字符组成的字符串

4.2、以设计和编码内容 URI 处理为例,假设某个拥有授权 com.example.app.provider 的提供程序能识别以下指向表的内容 URI

  • content://com.example.app.provider/table1:一个名为 table1 的表
  • content://com.example.app.provider/table2/dataset1:一个名为 dataset1 的表
  • content://com.example.app.provider/table2/dataset2:一个名为 dataset2 的表
  • content://com.example.app.provider/table3:一个名为 table3 的表

提供程序也可识别追加了行 ID 的内容 URI:
content://com.example.app.provider/table3/1:对应 table3 中的 1 所标识行的内容 URI。

4.3、可以使用以下内容 URI 模式

  • content://com.example.app.provider/* :匹配提供程序中的任何内容 URI。
  • content://com.example.app.provider/table2/*:根据4.2的内容可知,匹配表 dataset1 和表 dataset2 的内容 URI,但不匹配 table1 或 table3 的内容 URI
  • content://com.example.app.provider/table3/#:匹配 table3 中单个行的内容 URI,例如 content://com.example.app.provider/table3/6:对应 6 所标识行的内容 URI

以下代码段展示了 UriMatcher 中方法的工作方式。

代码采用不同方式来处理整个表的 URI 与单个行的 URI,它为整张表使用的内容 URI 模式是 content:///,为单个行使用的内容 URI 模式则是 content:////

addURI() 方法会将授权和路径映射为整型值match() 方法会返回 URI 的整型值。switch 语句会根据match方法返回的整型值选择查询整个表或者查询表中单个记录

举例:跟后续的代码无关

// 1. Creates a UriMatcher object.
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

// 2. 添加URI到UriMatcher
static {
   // 访问table3整个表的内容,将1跟跟整个表的URI进行映射
   uriMatcher.addURI("com.example.app.provider", "table3", 1);
   // 访问table3中单个记录,#匹配由任意长度的数字字符组成的字符串
   // 将数字2跟单个记录访问的URI进行映射
   uriMatcher.addURI("com.example.app.provider", "table3/#", 2);
}

// 3. 对比Uri,然后进行各自正确的操作
switch (uriMatcher.match(uri)) {
    case 1:
        if (TextUtils.isEmpty(sortOrder)) sortOrder = "_ID ASC";
      break;
    case 2:
         selection = selection + "_ID = " + uri.getLastPathSegment();
       break;
    default:
          // 如果条件不满足可以再次抛出异常提醒调用者
}

2.3. 实现 ContentProvider 类

ContentProvider 实例会处理其他应用发送的请求,从而管理对结构化数据集的访问。

所有形式的访问最终都会调用 ContentResolver,后者接着通过调用 ContentProvider 的具体方法来获取访问权限

抽象类 ContentProvider 定义了六个抽象方法,您必须将其作为具体子类的一部分加以实现。

query()
从提供程序中检索数据。使用参数选择要查询的表、要返回的行和列以及结果的排序顺序。将数据作为 Cursor 对象返回。

query() 参数 SELECT 关键字/参数 备注
Uri FROM table_name Uri 映射至提供程序中名为 table_name 的表。
projection col,col,col,… projection 是检索到的每个行所应包含的列的数组。
selection WHERE col = value selection 指定选择行的条件。
selectionArgs (没有完全等效项,选择参数会替换选择子句中的 ? 占位符。)
sortOrder ORDER BY col,col,… sortOrder 指定在返回的 Cursor 中各行的显示顺序。

insert()
在提供程序中插入新行。使用参数选择目标表并获取要使用的列值。返回新插入行的内容 URI。

update()
更新提供程序中的现有行。使用参数选择要更新的表和行,并获取更新后的列值。返回已更新的行数。

delete()
从提供程序中删除行。使用参数选择要删除的表和行。返回已删除的行数。

getType()
返回内容 URI 对应的 MIME 类型。

onCreate()
初始化提供程序。创建提供程序后,Android 系统会立即调用此方法。请注意,只有在 ContentResolver 对象尝试访问您的提供程序时,系统才会创建它。

注意事项:

  • 所有这些方法(onCreate() 除外)均可由多个线程同时调用,因此它们必须是线程安全的方法。
  • 避免在 onCreate() 中执行冗长的操作。将初始化任务推迟到实际需要时执行。
  • 尽管继承ContentProvider必须实现上述所有方法,但代码中只需要实现需要的操作,而无需执行任何其他操作。例如,你可能想防止其他应用向某些表插入数据。如要实现此目的,你可以忽略 insert() 调用并返回 0

3.实现 onCreate() 方法

Android 系统会在启动提供程序时调用 onCreate()。

此方法中你应该只执行快速运行的初始化任务,并将数据库创建和数据加载推迟到提供程序实际收到数据请求时进行

如果您在 onCreate() 中执行冗长的任务,则会减慢提供程序的启动速度。反之,这将减慢提供程序对其他应用的响应速度。

代码如下(示例):
Android ContentProvider使用_第2张图片

4.查询(query)

ContentProvider.query() 方法必须返回 Cursor 对象,如果失败,系统会抛出 Exception。

果您使用 SQLite 数据库作为数据存储,则只需返回由 SQLiteDatabase 类的某个 query() 方法返回的 Cursor。

如果查询不匹配任何行,则您应该返回一个 Cursor 实例(其 getCount() 方法返回 0)。只有当查询过程中出现内部错误时,您才应该返回 null。

Android 系统必须能够跨进程边界传达 Exception。

  • IllegalArgumentException(您可以选择在提供程序收到无效的内容 URI 时抛出此异常)
  • NullPointerException

代码如下(示例):
Android ContentProvider使用_第3张图片

5.插入

insert() 方法会使用 ContentValues 参数中的值,向相应表中添加新行。如果 ContentValues 参数中未包含列名称,可能希望在提供程序代码或数据库模式中提供其默认值。

此方法应返回新行的内容 URI。如要构造此方法,请使用 withAppendedId() 向表的内容 URI 追加新行的 _ID(或其他主键)值。

代码如下(示例):
Android ContentProvider使用_第4张图片

6.修改

update() 方法与 insert() 采用相同的 ContentValues 参数,并且该方法与 delete() 及 ContentProvider.query() 采用相同的 selection 和 selectionArgs 参数。

代码如下(示例):
Android ContentProvider使用_第5张图片

7.删除

delete() 方法无需从您的数据存储中实际删除行。 如果您将同步适配器与提供程序一起使用,则应考虑为已删除的行添加“删除”标志,而不是完全移除行。同步适配器可以检查是否存在已删除的行,并将这些行从服务器中移除,然后再将其从提供程序中删除。

代码如下(示例):
Android ContentProvider使用_第6张图片

8.实现内容提供程序 MIME 类型(getType())

ContentProvider 类拥有两个返回 MIME 类型的方法:

  • getType():任何ContentProvider程序都必须实现。
  • getStreamTypes():当提供程序提供文件时,系统要求您实现的方法。

8.1 表的 MIME 类型

对于指向一行或多行表数据的内容 URI,getType() 应该以 Android 供应商特有的 MIME 格式返回 MIME 类型:

  • 类型部分:vnd
  • 子类型部分:
    • 如果 URI 模式用于单个行:android.cursor.item/
    • 如果 URI 模式用于多个行:android.cursor.dir/
  • 提供程序特有部分:vnd..
    您提供 值应具有全局唯一性, 值应在对应的 URI 模式中具有唯一性。适合选择贵公司的名称或应用 Android 软件包名称的某个部分作为 。适合选择 URI 关联表的标识字符串作为

如果提供程序的授权是 com.example.app.provider(),并且它公开了名为 table1() 的表。

类型.子类型部分/内容提供者特有部分

  • table1 中多个行的 MIME 类型为:
// 类型.子类型部分/内容提供者特有部分
vnd.android.cursor.dir/vnd.com.example.provider.table1
  • table1 的单个行,MIME 类型为:
vnd.android.cursor.item/vnd.com.example.provider.table1

代码如下(示例)

@Override
    public String getType(@NonNull Uri uri) {
        int match = sUriMatcher.match(uri);
        switch (match) {
            // 1. 必须以vnd开头
            // 2. 如果内容URI以路径结尾,则后接android.cursor.dir/,
            // 如果内容URI以id结尾,则后接android.cursor.item/
            // 3. 最后接上vnd..
            case GESTURE_DIR:
                return "vnd.android.cursor.dir/vnd." + AUTHORITY + ".gesture";
            case GESTURE_ITEM:
                return "vnd.android.cursor.item/vnd." + AUTHORITY + ".gesture";
            default:
                throw new IllegalArgumentException(String.format("Unknown URI: %s", uri));
        }
    }

8.1 文件的 MIME 类型

  • 如果ContentProvider程序以 .jpg、.png 和 .gif 格式的文件形式提供照片图像。如果应用在调用 ContentResolver.getStreamTypes() 时使用过滤器字符串 image/*(任何“图像”内容),则 ContentProvider.getStreamTypes() 方法应返回数组:
{ "image/jpeg", "image/png", "image/gif"}
  • 如果ContentProvider只对 .jpg 文件感兴趣,则可以在调用 ContentResolver.getStreamTypes() 时使用过滤器字符串 *\/jpeg,并且 ContentProvider.getStreamTypes() 应返回:
{"image/jpeg"}

9.给ContentProvider增加权限

android官网提供了ContentProvider:实现内容提供程序权限

简单的讲一下常用的两个(更多的内容请查看上方提供的官网链接):

  • 统一的读写提供程序级权限
    一种同时控制对整个提供程序进行读取和写入访问的权限(通过 元素的 android:permission 属性指定)。

例如:

com.example.app.provider.permission.READ_PROVIDER
  • 单独的读写提供程序级权限
    针对整个提供程序的读取权限和写入权限。您可以通过 元素的 android:readPermission 属性和 android:writePermission 属性指定这些权限。这些权限优先于 android:permission 所需的权限

  • 还有路径级权限临时权限请查看:实现内容提供程序权限

9.在清单文件中配置ContentProvider

授权 (android:authorities)
用于在系统内标识整个ContentProvider的符号名称。

提供程序类名 ( android:name )
实现的ContentProvider的完整路径。
例如:com.example.app.provider.MyContentProvider

权限: 指定其他应用访问提供程序数据时所须的权限

  • android:grantUriPermssions:临时权限标志。
  • android:permission:统一提供程序范围读取/写入权限。
  • android:readPermission:提供程序范围读取权限。
  • android:writePermission:提供程序范围写入权限。

启动和控制属性

  • android:enabled:允许系统启动提供程序的标志。
  • android:exported:允许其他应用使用此提供程序的标志。
  • android:initOrder:在同一进程中,此提供程序相对于其他提供程序的启动顺序。
  • android:multiProcess:允许系统在与调用客户端相同的进程中启动提供程序的标志。
  • android:process:供提供程序运行的进程的名称。
  • android:syncable:指示提供程序的数据将与服务器上的数据进行同步的标志。

信息属性: 提供程序的可选图标和标签:

  • android:icon:包含提供程序图标的可绘制对象资源。该图标会出现在应用列表 (Settings > Apps > All) 提供程序的标签旁边。
  • android:label:描述提供程序和/或其数据的信息标签。该标签会出现在应用列表 (Settings > Apps > All) 中。

例如在AndroidManifest.xml中配置

<provider
     android:name="com.example.app.MyContentProvider"
     android:authorities="com.example.app.provider"
     android:enabled="true"
     android:exported="true"
android:permission="com.example.app.provider.permission.READ_PROVIDER" />

三、完整示例:

1. 简单的利用SQLite创建数据库

package com.google.mediapipe.examples.hands.service;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

import androidx.annotation.Nullable;

public class GestureSqliteHelper extends SQLiteOpenHelper {
    private static final String CREATE_GESTURE = "create table if not exists Gesture ("
            + "id integer primary key autoincrement,"
            + "gesture_switch integer,"
            + "gesture_result integer)";

    public GestureSqliteHelper(@Nullable Context context, @Nullable String name,
                               @Nullable SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_GESTURE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

    }
}

2. ContentProvider的完整实现

package com.google.mediapipe.examples.hands.service;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.util.List;

public class GestureContentProvider extends ContentProvider {
    private static final String TAG = "GestureContentProvider";
    private static final String DB_NAME = "Hands.db";
    private static final String TABLE_GESTURE = "Gesture";
    private static final int GESTURE_DIR = 0;
    private static final int GESTURE_ITEM = 1;
    private static final String AUTHORITY = "com.google.mediapipe.examples.hands.provider";
    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

    static {
        sUriMatcher.addURI(AUTHORITY, "gesture", GESTURE_DIR);
        sUriMatcher.addURI(AUTHORITY, "gesture/#", GESTURE_ITEM);
    }

    private GestureSqliteHelper mDbHelper;

    @Override
    public boolean onCreate() {
        Context context = getContext();
        if (context == null) {
            return false;
        }
        mDbHelper = new GestureSqliteHelper(context, DB_NAME, null, 1);
        return true;
    }

    @Nullable
    @Override
    public String getType(@NonNull Uri uri) {
        int match = sUriMatcher.match(uri);
        switch (match) {
            // 1. 必须以vnd开头
            // 2. 如果内容URI以路径结尾,则后接android.cursor.dir/,
            // 如果内容URI以id结尾,则后接android.cursor.item/
            // 3. 最后接上vnd..
            case GESTURE_DIR:
                return "vnd.android.cursor.dir/vnd." + AUTHORITY + ".gesture";
            case GESTURE_ITEM:
                return "vnd.android.cursor.item/vnd." + AUTHORITY + ".gesture";
            default:
                throw new IllegalArgumentException(String.format("Unknown URI: %s", uri));
        }
    }

    @Nullable
    @Override
    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
                        @Nullable String[] selectionArgs, @Nullable String sortOrder) {
        // 查询数据
        SQLiteDatabase database = mDbHelper.getReadableDatabase();
        Cursor cursor = null;
        switch (sUriMatcher.match(uri)) {
            case GESTURE_DIR:
                cursor = database.query(TABLE_GESTURE, projection, selection, selectionArgs,
                        null, null, sortOrder);
                cursor.setNotificationUri(getContext().getContentResolver(), uri);
                break;
            case GESTURE_ITEM:
                List<String> segments = uri.getPathSegments();
                if (segments.size() < 2) {
                    return null;
                }
                String gestureId = segments.get(1);
                cursor = database.query(TABLE_GESTURE, projection, "id=?", new String[]{gestureId},
                        null, null, sortOrder);
                cursor.setNotificationUri(getContext().getContentResolver(), uri);
                break;
        }
        return cursor;
    }

    @Nullable
    @Override
    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
        SQLiteDatabase database = mDbHelper.getWritableDatabase();
        Uri uriReturn = null;
        switch (sUriMatcher.match(uri)) {
            case GESTURE_DIR:
            case GESTURE_ITEM:
                long gestureId = database.insert(TABLE_GESTURE, null, values);
                uriReturn = Uri.parse("content://" + AUTHORITY + "/gesture/" + gestureId);
                getContext().getContentResolver().notifyChange(uriReturn, null);
                break;
        }
        return uriReturn;
    }

    @Override
    public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
        SQLiteDatabase database = mDbHelper.getWritableDatabase();
        int deleteRows = 0;
        switch (sUriMatcher.match(uri)) {
            case GESTURE_DIR:
                deleteRows = database.delete(TABLE_GESTURE, selection, selectionArgs);
                getContext().getContentResolver().notifyChange(uri, null);
                break;
            case GESTURE_ITEM:
                List<String> segments = uri.getPathSegments();
                if (segments.size() < 2) {
                    return deleteRows;
                }
                String gestureId = segments.get(1);
                deleteRows = database.delete(TABLE_GESTURE, "id=?", new String[]{gestureId});
                getContext().getContentResolver().notifyChange(uri, null);
                break;
        }
        return deleteRows;
    }

    @Override
    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
        SQLiteDatabase database = mDbHelper.getWritableDatabase();
        int updateRows = 0;
        switch (sUriMatcher.match(uri)) {
            case GESTURE_DIR:
                updateRows = database.update(TABLE_GESTURE, values, selection, selectionArgs);
                getContext().getContentResolver().notifyChange(uri, null);
                break;
            case GESTURE_ITEM:
                List<String> segments = uri.getPathSegments();
                if (segments.size() < 2) {
                    return updateRows;
                }
                String gestureId = segments.get(1);
                updateRows = database.update(TABLE_GESTURE, values, "id=?", new String[]{gestureId});
                getContext().getContentResolver().notifyChange(uri, null);
                break;
        }
        return updateRows;
    }
}

3. ContentProvider的客户端使用

常量URI和字段名

private static final String TAB_NAME = "gesture";
private static final String AUTHORITY = "com.google.mediapipe.examples.hands.provider";
public static final Uri URI_GESTURE_SWITCH_DIR = Uri.parse("content://" + AUTHORITY + "/" + TAB_NAME);
public static final Uri URI_GESTURE_SWITCH_ITEM = Uri.parse("content://" + AUTHORITY + "/" + TAB_NAME + "/1");

// 数据库字段名
public static final String COLUMN_ID = "id";
public static final String COLUMN_GESTURE_SWITCH = "gesture_switch";

通过ContentProvider插入数据

// 通过ContentProvider插入数据
private void providerInsert() {
        runDatabase(() -> {
            ContentValues values = new ContentValues();
            // 数据库字段名+当前字段值
            values.put(COLUMN_GESTURE_SWITCH, 0);
            Uri insertUri = getContentResolver().insert(URI_GESTURE_SWITCH_DIR, values);
            Log.d(TAG, "providerInsert:: " + insertUri);
        });
    }

通过ContentProvider更新数据

private void providerUpdate() {
        runDatabase(() -> {
            ContentValues values = new ContentValues();
            values.put(COLUMN_GESTURE_SWITCH, 0);
            int updateId = getContentResolver().update(URI_GESTURE_SWITCH_ITEM, values, null,
                    null);
            Log.d(TAG, "providerUpdate:: " + updateId);
        });
    }

通过ContentProvider查询数据

private void providerQuery() {
        runDatabase(() -> {
            Cursor cursor = getContentResolver().query(URI_GESTURE_SWITCH_DIR, null, null,
                    null, null);
            if (cursor != null) {
                while (cursor.moveToNext()) {
                    long id = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_ID));
                    int switchStatus = cursor.getInt(cursor.getColumnIndexOrThrow(COLUMN_GESTURE_SWITCH));
                    Log.d(TAG, "providerQuery id:: " + id + " ,switchStatus:: " + switchStatus);
                }
                cursor.close();
            }
        });
    }

通过ContentProvider删除数据

private void providerDelete() {
        runDatabase(() -> {
            int deleteId = getContentResolver().delete(URI_GESTURE_SWITCH_ITEM, null, null);
            Log.d(TAG, "providerDelete:: " + deleteId);
        });
    }

总结

以上就是本文要讲的内容,本文仅仅是简单的介绍了ContentProvider的概念和使用,而需要了解ContentProvider更多的用法可以查看Android官方文档。
ContentProvider创建已经用法.

参考:
ContentProvider讲解
Android - 内容提供者(Content Provider)

你可能感兴趣的:(Android学习总结,android,android)