Help us understand the problem. What is going on with this article?

ContentProvider

More than 5 years have passed since last update.

何故 ContentProvider を使用するのか

コンテンツプロバイダ (ContentProvider) と言えば、他アプリとのデータをやり取りをする為の仕組みであり SQLite を使用する為の最善の方法として知られているが、業務でこれを使用している場面を見ることはかなり少ない。何故なら SQLiteOpenHelper を直接使った方が遥かにシンプルで分かり易いからだ。あと、「他アプリとのデータのやり取り」というニーズが少なく、使用する魅力に乏しいように思える。

筆者が実際に業務で見た SQLite の取り扱いは以下のようなものだ:

  • SQLiteOpenHelper を extends し Singleton に保ちつつ直に使用する
  • DAO パターンを使用する (SQL の入力・出力用の JavaBeans を作成し、 それぞれ詰め直して内部で SQLiteOpenHelper を使用して抽象的に投げる) このパターンを使うなら今は GreenDAO が有力
  • 専用ユーティリティクラスを作成し (SQLiteUtils みたいなの) 、 メソッド名で分けてクエリを投げる (初歩的だが一番多かった)

内部の SQLiteと のやり取りは、同時に 2, 3 回までなら問題になるほど時間がかかる事は無い為実行はメインスレッド (UI スレッド) で行われる事が多かった。が Android 公式的にはこれは推奨されないとし Cursor を扱う最善の手段として CursorLoader を用意している。そしてこの CursorLoader はコンテンツプロバイダとやり取りする事を前提として設計されている。

コンテンツプロバイダは処理の実行に content://(authority)/(path)/ content://(authority)/(path)/(_id) のような形式の URI を使用する。authority の部分は全世界で唯一となるような名前が推奨されており、path の部分は SQLite の場合はテーブル名になるのだろうと思われる。query, insert, update, delete のいずれもこれを使用する。例えば以下のような感じだ:

// ベースとなるCONTENT_URI. com.kojion.testアプリのtestテーブルを対象とする
Uri contentUri = Uri.parse("content://com.kojion.test.provider/test");

// testテーブルの_idが1のレコードを取得する
Cursor cursor = getContentResolver().query(ContentUris.withAppendedId(contentUri, 1), null, null, null, null);

// testテーブルの全レコードを取得する
Cursor cursor2 = getContentResolver().query(contentUri, null, null, null, null);

// testテーブルにレコードを投入する
// FIXME 本当はContentValuesに投入データを入れるが省略
Uri newUri = getContentResolver().insert(contentUri, new ContentValues());

// testテーブルの_idが1のレコードを更新する
// FIXME 本当はContentValuesに投入データを入れるが省略
int updatedCount = getContentResolver().update(ContentUris.withAppendedId(contentUri, 1), new ContentValues(), null, null);

// testテーブルの_idが1のレコードを削除する
int deletedCount = getContentResolver().delete(ContentUris.withAppendedId(contentUri, 1), null, null);

コンテンツプロバイダが適切に実装されていれば 上記のように実行できる。

以上により、コンテンツプロバイダを使用する事によるメリットは以下の点ではないかと思う:

  • 外部への公開 API の形式を変えずに内部の実装を変更できる
  • 上記のような操作を Uri 表現で書くことにより、 実装の内部を隠蔽できる (SQLite が推奨されるが、 別にプレーンテキストやファイルでも良いしその組み合わせでも良い)
  • 権限を適切に実装することにより、 内部からは登録や削除ができるが、 外部からはできないといった事も可能となる
  • ContentResolver.notifyChange() を使用する事により、 登録・更新・削除でデータが変更されたタイミングで自動で決まった処理を呼ぶ
  • CursorLoader 及び CursorAdapter のパワフルな機能を利用できる

総じて言うと行儀が良くなるという事だろう。一般的な Android アプリでサーバ内の DB との通信を行う場合、直接 DB とアクセスするのではなく WebAPI (REST) を実装してそれを介してアクセスするのと同じ事だ。

このコンテンツプロバイダなのだが適切に解説・考察されている日本語のページが本当に少ない。ので、改めてここで考察してみようと思う。

Android Developers からの引用と筆者の考察

1つのコンテンツプロバイダで複数テーブルのデータを扱うのは推奨されるのか?

A content provider presents data to external applications as one or more tables that are similar to the tables found in a relational database.

(コンテンツプロバイダは外部のアプリケーションに対し1つ若しくはもっと多くのテーブルの、RDBMSからテーブルを扱うのに似たデータを提供する)

ネット上や本などでみるどの実装例も「1 コンテンツプロバイダに対し 1 テーブル」のような実装になっていた為、 1 コンテンツプロバイダに対し複数テーブルを紐付ける (つまり _id は table1 や table2 か判別が付かないため、コンテンツプロバイダに対し _id が一意な値とならなくなる) のが問題かと思えたが、問題ないように見える。複数テーブルを扱う場合 CONTENT_URI も複数定義してそれを使用するべきか?

CONTENT_URI の定義

A content URI is a URI that identifies data in a provider. Content URIs include the symbolic name of the entire provider (its authority) and a name that points to a table (a path).

When you call a client method to access a table in a provider, the content URI for the table is one of the arguments.

CONTENT_URI は URI 表現の authority の部分と path の「テーブルの部分」まで と定義する。 例えば content://com.kojion.test.provider/test までを CONTENT_URI と定義する。 実際には content://com.kojion.test.provider/test/1 と後ろに _id をつけて Uri が使われるかもしれないが、 この場合も CONTENT_URI は content://com.kojion.test.provider/test となる。 この Uri は public static final で定義すべきとあるが 1 コンテンツプロバイダに複数 CONTENT_URI が存在する場合 (複数テーブルを扱う場合) は名前を変えて複数定義していいのだろうか?

専用の MIME タイプの定義

公式ドキュメントによると、 カスタマイズされた MIME タイプのフォーマットは (type) / (subtype) であり type の部分は単数の結果を返す場合は vnd.android.cursor.item 複数の場合は vnd.android.cursor.dir を使うとある。subtype の部分は vnd.(ベンダー名).(テーブル名) とすべきとの事。なので例えば「kojion ベンダーの test テーブルから複数を返したもの」の MIME タイプは vnd.android.cursor.dir/vnd.kojion.test となるはず。

すべてのテーブルに _id を primary key auto increment で定義

Table data should always have a "primary key" column that the provider maintains as a unique numeric value for each row. You can use this value to link the row to related rows in other tables (using it as a "foreign key").

Although you can use any name for this column, using BaseColumns._ID is the best choice, because linking the results of a provider query to a ListView requires one of the retrieved columns to have the name _ID.

コンテンツプロバイダだけでなく ListView や CursorAdapter の内部で _id という主キーがあるものとして動作している為こういう決まりだと割りきって定義しておく。

authority は他のプロバイダとかぶらない名前で

A provider usually has a single authority, which serves as its Android-internal name. To avoid conflicts with other providers, you should use Internet domain ownership (in reverse) as the basis of your provider authority.

Because this recommendation is also true for Android package names, you can define your provider authority as an extension of the name of the package containing the provider.

For example, if your Android package name is com.example., you should give your provider the authority com.example..provider.

authority というのは content://com.kojion.test.provider/test/1 でいうところの com.kojion.test.provider の事。他のどのプロバイダとも被らない一意な名前を付けるとある。アプリのパッケージ名を推奨されているので、例えばプロバイダのクラスの完全修飾名が com.kojion.test.SQLiteProvider であったならば com.kojion.test.sqliteprovider が良いのではないか。もしアプリ内で 1 つのみプロバイダを定義すると決まっている場合は、アプリ内での重複を気にしないでよくなるので com.kojion.test.provider でいい気がする。

という事は逆に言うとテーブルが複数ある SQLite に対しコンテンツプロバイダもその数だけ分割して、 それぞれ content://com.kojion.test.provider/table1 content://com.kojion.test.provider/table2 とアクセスするアプローチは 間違っている。 authority が一意に特定できないからだ。その場合は authority もそれぞれ別のものにしなければならない為、些か直感的でなくなる気がする。

ユーザからアクセスさせる為の定数クラス (contract class) を用意する

A contract class is a public final class that contains constant definitions for the URIs, column names, MIME types, and other meta-data that pertain to the provider.

The class establishes a contract between the provider and other applications by ensuring that the provider can be correctly accessed even if there are changes to the actual values of URIs, column names, and so forth.

コンテンツプロバイダの内部構造を知らないユーザはどのような URI が用意されていてどのようなカラムがあるかを知らない為そのままでは使用できない。その為定数クラスとして開示しておく、という事。形式に規定が無いのなら enum でも使用するのが良いのだろうか。

実装例

以下 URI パターンを content://(authority)/(table)content://(authority)/(table)/(_id) のみに絞った形での 1コンテンツプロバイダに対し複数テーブル の実装例である。もしもう少し込み入ったパターン ( content://(authority)/(table)/(_id)/(group)/(group_pos) のような) にも対応しようとするともう少し工夫が要る。

UriMatcher とは「指定された authority と path のパターンに合致したら指定した ID を返却する」 というマッピング作業を行なってくれる。ワイルドカードとして「#」は任意の整数値、「*」は任意の文字列パターンと合致させることができる。

/**
 * アプリ内部のSQLiteを扱う為のコンテンツプロバイダ.
 * 
 * @author Hideyuki Kojima
 */
public final class SQLiteProvider extends ContentProvider {

    /** URIのauthority. */
    private static final String AUTHORITY = "com.kojion.test.provider";

    /** SQLiteデータベースのファイル名. */
    private static final String SQLITE_FILENAME = "test.sqlite";

    /**
     * コンテンツプロバイダ利用者との「契約」を定義する列挙型定数クラス.
     * 
     * @author Hideyuki Kojima
     */
    public enum Contract {

        /** TABLE1テーブル. */
        TABLE1(BaseColumns._ID, "title", "note"),

        /** TABLE2テーブル. */
        TABLE2(BaseColumns._ID, "title2", "note2");

        /**
         * コンストラクタ. カラムを定義する.
         * 
         * @param columns 対象テーブルで定義されているカラム
         */
        Contract(final String...columns) {
            this.columns = Collections.unmodifiableList(Arrays.asList(columns));
        }

        /** テーブル名. enum定数を小文字にしたものとする. */
        private final String tableName = name().toLowerCase();

        /** テーブル全体のデータに対して処理をしに行く時のコード. */
        private final int allCode = ordinal() * 10;

        /** 対象IDのデータに対して処理をしに行く時のコード. */
        private final int byIdCode = ordinal() * 10 + 1;

        /** そのテーブル固有のCONTENT_URI表現. コンテンツリゾルバからこれを使用してアクセスする. */
        public final Uri contentUri = Uri.parse("content://" + AUTHORITY + "/" + tableName);

        /** MIMEタイプ(単数). */
        public final String mimeTypeForOne = "vnd.android.cursor.item/vnd.kojion." + tableName;

        /** MIMEタイプ(複数). */
        public final String mimeTypeForMany = "vnd.android.cursor.dir/vnd.kojion." + tableName;

        /** カラムのリスト. */
        public final List<String> columns;
    }

    /** 既定のUriパターンで絞り込む為のMatcher. */
    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    static {
        sUriMatcher.addURI(AUTHORITY, Contract.TABLE1.tableName, Contract.TABLE1.allCode);
        sUriMatcher.addURI(AUTHORITY, Contract.TABLE1.tableName + "/#", Contract.TABLE1.byIdCode);
        sUriMatcher.addURI(AUTHORITY, Contract.TABLE2.tableName, Contract.TABLE2.allCode);
        sUriMatcher.addURI(AUTHORITY, Contract.TABLE2.tableName + "/#", Contract.TABLE2.byIdCode);
    }

    /** SQLiteOpenHelperのインスタンス. */
    private SQLite mOpenHelper;

    /**
     * コンテンツプロバイダが生成された際に呼ばれる.
     * SQLiteデータベースのファイルが存在しなかった場合は作成し, テーブルを作成する.
     * SQLiteデータベースのファイルが既に存在した場合は, それを開いて返す.
     * データベースのバージョンは, 管理しやすいようにアプリのversionCodeをそのまま使用するものとする.
     * 
     * @return SQLiteデータベースが開けたかどうか
     */
    @Override
    public boolean onCreate() {
        final int version;
        try {
            version = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0).versionCode;
        } catch (NameNotFoundException e) {
            e.printStackTrace();
            return false;
        }
        mOpenHelper = new SQLite(getContext(), SQLITE_FILENAME, null, version);
        return true;
    }

    /**
     * 単数または複数検索して返す.
     * 
     * @return クエリ結果が格納されたCursor
     */
    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
        checkUri(uri);
        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
        Cursor cursor = db.query(uri.getPathSegments().get(0), projection, appendSelection(uri, selection),
                appendSelectionArgs(uri, selectionArgs), null, null, sortOrder);
        cursor.setNotificationUri(getContext().getContentResolver(), uri);
        return cursor;
    }

    /**
     * 対象テーブルにデータを挿入する. Uriに_idを付与してリクエストしても_idは無視する.
     * 
     * @return 作成されたデータのUri表現
     */
    @Override
    public Uri insert(Uri uri, ContentValues values) {
        checkUri(uri);
        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        final long rowId = db.insertOrThrow(uri.getPathSegments().get(0), null, values);
        Uri returnUri = ContentUris.withAppendedId(uri, rowId);
        getContext().getContentResolver().notifyChange(returnUri, null);
        return returnUri;
    }

    /**
     * 対象テーブルの対象データを更新する. _idやselectionArgsの指定が無い場合は全件更新する.
     * 
     * @return 更新件数
     */
    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        checkUri(uri);
        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        final int count = db.update(uri.getPathSegments().get(0), values, appendSelection(uri, selection),
            appendSelectionArgs(uri, selectionArgs));
        getContext().getContentResolver().notifyChange(uri, null);
        return count;
    }

    /**
     * 対象テーブルの対象データを削除する. _idやselectionArgsの指定が無い場合は全件削除する.
     * 
     * @return 削除件数
     */
    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        checkUri(uri);
        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        final int count = db.delete(uri.getPathSegments().get(0), appendSelection(uri, selection),
            appendSelectionArgs(uri, selectionArgs));
        getContext().getContentResolver().notifyChange(uri, null);
        return count;
    }

    /**
     * 対象UriのMIMEタイプを返却する.
     * 
     * @return 対象UriのMIMEタイプ
     * @throws IllegalArgumentException このコンテンツプロバイダで扱えるUriパターンでなかった場合
     */
    @Override
    public String getType(Uri uri) {
        final int code = sUriMatcher.match(uri);
        for (final Contract contract : Contract.values()) {
            if (code == contract.allCode) {
                return contract.mimeTypeForMany;
            } else if (code == contract.byIdCode) {
                return contract.mimeTypeForOne;
            }
        }
        throw new IllegalArgumentException("unknown uri : " + uri);
    }

    /**
     * 対象Uriがこのコンテンツプロバイダで扱えるUriパターンかどうかを検証する.
     * 
     * @throws IllegalArgumentException このコンテンツプロバイダで扱えるUriパターンでなかった場合
     */
    private void checkUri(Uri uri) {
        final int code = sUriMatcher.match(uri);
        for (final Contract contract : Contract.values()) {
            if (code == contract.allCode) {
                return;
            } else if (code == contract.byIdCode) {
                return;
            }
        }
        throw new IllegalArgumentException("unknown uri : " + uri);
    }

    /**
     * Uriで_idの指定があった場合, selectionにそれを連結して返す.
     * 
     * @param uri Uri
     * @param selection 絞り込み条件
     * @return _idの条件が連結されたselection
     */
    private String appendSelection(Uri uri, String selection) {
        List<String> pathSegments = uri.getPathSegments();
        if (pathSegments.size() == 1) {
            return selection;
        }
        return BaseColumns._ID + " = ?" + (selection == null ? "" : " AND (" + selection + ")");
    }

    /**
     * Uriで_idの指定があった場合, selectionArgsにそれを連結して返す.
     * 
     * @param uri Uri
     * @param selectionArgs 絞り込み条件の引数
     * @return _idの条件が連結されたselectionArgs
     */
    private String[] appendSelectionArgs(Uri uri, String[] selectionArgs) {
        List<String> pathSegments = uri.getPathSegments();
        if (pathSegments.size() == 1) {
            return selectionArgs;
        }
        if (selectionArgs == null || selectionArgs.length == 0) {
            return new String[] {pathSegments.get(1)};
        }
        String[] returnArgs = new String[selectionArgs.length + 1];
        returnArgs[0] = pathSegments.get(1);
        System.arraycopy(selectionArgs, 0, returnArgs, 1, selectionArgs.length);
        return returnArgs;
    }

    /**
     * SQLiteを扱うクラス. ContentProvider内で使用されるに留まる.
     * 
     * @author Hideyuki Kojima
     */
    private static class SQLite extends SQLiteOpenHelper {

        /**
         * コンストラクタ.
         * 
         * @param context コンテキスト
         * @param name SQLiteファイル名
         * @param factory CursorFactory
         * @param version DBバージョン
         */
        public SQLite(Context context, String name, CursorFactory factory, int version) {
            super(context, name, factory, version);
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            db.beginTransaction();
            try {
                db.execSQL("CREATE TABLE Table1 (_id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, note TEXT)");
                db.execSQL("CREATE TABLE Table2 (_id INTEGER PRIMARY KEY AUTOINCREMENT, title2 TEXT, note2 TEXT)");
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            // TODO 本来は移行用のコードを書く.
        }
    }
}

AndroidManifest.xml に以下のようにプロバイダの定義を追加:

<provider android:name=".SQLiteProvider"
    android:authorities="com.kojion.test.provider"
    android:exported="false"/>

android:exported="false" とすることで他アプリからのアクセスはできなくなる。これで以下のように実行できる:

public final class MainActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        // table1テーブルにデータ投入.
        ContentValues values = new ContentValues();
        for (int i = 0; i < 3; i++) {
            values.clear();
            values.put(Contract.TABLE1.columns.get(1), "title" + i);
            values.put(Contract.TABLE1.columns.get(2), "note" + i);
            getContentResolver().insert(Contract.TABLE1.contentUri, values);
        }

        // table2テーブルにデータ投入.
        for (int i = 0; i < 3; i++) {
            values.clear();
            values.put(Contract.TABLE2.columns.get(1), "title" + i);
            values.put(Contract.TABLE2.columns.get(2), "note" + i);
            getContentResolver().insert(Contract.TABLE2.contentUri, values);
        }

        // table1テーブルの_idが1のデータを削除.
        getContentResolver().delete(ContentUris.withAppendedId(Contract.TABLE1.contentUri, 1), null, null);

        // table1テーブルのデータを全件検索. 表示.
        Cursor c = getContentResolver().query(Contract.TABLE1.contentUri, null, null, null, null);
        startManagingCursor(c);
        while (c.moveToNext()) {
            for (int i = 0; i < c.getColumnCount(); i++) {
                Log.d(getClass().getSimpleName(), c.getColumnName(i) + " : " + c.getString(i));
            }
        }
    }
}

上記 CursorLoader を使用していない。CursorLoader を使用した例はまた次回とする。

kojionilk
Android, Java, PHP あたりのプログラマ。Python の思想が好み。開発環境として OS X や Ubuntu を使う。
http://www.kojion.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away