View on GitHub
mixi-inc/AndroidTraining
ContentProviderの発展
この章では、ContentProvider の発展的な使用について解説します。

参考:Content Provider Basics | Android Developers
参考:Transferring Data Using Sync Adapter | Android Developers
参考:ContentResolver | Android Developers
参考:ContentProviderOperation | Android Developers
参考:AbstractThreadedSyncAdapter | Android Developers

目次

  • ContentProvider へのアクセス
    • バッチ処理
    • Intent によるアクセス
  • データの同期
    • ContentProvider を用いた同期の仕組み
    • 同期の仕組みを使用する準備
    • AbstractThreadedSyncAdapter
    • Service
    • メタデータの宣言
    • AndroidManifest の宣言
    • 同期の実行と下位互換

ContentProvider へのアクセス

ContentProvider へのアクセスは、ContentResolver を介して行うことは、2.10. データベースにて解説しました。このページの方法では、1 回のアクセスで 1 回の操作(挿入、更新、削除)を行うように動作します。

今回は、1 回のアクセスで複数の操作(挿入、更新、削除)を行う、バッチ処理について解説するとともに、ContentResolver 以外の手段を用いた ContentProvider へのアクセスについて解説します。

バッチ処理

ContentProviderOperation に、挿入・更新・削除のいずれかの処理のための情報をもたせ、このオブジェクトを List にまとめて ContentResolver 経由でリクエストすることで、バッチ処理が実行できます。

バッチ処理と同等のリクエストを複数回繰り返すよりも、バッチ処理の方が高速に動作します。一方で、バッチ処理はそのままではアトミック性を保証しないまま動作してしまうため、必要であればContentProvider#applyBatch(ArrayList)をオーバライドして、トランザクションを自分で開始・終了する必要があります。

以下に、挿入・更新・削除のそれぞれの処理を構成する ContentProviderOperation の作成例と、バッチ処理の適用方法を示します。

public void batchInsert(Context context, List<String> names) {
    ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();

    // List に ContentProviderOperation を詰め込んで、バッチ処理時にすべてを実行するようにする
    for (String name : names) {
        ContentValues values = new ContentValues();
        values.put("name", name);

        // ContentProviderOperation#newInsert(Uri) によって、挿入に関する操作手順を生成するビルダーを取得する
        ContentProviderOperation insertOp = ContentProviderOperation.newInsert(MyContentProvider.CONTENT_URI)
                .withValues(values)  // ContentValues に含まれるデータを挿入
                .build();  // ContentProviderOperation を作成
        ops.add(insertOp);
    }

    ContentResolver resolver = context.getContentResolver();
    resolver.applyBatch(MyContentProvider.AUTHORITY, ops);  // バッチ処理を実行
}

public void batchUpdate(Context context, List<Integer> ids, List<String> names) {
    ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();

    for (int i = 0; i < ids.size() && i < names.size(); i++) {
        Integer id = ids.get(i);
        String name = names.get(i);

        ContentValues values = new ContentValues();
        values.put("name", name);

        // ContentProviderOperation#newUpdate(Uri) によって、更新に関する操作手順を生成するビルダーを取得する
        ContentProviderOperation updateOp = ContentProviderOperation.newUpdate(MyContentProvider.CONTENT_URI)
                .withValues(values)
                .withSelection("_id = ?", new String[] { id.toString() })  // Update 対象の条件を付与
                .build();
        ops.add(updateOp);
    }

    ContentResolver resolver = context.getContentResolver();
    resolver.applyBatch(MyContentProvider.AUTHORITY, ops);  // バッチ処理を実行
}

public void batchDelete(Context context, List<Integer> ids) {
    ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();

    for (Integer id : ids) {
        // ContentProviderOperation#newUpdate(Uri) によって、削除に関する操作手順を生成するビルダーを取得する
        ContentProviderOperation deleteOp = ContentProviderOperation.newDelete(MyContentProvider.CONTENT_URI)
                .withSelection("_id = ?", new String[] { id.toString() })
                .build();
        ops.add(deleteOp);
    }

    ContentResolver resolver = context.getContentResolver();
    resolver.applyBatch(MyContentProvider.AUTHORITY, ops);
}

挿入・更新・削除とも、操作に合わせて呼び出すメソッドが異なりますが、それぞれで得られるオブジェクトの型はすべて ContentProviderOperation.Builder となります。操作に応じて、 ContentProviderOperation.Builder の各種メソッドで手順の設定の仕方が変わることに注意して下さい。例えば、ContentProviderOperation#newInsert(Uri) で生成した ContentProviderOperation.Builder では、ContentProviderOperation.Builder#withSelection(String, String[]) が使用できず、実行時にIllegalArgumentExceptionとなります。

挿入・更新・削除の操作を複数含む ArrayList を生成すれば、バッチ処理中にそれらの手順をまとめて実行することもできます。

バッチ処理の実行は、ContentResolver#applyBatch(String, ArrayList)によって行い、返り値として、処理結果を表すContentProviderResultの配列が得られます。

Intent によるアクセス

データの同期

しばしば、アプリケーションはサーバサイドとのデータのやり取りを密接にやりとりします。ネットワークにつながらない場所に居ても、最低限、それ以前にサーバからダウンロードしておいたデータへのアクセス性を確保したり、定期的にアプリケーション内に蓄積したデータをサーバへ送信することでバックアップを取ったりするような仕組みは、ネットワークへの接続が必ずしも常にあるとは限らない環境において、また、多様な端末を複数使用する環境においては、とても大切な仕組みとなります。

Android では、これらを支援する仕組みとして、クラウドとの同期のためのフレームワークを提供しています。今回は、特に ContentProvider を用いたフレームワークに注目して解説します。

ContentProvider 以外にも同期の仕組みが存在しますが、この仕組は3.08. クラウド同期で解説します。

ContentProvider を用いた同期の仕組み

API Level 8 から、ContentProvider の保持しているデータを、定期的にサーバ(クラウド)と同期する仕組みが導入されました。端末の AccountManager で管理されているアカウントに紐付いて、同期の定期実行をシステムに促すことで、指定した間隔で同期を実行するようになります。

複数の ContentProvider を持つ場合、それぞれの ContentProvider で個別に同期処理を実行させることができます。この時、それぞれの同期処理は並列には実行されず、キューに積まれて順次同期処理が実行されます。このため、他の同期処理の影響を受けて、指定した間隔通りに同期処理が実行されないことがある点に注意して下さい。

同期の仕組みを使用する準備

AccountManager で管理されているアカウントが必要となるため、ContentProvider を用いたデータの同期をするには、Authenticator の使用が必要となります。既に Authenticator によるアカウント管理の仕組みがある場合は特に作業の必要はありませんが、アカウント管理の仕組みが必要でないアプリケーションでは、スタブの Authenticator を用意する必要があります。

以下がスタブの Authenticator の実装の例です。アカウント管理をしないのであれば、ロジックを記述せず、操作がサポートされない旨を表すUnsupportedOperationExceptionをスローするようにします。

public class StubAuthenticator extends AbstractAccountAuthenticator {
    public StubAuthenticator(Context context) {
        super(context);
    }

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {
        return null; // アカウント管理をしないため、null を返す
    }

    @Override
    public abstract Bundle confirmCredentials (AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
        return null; // アカウント生成の了承リクエストは無視するため、null を返す
    }

    @Override
    public abstract Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
        throw new UnsupportedOperationException("edit properties not supported"); // アカウント情報の編集は、アカウント管理をしないポリシーとなるので例外とする
    }

    @Override
    public abstract Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetowrkErrorException {
        throw new UnsupportedOperationException("get auth token not supported"); // トークン管理も、アカウント管理をしないため例外
    }

    @Override
    public abstract String getAuthTokenLabel(String authTokenType) {
        throw new UnsupportedOperationException("get auth token label not supported"); // トークン管理をしないので、トークンの種類に対応する表示も例外
    }

    @Override
    public abstract Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
        throw new UnsupportedOperationException("feature check not supported"); // アカウント管理をしないため例外
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse r, Account account, String s, Bundle bundle) throws NetworkErrorException {
        throw new UnsupportedOperationException(); // アカウント管理をしないため例外
    }
}

Authenticator の実装ができたら、Authenticator を動作させるための Service を実装します。

public class StubAuthenticatorService extends Service {
    // スタブの Authenticator を保持しておく
    private StubAuthenticator mAuthenticator;
    @Override
    public void onCreate() {
        mAuthenticator = new StubAuthenticator(this);
    }

    // システムは、Authenticator に対して RPC を実施する為にこの Service にバインドするので、RPC のための IBinder を Authenticator から取得する
    @Override
    public IBinder onBind(Intent intent) {
        return mAuthenticator.getIBinder();
    }
}

次に、スタブの Authenticator に関するメタデータの宣言をします。

属性名 意味
android:accountType アカウントの識別子。同期処理をシステムが管理する際にも、どのアカウントに紐づくものかを識別するために使用する。
android:icon アカウントのアイコン。同期処理をユーザによって設定可能にする場合は必須。
android:smallIcon スクリーンの大きさによって、icon属性に指定した画像のかわりに使用される。
android:label アカウントの表示名。同期処理をユーザによって設定可能にする場合は必須。
<!-- res/xml/authenticator.xml -->
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:accountType="jp.co.mixi.sample.sync.stub.account"
        android:icon="@drawable/ic_launcher"
        android:smallIcon="@drawable/ic_launcher"
        android:label="@string/app_name"/>

AndroidManifest には Service と同期処理を紐付ける ContentProvider を宣言します。

Service の宣言には、<intent-filter><meta-data>を含める必要があります。
ContentProvider の宣言では、android:syncable属性をtrueにしておく必要があります。

<service
    android:name="jp.mixi.sample.sync.stub.authenticator.StubAuthenticatorService">
    <intent-filter>
        <action android:name="android.accounts.AccountAuthenticator"/>
    </intent-filter>
    <meta-data
        android:name="android.accounts.AccountAuthenticator"
        android:resource="@xml/authenticator" />
</service>

<provider
    android:name="jp.mixi.sample.sync.StubSyncProvider"
    android:authorities="jp.mixi.sample.sync.StubSyncProvider"
    android:export="false"
    android:syncable="true"/>

最後に、端末にアカウント情報を登録します。

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

        // ...
        Account account = createAccount();

    }

    private Account createAccount() {
        AccountManager manager = (AccountManager) getSystemService(Context.ACCOUNT_SERVICE);
        // スタブのアカウント情報を持つ Account オブジェクトを作る
        Account stubAccount = new Account("stubAccount", "jp.co.mixi.sample.sync.stub.account");

        // AccountManager にスタブのアカウント情報を登録
        if (manager.addAccountExplicitly(stubAccount, null, null)) {
            // 登録成功
        } else {
            // 既に登録されているか、アカウントが null、その他エラーの場合
        }
        return stubAccount;
    }
}

AbstractThreadedSyncAdapter

ContentProvider のデータの同期を目的としたクラスで、ContentResolver によって同期のタイミングが管理されます。

このクラスは、アプリケーションプロセスが実行中、ワーカスレッド上でクラウドとの同期処理を記述するフレームワークを提供しています。UI スレッドとは別のスレッド上で実行される為、各種の IO を同期的に記述しても問題ありません。

public MySyncAdapter extends AbstractThreadedSyncAdapter {
    public MySyncAdapter(Context context, boolean autoInitialize) {
        super(context, autoInitialize);
    }

    /**
     * 定期実行処理が走った時に呼ばれるコールバック
     *
     * @param account 定期実行のリクエストが紐付けられているアカウント
     * @param extras 定期実行をシステムに登録した時に渡すパラメータを含むマップオブジェクト
     * @param authority ContentProvider の AUTHORITY
     * @param provider ContentProvider へのアクセサ
     * @param syncResult 同期処理の結果を保持するオブジェクト。成功・例外などの情報を持つ
     */
    @Override
    public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
        // ここに同期のための処理を記述する
    }
}

Service

データの同期のタイミングで呼び出され、AbstractThreadedSyncAdapter に記述した同期の手続きを実行するための Service を定義します。

AbstractThreadedSyncAdapter のインスタンスは、アプリケーションのライフサイクルの中で Singleton として扱うようにし、複数の AbstractThreadedSyncAdapter のインスタンスを生成して並列に同期を実行してしまうことを防ぎます。

public MySyncService extends Service {
    private static AbstractThreadedSyncAdapter sAdapter;
    private static final Object LOCK = new Object();
    @Override
    public void onCreate() {
        synchronized (LOCK) {
            if (sAdapter == null) {
                sAdapter = new MySyncAdapter(getApplicationContext(), true);
            }
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return sAdapter.getSyncAdapterBinder();
    }
}

メタデータの宣言

AbstractThreadedSyncAdapter のためのメタデータを下記のように宣言します。

属性名 意味
android:contentAuthority ContentProvider の AUTHORITY を指定する。
android:accountType Authenticator のメタデータに記述したアカウントの識別子を指定する。
android:userVisible 設定画面でユーザがこの同期処理をコントロール出来るようにするかどうか。
android:supportsUploading 同期中、データのアップロードをするかどうか。ダウンロードのみの場合はfalseとする。
android:allowParallelSyncs 複数の AbstractThreadedSyncAdapter のインスタンスから同時並行に同期処理を実行することを許すかどうか。複数アカウントを同じアプリケーションが管理する際に使用するため、単一のアカウントしかありえない場合はfalseとする。
android:isAlwaysSyncable 常に同期できるようにしておくかどうか。手動で設定出来るようにする場合はfalseとする。
<!-- res/xml/syncadapter.xml -->
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:contentAuthority="jp.mixi.sample.sync.StubSyncProvider"
    android:accountType="jp.co.mixi.sample.sync.stub.account"
    android:userVisible="false"
    android:supportsUploading="false"
    android:allowParallelSyncs="false"
    android:isAlwaysSyncable="true"/>

AndroidManifest の宣言

定期的な同期の実行は、Android が提供するフレームワークに則って実装し、自動的にフレームワークによって管理・実行されます。このため、定期的な同期の実行の仕組みを持つアプリケーションは、パーミッションの使用を宣言する必要があります。

<uses-permission
        android:name="android.permission.INTERNET"/>
<uses-permission
        android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission
        android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission
        android:name="android.permission.AUTHENTICATE_ACCOUNTS"/>

次に、AbstractThreadedSyncAdapter を動かす Service を宣言します。この Service はシステムによって呼び出される為、android:exportedtrueにしておきます。

<service
    android:name="com.example.android.datasync.SyncService"
    android:exported="true"
    android:process=":sync">
    <intent-filter>
        <action android:name="android.content.SyncAdapter"/>
    </intent-filter>
    <meta-data
        android:name="android.content.SyncAdapter"
        android:resource="@xml/syncadapter" />
</service>

同期の実行と下位互換

これまでは、同期に必要なコンポーネントの作り方を見てきましたが、これらを準備しただけでは同期が実行されるようにはなりません。
アプリケーションの初回実行時に、システムに登録する必要があります。

定期実行する同期処理の登録には、ContentResolver を使用します。また、AccountManager に登録した Account に紐付けるため、Account も必要です。

指定した間隔で同期する

指定した間隔で同期するようシステムに登録するには、ContentResolver#addPeriodicSync(Account, String, Bundle, long)を使用します。

/**
 * @param account 紐付けるアカウント
 * @param frequencySec 同期処理を実行する間隔(秒)
 */
public void applySyncPeriod(Account account, long frequencySec) {
    Bundle args = new Bundle(); // 同期処理の制御に関するパラメータを保持するマップオブジェクト
                                //何らか同期処理のために渡したいパラメータがある場合はこの Bundle に詰め込む
    ContentResolver.addPeriodicSync(account, StubContentProvider.AUTHORITY, args, frequencySec);
}

ネットワークの状態に応じて同期する

Android システムは、ネットワークの接続が確立されると、TCP/IP のコネクションを開いたままの状態に保つために、短い間隔でメッセージを創出し続けます。このメッセージの送出に合わせて同期する場合は、ContentResolver#setSyncAutomatically(Account, String, boolean)を使用して登録します。

/**
 * @param account 紐付けるアカウント
 */
public void applySyncPeriod(Account account) {
    // 最後の boolean を true にすれば同期が有効となり、false にすれば同期が無効化される
    ContentResolver.setSyncAutomatically(account, StubContentProvider.AUTHORITY, true);   
}

短時間で頻繁に同期が実行されるようになりますが、この操作によって、ContentResolver#addPeriodicSync(Account, String, Bundle, long)によって登録された同期処理が無効化されるわけではありません。一定の間隔で実行すればよいものは、ContentResolver#addPeriodicSync(Account, String, Bundle, long)を用いるだけで事足ります。

オンデマンドに同期する

ボタンをおした時など、何かしらのイベントをトリガに同期を手動で実行したい時には、ContentResolver#requestSync(Account, String, Bundle)を使用します。

フレームワークは、定期的に実行するタイミングで効率よく処理を行えるようチューニングされているため、オンデマンドに同期処理を起動することは推奨されていません。また、データの同期が必要ない場合でも無理やり同期を実行させてしまうため、無駄にシステムのリソースを消費してしまう可能性があります。これらの理由から、オンデマンドに同期する機能の実装は推奨されていません。

public void dispatchSync(Account account) {
    Bundle args = new Bundle();
    args.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); // 手動で同期するフラグをシステムに伝える
    args.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); // キューの先頭に積み、すぐに同期を実行するようシステムに伝える
    ContentResolver.requestSync(account, StubContentProvider.AUTHORITY, args);
}

下位互換性

ContentResolver が持つ各種の定期的な同期のためのメソッドは、API Level 8 から導入されました。一方で、AbstractThreadedSyncAdapter など、同期に使われるコンポーネントは API Level 5 から導入されています。このため、定期的に実行するようシステムに伝える部分を、API Level 7 以前の OS の為に自分で実装する必要があります。

単純な、一定間隔での同期の実行であれば、AlarmManagerを用いて対応することができます。

/**
 * @param account 紐付けるアカウント
 * @param frequencySec 同期処理を実行する間隔(秒)
 */
public void applySyncPeriod(Context context, Account account, long frequencySec) {
    Bundle args = new Bundle(); // 同期処理の制御に関するパラメータを保持するマップオブジェクト
                                //何らか同期処理のために渡したいパラメータがある場合はこの Bundle に詰め込む
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO) {
        // AlarmManager ではミリ秒で管理されるのでミリ秒へ変換
        long frequencyMillis = frequencySec * 1000;

        // AlarmManager によって、定期的にブロードキャストされる Intent を準備する
        Intent intent = new Intent(SyncTriggerReceiver.ACTION_TRIGGER, Uri.parse("alarm:" + authority));
        intent.putExtra(SyncTriggerReceiver.EXTRA_SYNC_AUTHORITY, StubContentProvider.AUTHORITY);
        intent.putExtra(SyncTriggerReceiver.EXTRA_SYNC_ACCOUNT_NAME, account.name);
        intent.putExtra(SyncTriggerReceiver.EXTRA_SYNC_ACCOUNT_TYPE, account.type);
        intent.putExtra(SyncTriggerReceiver.EXTRA_SYNC_EXTRAS, extras);

        // AlarmManager によって Intent のハンドリングがなされるため、遅延させるために PendingIntent を作成        
        PendingIntent pendingOperation = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);

        // AlarmManager に登録
        AlarmManager manager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        manager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + frequencyMillis, frequencyMillis, operation);
    } else {
        ContentResolver.addPeriodicSync(account, StubContentProvider.AUTHORITY, args, frequencySec);
    }
}

これに対応して、BroadcastReceiver を準備します。

public class SyncTriggerReceiver extends BroadcastReceiver {
    public static final String ACTION_TRIGGER = "ACTION_TRIGGER";
    public static final String EXTRA_SYNC_AUTHORITY = "authority";
    public static final String EXTRA_SYNC_ACCOUNT_NAME = "account_name";
    public static final String EXTRA_SYNC_ACCOUNT_TYPE = "account_type";
    public static final String EXTRA_SYNC_EXTRAS = "extras";

    @Override
    public void onReceive(Context context, Intent intent) {
        if (ACTION_TRIGGER.equals(intent.getAction)) {
            String accountName = intent.getStringExtra(EXTRA_SYNC_ACCOUNT_NAME);
            String accountType = intent.getStringExtra(EXTRA_SYNC_ACCOUNT_TYPE);
            String authority = intent.getStringExtra(EXTRA_SYNC_AUTHORITY);
            Bundle extras = intent.getParcelableExtra(EXTRA_SYNC_EXTRAS);
            Account account = new Account(accountName, accountType);

            ContentResolver.requestSync(account, authority, extras);
        }
    }
}