2012/08/30

Android:CancellationSignalで処理を中断する

●はじめに

JellyBeanで"処理のキャンセル要求"を表現するCancellationSignalクラスが追加されました。

下記のようなシーンで利用されています。
  • ローダの処理を中断する。 
  • データベースへのクエリ要求を中断する。
「前者はLoaderのcancelLoadと違うのか?」って疑問を持つ方もいると思います。

ローダについては、CancellationSignalの仕組みを取り込む形で内部設計が変更されました。
この変更より、ローダ利用側がしないといけないことは特にありません。

一般的に利用されるシーンとしては、後者の"データベースへのクエリ要求の中断"でしょう。
例えば、下記のような利用が考えられます。
  • クエリを発行したものの、結果返却される前にアプリ終了したのでクエリをキャンセルしたい。
  • クエリを発行したものの、(ロック解放待ち等により)一定時間応答がないのでクエリをキャンセルしたい。
これまでは、一度発行したクエリは投げっぱなしで、中断する術がありませんでした。

(ContentProvider側をゴリゴリカスタムすればできそうですけど...)
JellyBean以降、CancellationSignalが導入されたことでこれが可能となりました。

●CancellationSignal概念

CancellationSignalは"処理の中断要求"を表現するクラスです。
このクラスは、キャンセル状態の管理とリスナーへのコールバックを持つシンプルなものです。
CancellationSignalはあくまでもキャンセル"要求"です。
このキャンセル要求を受け入れるかどうかは、キャンセルされる処理側が判断します。

これは、AsyncTaskのそれと変わりありません。
AsyncTaskもキャンセルメソッドを持っていますが、本当にキャンセルされるかどうかは実装依存です。

独自のローダやContentProviderにキャンセル機能を実現したい場合でも同じです。
JB以前に作成したContentProviderにキャンセル要求を投げても上手く動きません。
キャンセル機能を実現したいならそれを組み込む必要があります。

データベースのクエリ要求では、少なくとも次のポイントでキャンセルできるよう実装されています。
このケースでは少なくとも次のポイントでキャンセル要求を確認しています。
  • ContentResolver/ContentProviderClientへのクエリ要求時 
  • データベースへの接続前と接続待ち中
  • クエリの実行前と実行中 
  • トランザクションの開始時と終了時
注意点として、標準で用意されているContentProviderでもキャンセル機能をサポートしていないものが存在します。
例えば通話履歴データを提供するCallLogProviderはこれをサポートしていません。
このようなContentProviderにもCancellationSignalを渡すことは可能ですが、結果無視されます。

●"キャンセル要求があるか?"

「キャンセル要求があるか?」を判断するには2通りの方法があります。
CancellationSignal.isCanceled()throwIfCanceled()です。
isCanceled()はキャンセル要求の有無(true/false)を返します。
throwIfCanceled()はキャンセルされている場合にandroid.os.OperationCanceledException(実行時例外)をスローします。

キャンセル要求をチェック/実行する側はthrowIfCanceled()を使用するケースが多いようです。
処理をキャンセルしたい場合は呼び出しもとにOperationCanceledExceptionをそのままスローします。
キャンセル要求を投げる側はisCanceled()を使うことがほとんどでしょう。

●CancellationSignalによるキャンセル要求をサポートする

ここでは、独自ContentProviderにCancellationSignalによるキャンセルをサポートさせてみます。
実装は非常に簡単。

まず、ContentProviderを継承したクラスで下記のqueryメソッドをオーバライドします。
ContentProvider.query (Uri, String[], String, String[], String, CancellationSignal)

public class MyContentProvider extends ContentProvider {
    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
            String[] selectionArgs, String sortOrder,
            CancellationSignal cancellationSignal) {
        ... 略 ...
    }
}

続いて、SQLiteDatabaseインスタンスに対して下記メソッド経由でCancellationSignalインスタンスを渡します。
SQLiteDatabase.query (boolean, String, String[], String, String[], String , String, String, String, CancellationSignal)

@Override
public Cursor query(Uri uri, String[] projection, String selection,
        String[] selectionArgs, String sortOrder,
        CancellationSignal cancellationSignal) {
    SQLiteDatabase db = mDataBaseHelper.getReadableDatabase();
    Cursor c = db.query(false, "test", projection,
            selection, selectionArgs, null, null, sortOrder, null,
            cancellationSignal);
    ... 略 ...
}

ContentProviderの呼出し元はContentResolverあるはContentProviderClient経由でCancellationSignalインスタンスを渡します。

・ContentResolver.query (Uri, String[], String, String[], String, CancellationSignal)
・ContentProviderClient.query (Uri, String[], String, String[], String, CancellationSignal)
mCancelSignal = new CancellationSignal();
Cursor cursor = getContentResolver().query(
        Uri.parse("content://yuki.mycontentprovider"),
        new String[] { "_id" }, null, null, null, mCancelSignal);
これで、SQLiteデータベースへのクエリ要求はキャンセル可能なものとして扱われます。
クエリ要求をキャンセルしたい場合は渡したCancellationSignalのcancelメソッドを呼び出します。
mCancelSignal.cancel();
キャンセル要求が受け入れられて処理中断されるとOperationCanceledException例外がスローされるのでこれをキャッチしましょう。
try {
    Cursor cursor = getContentResolver().query(
            Uri.parse("content://yuki.mycontentprovider"),
            new String[] { "_id" }, null, null, null, mCancelSignal);
} catch (OperationCanceledException ex) {
    // do something.
}
キャンセル要求のサポートは以上です。

ちなみにですが、AsyncTaskLoaderはCancellationSignalによるキャンセルをサポートしています。
キャンセル可能な範囲はバックグラウンド処理部分。
ちょっと気になるコメントがあったので引用します。
http://tools.oesf.biz/android-4.1.1_r1.0/xref/frameworks/base/core/java/android/content/AsyncTaskLoader.java#71

} catch (OperationCanceledException ex) {
    if (!isCancelled()) {
        // onLoadInBackground threw a canceled exception spuriously.
        // This is problematic because it means that the LoaderManager did not
        // cancel the Loader itself and still expects to receive a result.
        // Additionally, the Loader's own state will not have been updated to
        // reflect the fact that the task was being canceled.
        // So we treat this case as an unhandled exception.
        throw ex;
    }
CancellationSignalによるキャンセルが実行されたのに、Loaderとしてはキャンセル状態にない場合。
AsyncTaskLoaderはこれを異常として例外を再スローするようですね。

●キャンセル処理をサポートしていないContentProviderにCancellationSignalを渡したら...

クエリのキャンセルをサポートしていない or 古いContentProviderでもCancellationSignalを渡すことは可能です。
しかし、渡しても即破棄されるのでキャンセル要求は無視されます。

http://tools.oesf.biz/android-4.1.1_r1.0/xref/frameworks/base/core/java/android/content/ContentProvider.java#649
public Cursor query(Uri uri, String[] projection,
        String selection, String[] selectionArgs, String sortOrder,
        CancellationSignal cancellationSignal) {
    return query(uri, projection, selection, selectionArgs, sortOrder);
}


●お試し実装で...

以下はCancellationSignalの効果を確かめようと思って実施した内容です。
蛇足ではありますが、一部落とし穴があったので記載します。

キャンセル要求が本当に通るかを検証すべく、
Exclusiveロックされたデータベースにクエリを投げて、ロック解除待ち状態のクエリをキャンセルしてみようと思いました。

で、手軽にロックを掛けたかったのでsqlite3コマンドから直接
sqlite3> BEGIN EXCLUSIVE TRANSACTION;
としたのですが、これが誤り...

sqlite3コマンドから直接データベースを操作すると、期待通りの動作となりませんでした。
# ずっとロック解放待ちのままハマってしまう...

ちゃんとSQLiteDatabaseのbeginTransactionでロックを取得すれば上手くいきました。

----------
-------
---
以降はCancellationSignal調査中のメモ書き。あまり役に立たないネタです。
(といいつつ、キャンセル処理を自力で実装したい場合は参考になります)
「どうやってSQLiteデータベースはクエリを中断しているのか?」を追いました。

おおまかなシーケンスは下記(一部省略)。



下記はデータベースにクエリを発行した際のシーケンスを順に追っています。

1. SQLiteDatabaseに対してqueryを発行
android.database.sqlite.SQLiteDatabase.query(boolean, String, String[], String, String[], String, String, String, String, CancellationSignal)

2. query発行処理...
android.database.sqlite.SQLiteDatabase.queryWithFactory(CursorFactory, boolean, String, String[], String, String[], String, String, String, String, CancellationSignal)

3. SQLiteDirectCursorDriverのインスタンス化
android.database.sqlite.SQLiteDirectCursorDriver.SQLiteDirectCursorDriver(SQLiteDatabase, String, String, CancellationSignal)
SQLiteDirectCursorDriverはCancellationSignalをキャッシュする。
public SQLiteDirectCursorDriver(SQLiteDatabase db, String sql, String editTable,
        CancellationSignal cancellationSignal) {
    mDatabase = db;
    mEditTable = editTable;
    mSql = sql;
    mCancellationSignal = cancellationSignal;
}

4. SQLiteQueryとSQLiteCursorの生成
android.database.sqlite.SQLiteDirectCursorDriver.query(CursorFactory, String[])
public Cursor query(CursorFactory factory, String[] selectionArgs) {
    final SQLiteQuery query = new SQLiteQuery(mDatabase, mSql, mCancellationSignal);
    ... 略 ...
        cursor = new SQLiteCursor(this, mEditTable, query);
    ... 略 ...
}

5. SQLiteQueryはCancellationSignalをキャッシュする
android.database.sqlite.SQLiteQuery.SQLiteQuery(SQLiteDatabase, String, CancellationSignal)
SQLiteQuery(SQLiteDatabase db, String query, CancellationSignal cancellationSignal) {
    super(db, query, null, cancellationSignal);
    mCancellationSignal = cancellationSignal;
}

6. SQLiteQueryは親クラスSQLiteProgramのコンストラクタを呼び出す
android.database.sqlite.SQLiteProgram.SQLiteProgram(SQLiteDatabase, String, Object[], CancellationSignal)
クエリ操作では、SQLiteSessionの準備を行う。
int n = DatabaseUtils.getSqlStatementType(mSql);
switch (n) {
    default:
        ... 略 ...
        db.getThreadSession().prepare(mSql,
                db.getThreadDefaultConnectionFlags(assumeReadOnly),
                cancellationSignalForPrepare, info);
        ... 略 ...
        break;
}

7. データベースコネクションの取得を開始
android.database.sqlite.SQLiteSession.prepare(String, int, CancellationSignal, SQLiteStatementInfo)
prepare処理前にキャンセル確認。
public void prepare(String sql, int connectionFlags, CancellationSignal cancellationSignal,
        SQLiteStatementInfo outStatementInfo) {
    ... 略 ...
    if (cancellationSignal != null) {
        cancellationSignal.throwIfCanceled();
    }
    ... 略 ...
}
さらに、SQLiteConnectionを取得
acquireConnection(sql, connectionFlags, cancellationSignal); // might throw
try {
    mConnection.prepare(sql, outStatementInfo); // might throw
} finally {
    releaseConnection(); // might throw
}

8. コネクションプールにSQLiteConnection取得要求
android.database.sqlite.SQLiteConnectionPool.acquireConnection(String, int, CancellationSignal)

9. コネクションプールからSQLiteConnection取得できるまでウェイト
android.database.sqlite.SQLiteConnectionPool.waitForConnection(String, int, CancellationSignal)
ウェイト前にキャンセル確認
private SQLiteConnection waitForConnection(String sql, int connectionFlags,
        CancellationSignal cancellationSignal) {
        ... 略 ...
        // Abort if canceled.
        if (cancellationSignal != null) {
            cancellationSignal.throwIfCanceled();
        }
        ... 略 ...
}
ウェイト時にもキャンセルできるようにキャンセルリスナー前準備
cancellationSignal.setOnCancelListener(new CancellationSignal.OnCancelListener() {
@Override
public void onCancel() {
        synchronized (mLock) {
            if (waiter.mNonce == nonce) {
                cancelConnectionWaiterLocked(waiter);
            }
        }
});
ウェイト開始...
for (;;) {
    ... 略 ...
    // Wait to be unparked (may already have happened), a timeout, or interruption.
    LockSupport.parkNanos(this, busyTimeoutMillis * 1000000L);
    ... 略 ...
}
ウェイトが満了したらキャンセルリスナー解除
try {
    ... 略 ....
} finally {
    // Remove the cancellation listener.
    if (cancellationSignal != null) {
        cancellationSignal.setOnCancelListener(null);
    }
}

10. コネクションが取得できたらCursorWindowの準備
android.database.sqlite.SQLiteConnection.executeForCursorWindow(String, Object[], CursorWindow, int, int, boolean, CancellationSignal)
ネイティブへの問い合わせがあるので、ネイティブ実行中でもキャンセルできるよう前準備
try {
    final PreparedStatement statement = acquirePreparedStatement(sql);
        ... 略 ...
        attachCancellationSignal(cancellationSignal);
        try {
            final long result = nativeExecuteForCursorWindow(...);
            ... 略 ...
        } finally {
            detachCancellationSignal(cancellationSignal);
        }
        ... 略 ...
} catch (RuntimeException ex) {
                ... 略 ...

11. ネイティブ処理のキャンセルシグナルをアタッチ
android.database.sqlite.SQLiteConnection.attachCancellationSignal(CancellationSignal)
if (cancellationSignal != null) {
    cancellationSignal.throwIfCanceled();
    ... 略 ...
        // Reset cancellation flag before executing the statement.
        nativeResetCancel(mConnectionPtr, true /*cancelable*/);

        // After this point, onCancel() may be called concurrently.
        cancellationSignal.setOnCancelListener(this);
    ... 略 ...
}

ちなみに、SQLiteConnectionはCancellationSignal.OnCancelListenerをimplementsしている。
// CancellationSignal.OnCancelListener callback.
// This method may be called on a different thread than the executing statement.
// However, it will only be called between calls to attachCancellationSignal and
// detachCancellationSignal, while a statement is executing.  We can safely assume
// that the SQLite connection is still alive.
@Override
public void onCancel() {
    nativeCancel(mConnectionPtr);
}

12. キャンセルシグナルのデタッチ
android.database.sqlite.SQLiteConnection.detachCancellationSignal(CancellationSignal)
private void detachCancellationSignal(CancellationSignal cancellationSignal) {
    ... 略 ...
            // After this point, onCancel() cannot be called concurrently.
            cancellationSignal.setOnCancelListener(null);

            // Reset cancellation flag after executing the statement.
            nativeResetCancel(mConnectionPtr, false /*cancelable*/);
    ... 略 ...
}

13. SQLiteCursorはCancellationSignalをキャッシュしたSQLiteQueryをキャッシュする
android.database.sqlite.SQLiteCursor.SQLiteCursor(SQLiteCursorDriver, String, SQLiteQuery)

これで、生成されたCursorがSQLiteDatabase.query()の戻り値になります。
他にもトランザクションの開始・終了時なんかにもキャンセル要求のチェックが入ります。

以上です。