2012年11月5日月曜日

Android - パワフルな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のソース(https://github.com/android/platform_frameworks_support/blob/master/v4/java/android/support/v4/content/CursorLoader.java) を読んでみる。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の実装がされていれば、その仕組みを自動で利用できたわけだ。 ちなみにこのmObserverはLoader<D>.ForceLoadContentObserverというContentObserverのサブクラスで、 結局中身では「ローダがリセット(reset)や停止(stop)されてなければ読み込む」という事をやっている。

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

一つ注意が必要なのだが、SimpleCursorAdapterのflagを持たない方のコンストラクタはdeprecatedになっているのだが、 こちらを使用してしまうと自動でrequeryしてしまうようだ。しかもUIスレッドで。なのでこれは使用しないようにし、フラグでauto_requeryを行わせないようにする。 これはCursorAdapterも同様。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はしないようにセットするので注意. フラグをセットしないコンストラクタはdeprecatedになっている.
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へのリクエストは非同期で行われる為ユーザを待たすような事もない。

まとめると
適切にコンテンツプロバイダを実装するという前準備が必要だがかなり強力。勉強期間を作って導入してみるのをお勧めする。

コンテンツプロバイダを適切に実装すればよいので、例えばネットワーク越しのコンテンツにも使えるのではないか?とちょっと思った。 が、例えばTwitterのタイムラインの例など、勝手に更新されているようなものだと更新タイミングをこちらで特定できないためそう話は単純ではない気がした。検討する価値はあると思うが。

0 件のコメント:

コメントを投稿