Android

CursorLoader

More than 3 years have passed since last update.

CursorLoader とは

Android 公式 (翻訳) より引用:

AsyncTaskLoader のサブクラスで、ContentResolver を問い合わせし、Cursor を返します。これはカーソルの問い合わせに対し標準的な方法で Loader プロトコルを実装したクラスで、アプリケーションの UI をブロックしないようにバックグラウンドスレッドでカーソルを動作させるために、AsyncTaskLoader を基に構築されています。フラグメントやアクティビティの API で管理されたクエリーを実行するのではなく、ContentProvider からデータを非同期にロードするようにするために、このローダを使用するのがもっとも良い方法です。

AsyncTaskLoader の実装の一つであり ContentProvider 経由で Cursor にクエリを投げる際の最も有力な方法 である。よって使いこなすにはまず ContentProvider (コンテンツプロバイダ) に対して理解していなければならない。それは 拙記事 でも考察しているので参考にされたい。

ContentProvider の更新通知の仕組み

CursorLoader で必要な前提知識としてここで実験する。以下、以前の記事で紹介したように適切にコンテンツプロバイダが実装されているものとする:

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

        // まずデータ初期化. 3 件入れておく
        getContentResolver().delete(Contract.TABLE1.contentUri, null, null);
        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);
        }

        // table1 テーブルのデータを全て変更
        findViewById(R.id.update).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ContentValues values = new ContentValues();
                values.put(Contract.TABLE1.columns.get(1), "modified");
                values.put(Contract.TABLE1.columns.get(2), "modified");
                final int updatedCount = getContentResolver().update(Contract.TABLE1.contentUri, values, null, null);
                Log.d(getClass().getSimpleName(), "updated. count: " + updatedCount);
            }
        });

        // table1 テーブルのデータを全件検索表示
        final Cursor c = getContentResolver().query(Contract.TABLE1.contentUri, null, null, null, null);

        // Cursor に関係するデータが更新された事を通知する
        c.registerContentObserver(new ContentObserver(new Handler()) {
            @Override
            public void onChange(boolean selfChange) {
                Log.d(getClass().getSimpleName(), "onChange called. selfChange: " + selfChange);
                c.requery();
            }
        });

        // 既に検索済みの cursor を使ってデータをログ表示する
        findViewById(R.id.query).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                c.moveToFirst();
                do {
                    for (int i = 0; i < c.getColumnCount(); i++) {
                        Log.d(getClass().getSimpleName(), c.getColumnName(i) + " : " + c.getString(i));
                    }
                } while (c.moveToNext());
            }
        });
    }
}

これを QUERY -> UPDATE -> QUERY の順で実行してみた。以下がそのログ:

05-18 11:33:34.108: D/(5583): _id : 34
05-18 11:33:34.108: D/(5583): title : title0
05-18 11:33:34.108: D/(5583): note : note0
05-18 11:33:34.108: D/(5583): _id : 35
05-18 11:33:34.108: D/(5583): title : title1
05-18 11:33:34.108: D/(5583): note : note1
05-18 11:33:34.108: D/(5583): _id : 36
05-18 11:33:34.108: D/(5583): title : title2
05-18 11:33:34.108: D/(5583): note : note2
05-18 11:33:37.121: D/(5583): updated. count: 3
05-18 11:33:37.121: D/(5583): onChange called. selfChange: false
05-18 11:33:40.344: D/(5583): _id : 34
05-18 11:33:40.344: D/(5583): title : modified
05-18 11:33:40.344: D/(5583): note : modified
05-18 11:33:40.344: D/(5583): _id : 35
05-18 11:33:40.344: D/(5583): title : modified
05-18 11:33:40.344: D/(5583): note : modified
05-18 11:33:40.344: D/(5583): _id : 36
05-18 11:33:40.344: D/(5583): title : modified
05-18 11:33:40.344: D/(5583): note : modified

コンテンツプロバイダの query() で戻ってきた cursor に対して Cursor.registerContentObserver() でリスナを登録しておけば insert, update, delete のタイミングでコールバックを実行する事が出来るようになる。上記実験では cursor.requery() を行なっている為、再度コンテンツプロバイダに問い合わせなくても最新の情報がログ表示されているのが分かる。

ポイントは予めコンテンツプロバイダの query() 内で setNotificationUri() を行なっている事。これをコメントアウトしたらコールバックは呼ばれなかった。恐らく対象の URI で notifyChange() されたらその通知をコールバックに送るという事だと思われる。

URIが完全一致でなくても大丈夫

  • content://authority/table/query() した後 content://authority/table/_idupdate()
  • content://authority/table/_idquery() した後 content://authority/table/update()

上記の場合でもコールバックは呼ばれた。どうも CONTENT_URI の部分が一致していさえすれば良いようだ。基本的に update, delete は _id 指定で実行されるのに対し query は複数で実行される場合が多いと思うのでその場合使えないのであれば微妙だと思ったのだがそんな事はなかったようで安心。

CursorLoader

CursorLoader の動作を実験する意味で 最も単純に 組んでみた:

public final class MainActivity extends FragmentActivity implements LoaderCallbacks<Cursor> {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        // まずデータ初期化. 3 件入れておく
        getContentResolver().delete(Contract.TABLE1.contentUri, null, null);
        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);
        }

        // table1 テーブルのデータを全て変更
        findViewById(R.id.update).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ContentValues values = new ContentValues();
                values.put(Contract.TABLE1.columns.get(1), "modified");
                values.put(Contract.TABLE1.columns.get(2), "modified");
                final int updatedCount = getContentResolver().update(Contract.TABLE1.contentUri, values, null, null);
                Log.d(getClass().getSimpleName(), "updated. count: " + updatedCount);
            }
        });

        // table1 テーブルのデータを全件検索表示
        getSupportLoaderManager().initLoader(0, null, this);
    }

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        Log.d(getClass().getSimpleName(), "onCreateLoader called.");
        return new CursorLoader(this, Contract.TABLE1.contentUri, null, null, null, null);
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
        Log.d(getClass().getSimpleName(), "onLoadFinished called.");
        c.moveToFirst();
        do {
            for (int i = 0; i < c.getColumnCount(); i++) {
                Log.d(getClass().getSimpleName(), c.getColumnName(i) + " : " + c.getString(i));
            }
        } while (c.moveToNext());
    }

    @Override
    public void onLoaderReset(Loader<Cursor> cursor) {
        Log.d(getClass().getSimpleName(), "onLoaderReset called.");
    }
}

起動時に initLoader() して初回読み込みし、その後任意のタイミングでデータを UPDATE する実験。UPDATE を実行してみたら以下のログの通りとなった:

05-18 13:29:54.698: D/MainActivity(8102): onCreateLoader called.
05-18 13:29:54.778: D/MainActivity(8102): onLoadFinished called.
05-18 13:29:54.778: D/MainActivity(8102): _id : 52
05-18 13:29:54.778: D/MainActivity(8102): title : title0
05-18 13:29:54.778: D/MainActivity(8102): note : note0
05-18 13:29:54.778: D/MainActivity(8102): _id : 53
05-18 13:29:54.778: D/MainActivity(8102): title : title1
05-18 13:29:54.778: D/MainActivity(8102): note : note1
05-18 13:29:54.778: D/MainActivity(8102): _id : 54
05-18 13:29:54.778: D/MainActivity(8102): title : title2
05-18 13:29:54.778: D/MainActivity(8102): note : note2
05-18 13:30:07.201: D/(8102): updated. count: 3
05-18 13:30:07.221: D/MainActivity(8102): onLoadFinished called.
05-18 13:30:07.221: D/MainActivity(8102): _id : 52
05-18 13:30:07.221: D/MainActivity(8102): title : modified
05-18 13:30:07.221: D/MainActivity(8102): note : modified
05-18 13:30:07.221: D/MainActivity(8102): _id : 53
05-18 13:30:07.221: D/MainActivity(8102): title : modified
05-18 13:30:07.221: D/MainActivity(8102): note : modified
05-18 13:30:07.221: D/MainActivity(8102): _id : 54
05-18 13:30:07.221: D/MainActivity(8102): title : modified
05-18 13:30:07.221: D/MainActivity(8102): note : modified

ContentResolver.update() を実行すると 再読み込みの指示を何もしていないのに自動で onLoadFinished() が呼ばれて最新のデータが取得された。 試しにコンテンツプロバイダ内の query()Cursor.setNotificationUri() をコメントアウトしてみたら、 自動で onLoadFinished() が呼ばれるような事は無かった。しかも cursor は自動でアクティビティのライフサイクルに組み込まれるらしく、そのままアクティビティを終了してもメモリリークのエラーが表示されるようなことはない。

カラクリ

GitHub の CursorLoader のソースを読んでみる。CursorLoader のソースコードは短いので全く難しくない。ポイントは loadInBackground() の箇所:

/* Runs on a worker thread */
@Override
public Cursor loadInBackground() {
    Cursor cursor = getContext().getContentResolver().query(mUri, mProjection, mSelection, mSelectionArgs, mSortOrder);
    if (cursor != null) {
        // Ensure the cursor window is filled
        cursor.getCount();
        registerContentObserver(cursor, mObserver);
    }
    return cursor;
}

結局 CursorLoader が非同期でやってることは、コンテンツプロバイダにクエリを投げてその結果に例の Cursor.registerContentObserver() でコールバックを登録しているだけだった。 だからコンテンツプロバイダが適切に setNotificationUri()nofityChange() の実装がされていれば その仕組みを自動で利用できたわけだ。

ちなみにこの mObserverLoader.ForceLoadContentObserver という ContentObserver のサブクラスで、結局中身では「ローダがリセット (reset) や停止 (stop) されてなければ読み込む」という事をやっている。

CursorAdapter

さて実際の業務では Cursor を利用して ListView を簡単に描画する為の Adapter である CursorAdapter (SimpleCursorAdapter) を利用する機会が多いだろう。なのでこれもまた短いコードで試してみた。

注意として SimpleCursorAdapter の flag を持たない方のコンストラクタは deprecated になっているが、これは UI スレッドで自動で requery してしまうので決して使用しないこと。 これは CursorAdapter も同様であり autoRequery を行うかどうかのフラグをコンストラクタに渡せるので、それで必ず false を渡せばよい。

public final class MainActivity extends FragmentActivity implements LoaderCallbacks<Cursor> {

    private SimpleCursorAdapter mAdapter;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        // まずデータ初期化. 3 件入れておく
        getContentResolver().delete(Contract.TABLE1.contentUri, null, null);
        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);
        }

        // table1 テーブルのデータを全て変更
        findViewById(R.id.update).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ContentValues values = new ContentValues();
                values.put(Contract.TABLE1.columns.get(1), "modified");
                values.put(Contract.TABLE1.columns.get(2), "modified");
                final int updatedCount = getContentResolver().update(Contract.TABLE1.contentUri, values, null, null);
                Log.d(getClass().getSimpleName(), "updated. count: " + updatedCount);
            }
        });

        // CursorAdapter をセット. フラグの部分は autoRequery はしないようにセットするので注意
        final String[] from = {"title"};
        final int[] to = {android.R.id.text1};
        mAdapter = new SimpleCursorAdapter(this, android.R.layout.simple_list_item_1, null, from, to, 0);
        ((ListView)findViewById(R.id.listView)).setAdapter(mAdapter);

        // table1 テーブルのデータを全件検索表示
        getSupportLoaderManager().initLoader(0, null, this);
    }

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        Log.d(getClass().getSimpleName(), "onCreateLoader called.");
        return new CursorLoader(this, Contract.TABLE1.contentUri, null, null, null, null);
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
        Log.d(getClass().getSimpleName(), "onLoadFinished called.");

        // CursorLoader と CursorAdapter を使用する上での決まり文句
        mAdapter.swapCursor(c);
    }

    @Override
    public void onLoaderReset(Loader<Cursor> cursor) {
        Log.d(getClass().getSimpleName(), "onLoaderReset called.");

        // CursorLoader と CursorAdapter を使用する上での決まり文句
        mAdapter.swapCursor(null);
    }
}

単純に ListView に Table1 のデータのタイトルを一覧表示するコードであるが、これで UPDATE を押下すると データが書き換わったタイミングで自動で onLoadFinished() が呼ばれるので swapCursor() で画面上のデータも自動で書き換わる。

以前の単純な SQLiteOpenHelper をそのまま使用する例だと、例えば別画面の登録・更新画面から戻った際に表示データを最新にする為に onResume()onActivityResult()cursor.requery() などを行うことが多かった。これらは例えデータが書き換わっていなくても行われるのでそれがオーバーヘッドとなるし、「データが書き換わっている場合のみ表示を更新したい」場合は更新画面から結果を返し、それを onActivityResult() で判断しなければならなかったと思う。が、適切に ContentProvider / CursorLoader を使用すれば後はほっといていい、と言えそうだ。しかも今回の例だと一瞬で処理が終わるため恩恵が少ないのだが ContentProvider へのリクエストは非同期で行われる為ユーザを待たすような事もない。