Поделиться через


Azure Mobile Services の Android SDK でのオフライン サポート

このポストは、8 月 7 日に投稿した Offline support in the Azure Mobile Services Android SDK の翻訳です。

前回のブログ記事では、新たに Future のサポートが導入された、Azure Mobile Services の新バージョン (アルファ版) の Android SDK についてご紹介しました。また、この他にオフライン機能がサポートされ、Android SDK がマネージ コードや iOS SDK と同等の機能を持つようになったことも簡単に説明しました。今回は、ToDo アプリをオフラインで使用できるようにする方法を段階的に追いながら、このアプリで必要な各機能を説明し、オフラインのサポートについて詳しくご紹介します。

要約: この記事では、新バージョンの Azure Mobile Services の Android SDK でリリースされた新機能を使用し、Azure ポータルからダウンロードしたクイックスタート アプリケーションを更新してオフラインで使用できるようにする方法を説明します。この記事で使用しているサンプル コードの全文はサンプル リポジトリ (英語) からダウンロードできます。また、SDK のソースはメイン リポジトリの Android ブランチ (英語) で確認できます。

準備

説明を開始する前に、クイックスタート アプリケーションを使用してみましょう。このアプリケーションはポータルからダウンロードできます。まず、新しい Mobile Services を作成します。この例では、Visual Studio がなくてもセットアップが可能な node.js バックエンドを使用します。

サービスの作成が完了したら Android プラットフォームを選択し、[Create TodoItem Table] をクリックします。その後、スターター プロジェクトをコンピューターにダウンロードします。

Eclipse でプロジェクトを開くと準備完了です。

クイックスタート アプリケーションの更新

これから説明するオフライン サポートには便利な機能がいくつかありますが、その中の 1 つに、ローカルで変更された内容を Azure の Table にプッシュするときに発生する競合を解決するという機能があります。たとえば、2 つの携帯端末で同じアプリを使用していて、両方で同じアイテムを変更した場合、サーバーに変更をプッシュしようとしても一方は競合により正常に処理されません。しかし、この SDK ではこうした競合をコードで制御し、競合が発生したアイテムをどのように処理するかを指定できます。

ただし、現状のクイックスタート アプリではアイテムに完了マークを付けることしかできないため、競合が発生する場面はあまりありません。両方のクライアントで同時に同じアイテムに完了マークを付けると技術的には競合が発生し、フレームワークでは競合の発生を示すフラグが立てられますが、これではあまり面白味がありません。このため、クイックスタート アプリを変更して、ToDo のアイテムを完全に編集できるようにします。

ダウンロードしたコードは、アプリでアイテムの編集ができるように更新が済んでいます。この更新作業を自分で行う場合は、この記事末尾の付録を参照してください。

SDK の更新

アプリケーションにオフラインのサポートを追加するには、まずオフライン機能がサポートされているバージョンの Mobile Services の Android SDK を入手する必要があります。この機能はプレビューとしてリリースされているため、公式の場所からはダウンロードできませんが、https://aka.ms/Iajk6q からローカルにダウンロードできます。ダウンロードが完了したら、mobileservices-2.0.0-alpha.jar および guava-17.0.jar の 2 つを解凍してプロジェクトの libs フォルダーにコピーし、古いバージョンの SDK (このブログの執筆時点では mobileservices-1.1.5.jar) を削除して Eclipse で libs フォルダーの内容を更新します。

以上の操作を行うと、プロジェクトで大量のエラーが発生するようになります。これは、前回の記事でご紹介した大規模な変更によるものです。エラーの大半は ToDoActivity.java で発生していて、その原因のほとんどが、クラスが異なるパッケージに移動されたためです。Eclipse でファイルを指定した後 [Source] メニューの [Organize Imports] (Ctrl+Shift+O) をクリックすると、大半のエラーが修正されます。残りのエラーを修正するには、クラス宣言の前に次の import 文を追加します。

 import static com.microsoft.windowsazure.mobileservices.table.query.QueryOperations.*;

さらに、サービスのフィルターを更新します。これは、インターフェイスが新しいコントラクトにどの程度準拠しているかを Future を使用して示すものです。

 private class ProgressFilter implements ServiceFilter {

    @Override
    public ListenableFuture<ServiceFilterResponse> handleRequest(
            ServiceFilterRequest request, NextServiceFilterCallback next) {

        runOnUiThread(new Runnable() {

            @Override
            public void run() {
                if (mProgressBar != null) mProgressBar.setVisibility(ProgressBar.VISIBLE);
            }
        });

        ListenableFuture<ServiceFilterResponse> result = next.onNext(request);

        Futures.addCallback(result, new FutureCallback<ServiceFilterResponse>() {
            @Override
            public void onFailure(Throwable exc) {
                dismissProgressBar();
            }

            @Override
            public void onSuccess(ServiceFilterResponse resp) {
                dismissProgressBar();
            }

            private void dismissProgressBar() {
                runOnUiThread(new Runnable() {

                    @Override
                    public void run() {
                        if (mProgressBar != null) mProgressBar.setVisibility(ProgressBar.GONE);
                    }
                });
            }
        });

        return result;
    }
}

これでアプリが最新の SDK を使用し、以前と同様に動作するようになります。

新しい (Future をベースとする) API を使用するようにモバイル サービスの呼び出しを更新する

完全にオフラインで使用できるようにするには、今回のリリースで導入された新しい Future をベースとする API を使用するようにテーブル操作を変更する必要があります。オフラインで使用されるクラスにはコールバックをベースとするメソッドがないため、アプリケーションを常時接続から一時接続に移行するのは簡単です。ここでは、バックグラウンドのスレッドの操作に関連する任意のアクションを開始し、操作の結果を取得した後、何らかの UI コンポーネントの変更が必要な場合はメイン (UI) スレッドにコールバックを送信します。この例では、addItem メソッドを次のように書き換えます。

 public void addItem(View view) {
    if (mClient == null) {
        return;
    }

    // 新しいアイテムを作成
    final ToDoItem item = new ToDoItem();

    item.setText(mTextNewToDo.getText().toString());
    item.setComplete(false);

    // 新しいアイテムを挿入
    new AsyncTask<Void, Void, Void>() {

        @Override
        protected Void doInBackground(Void... params) {
            try {
                mToDoTable.insert(item).get();
                if (!item.isComplete()) {
                    runOnUiThread(new Runnable() {
                        public void run() {
                            mAdapter.add(item);
                        }
                    });
                }
            } catch (Exception exception) {
                createAndShowDialog(exception, "Error");
            }
            return null;
        }
    }.execute();

    mTextNewToDo.setText("");
}

update メソッドも上記と同様です。

 private void updateItem(final ToDoItem item) {
    if (mClient == null) {
        return;
    }

    new AsyncTask<Void, Void, Void>() {

        @Override
        protected Void doInBackground(Void... params) {
            try {
                mToDoTable.update(item).get();
                runOnUiThread(new Runnable() {
                    public void run() {
                        if (item.isComplete()) {
                            mAdapter.remove(item);
                        }
                        refreshItemsFromTable();
                    }
                });
            } catch (Exception exception) {
                createAndShowDialog(exception, "Error");
            }
            return null;
        }
    }.execute();
}

最後に、テーブルからアイテムを取得する方法をご紹介します。これも上記と同様です。

 private void refreshItemsFromTable() {

    // 完了マークが付けられていないアイテムを取得し
    // アダプターに追加する
    new AsyncTask<Void, Void, Void>() {

        @Override
        protected Void doInBackground(Void... params) {
            try {
                final MobileServiceList<ToDoItem> result = mToDoTable.where().field("complete").eq(false).execute().get();
                runOnUiThread(new Runnable() {

                    @Override
                    public void run() {
                        mAdapter.clear();

                        for (ToDoItem item : result) {
                            mAdapter.add(item);
                        }
                    }
                });
            } catch (Exception exception) {
                createAndShowDialog(exception, "Error");
            }
            return null;
        }
    }.execute();
}

これでまたアプリケーションが以前と同様に動作するようになりました。

テーブルから同期テーブルへの移行

ここまでで、アプリをオフラインで使用できるようになりました。次に、テーブルにアクセスするときに使用するクラスを変更します。具体的には、MobileServiceTable<E> または MobileServiceJsonTable ではなく、新しいクラスの MobileServiceSyncTable<E> または MobileServiceJsonSyncTable を使用するように変更します。他のプラットフォームと同様、同期テーブルとは基本的にローカルでの変更を対応する「リモート」テーブルにプッシュする方法、およびリモート テーブルからローカルにアイテムを「プル」する方法が指定されているローカル テーブルです。変更の追跡は同期コンテキストにより行われます。このとき、アイテムをローカルに格納するために使用するローカル ストアを初期化する必要があります。Mobile Services の SDK では SQLite データベースを使用するストアが実装されているので、この記事ではこれを使用します。

まず、ローカル ストアを初期化します。MobileServiceClient インスタンスを作成した後、onCreate メソッドに次の行を追加します。

 SQLiteLocalStore localStore = new SQLiteLocalStore(mClient.getContext(), "ToDoItem", null, 1);
SimpleSyncHandler handler = new SimpleSyncHandler();
MobileServiceSyncContext syncContext = mClient.getSyncContext();

Map<String, ColumnDataType> tableDefinition = new HashMap<String, ColumnDataType>();
tableDefinition.put("id", ColumnDataType.String);
tableDefinition.put("text", ColumnDataType.String);
tableDefinition.put("complete", ColumnDataType.Boolean);

localStore.defineTable("ToDoItem", tableDefinition);
syncContext.initialize(localStore, handler).get();

最後の 2 行は、ローカル テーブル (ストアで定義) にアクセスする場合、またはコンテキストを初期化する場合に、例外をスローします。このため、これらを処理できるように例外ハンドラーも更新します。

 } catch (MalformedURLException e) {
    createAndShowDialog(new Exception("There was an error creating the Mobile Service. Verify the URL"), "Error");
} catch (Exception e) {
    Throwable t = e;
    while (t.getCause() != null) {
        t = t.getCause();
    }
    createAndShowDialog(new Exception("Unknown error: " + t.getMessage()), "Error");
}

これでコンテキストが初期化されます。メイン アクティビティの mToDoTable フィールドの型を MobileServiceTable<ToDoItem> から MobileServiceSyncTable<ToDoItem> に変更します。getSyncTable メソッドを使用するように、onCreate メソッドのこのフィールドの初期化部分を変更します。さらに、ローカル テーブルから読み込む際にクエリを渡し「通常の」テーブルからクエリ オブジェクトを取得できるようにするために、新たに private フィールドを定義する必要があります。

 /**
 * リモート サーバーからデータをプルする際に使用するクエリ
 */
private Query mPullQuery;

onCreate メソッドでこれを初期化します。

 // データを読み込む際に使用するクエリを保存
mPullQuery = mClient.getTable(ToDoItem.class).where().field("complete").eq(false);

このクエリを使用するように refreshItemsFromTable メソッドのコードを更新します。

 final MobileServiceList<ToDoItem> result = mToDoTable.read(mPullQuery).get();

これで、アプリを完全にオフラインで実行できるようになりました。

プルとプッシュ

過去にこのアプリを実行したことのある方は、以前にはアプリで表示されていたアイテムがリストに表示されていないことにお気付きかと思います。これは、サーバー側との同期をまったく行っていないからです。ここではローカルの (同期) テーブルについて説明しているのですが、同期を行っていないので、このコードを最初に実行するときには空の (ローカル) テーブルに対して実行することになります。このため、サーバーから既存のデータを同期テーブルにプルする方法と、ローカルで実施された変更をサーバー側にプッシュする方法を指定する必要があります。

クイックスタート アプリでは、右上隅にある [REFRESH] メニュー項目にこのロジックを実装できます。データを同期するタイミングなどの要件はアプリごとに異なりますが、このアプリではユーザーが明示的に要求することにします。onOptionsItemSelected メソッドで新しいバックグラウンド タスクを開始し、その場でまず同期コンテキストを使用してすべての変更をローカル ストアにプッシュし、その後同期テーブルを使用して必要なデータをすべてローカル テーブルにプルします。

 public boolean onOptionsItemSelected(MenuItem item) {
    if (item.getItemId() == R.id.menu_refresh) {
        new AsyncTask<Void, Void, Void>() {

            @Override
            protected Void doInBackground(Void... params) {
                try {
                    mClient.getSyncContext().push().get();
                    mToDoTable.pull(mPullQuery).get();
                    refreshItemsFromTable();
                } catch (Exception exception) {
                    createAndShowDialog(exception, "Error");
                }
                return null;
            }

        }.execute();
    }

    return true;
}

これで、アプリを実行して [REFRESH] ボタンをタップすると、サーバーに存在するすべてのアイテムが表示されるようになります。この時点でデバイスのネットワーク接続を切断しても、引き続き変更を行うことが可能でアプリは正常に動作します。変更をサーバーに同期するときにはネットワークに再び接続し、[REFRESH] ボタンをタップします。

ここで、注意が必要なことがあります。ローカル ストアに保留中の変更がある場合、プル操作では最初にこの変更がサーバーにプッシュされます。このため、同一行に変更が存在していた場合、プッシュ操作の処理が失敗することになり、アプリケーションが競合を適切に処理します。つまり、上記のコードのプッシュ呼び出しは必ずしも必要なわけではありません。このように説明しているのは、私自身、コードが何を実行しているかをはっきりと示したほうがよいと考えているからです。

競合の処理

データのオフライン処理は魅力的ですが、競合が発生した場合にプッシュ操作では何が実行されるのでしょうか。これまでに作成されたアプリでは、ToDoItem クラスにバージョン列が格納されていないため、それまでに行われたサーバーのデータの変更をすべて無視して上書きします (基本的に「クライアント優先」の競合解決ポリシーを適用)。しかし、この処理をより高度なものに変更することができます。まずバージョンを追加して、オプティミスティック同時実行 (英語) が強制適用されるようにすると共に、更新がアイテムのバージョンに従って実行されるようにします。ToDoItem クラスに下記のメンバーを追加します。

 /**
 * データベース内のアイテムのバージョン
 */
@com.google.gson.annotations.SerializedName("__version")
private String mVersion;

/**
 * データベース内のアイテムのバージョンを取得
 *
 * @return データベース内のアイテムのバージョン
 */
public String getVersion() {
    return mVersion;
}

/**
 * データベース内のアイテムのバージョンを設定
 *
 * @param mVersion データベース内のアイテムのバージョン
 */
public void setVersion(String mVersion) {
    this.mVersion = mVersion;
}

さらに、ローカル テーブルを onCreate メソッドで定義する際に新しい列を追加します。

 tableDefinition.put("__version", ColumnDataType.String);

これで、Fiddler や Postmon などのツールでアイテムを編集しているユーザーが、同一アイテムをアプリケーションで (または 2 つの異なるデバイスやエミュレーターで) 編集すると、プッシュのときにエラーが発生するようになります。しかし、下記のようにカスタム同期ハンドラーを実装すると、このエラーを処理し、独自の競合解決ポリシーを実装することができます。

 private class ConflictResolvingSyncHandler implements MobileServiceSyncHandler {

    @Override
    public JsonObject executeTableOperation(
            RemoteTableOperationProcessor processor, TableOperation operation)
            throws MobileServiceSyncHandlerException {

        MobileServicePreconditionFailedExceptionBase ex = null;
        JsonObject result = null;
        try {
            result = operation.accept(processor);
        } catch (MobileServicePreconditionFailedExceptionBase e) {
            ex = e;
        } catch (Throwable e) {
            ex = (MobileServicePreconditionFailedExceptionBase) e.getCause();
        }

        if (ex != null) {
            // 競合が検出された場合、アイテムのクライアント バージョンを破棄して
            // 強制的にサーバーを "優先" する
            // どのバージョンを維持するかをユーザーに確認するなど、
            // 他のポリシーも使用可能
            JsonObject serverItem = ex.getValue();

            if (serverItem == null) {
                // 例外でアイテムが返されず、サーバーから取得
                try {
                    serverItem = mClient.getTable(operation.getTableName()).lookUp(operation.getItemId()).get();
                } catch (Exception e) {
                    throw new MobileServiceSyncHandlerException(e);
                }
            }

            result = serverItem;
        }

        return result;
    }

    @Override
    public void onPushComplete(MobileServicePushCompletionResult result)
            throws MobileServiceSyncHandlerException {
    }
}

これで、アプリケーション実行時に競合が発生しても自動的に処理されるようになります。

まとめ

マイクロソフトでは現在、ネイティブな iOS アプリケーションでのオフライン サポートの開発を進めており、マネージ コード用 SDK と同様にプレビューとしてリリースする予定です。Azure Mobile Services の SDK の改良に引き続き取り組むために、皆様からのフィードバックをお待ちしております。いつものお願いですが、この記事に関するご意見やご提案、ご質問がございましたら、MSDN のフォーラムまたは Twitter アカウントの @AzureMobile までお寄せください。

この記事で使用したコードは、GitHub の Azure Mobile Services 用サンプル リポジトリ (英語) からダウンロードできます。また、Azure Mobile Services の Android SDK バージョン 2.0 (アルファ版) は https://aka.ms/Iajk6q からダウンロードできます。完全版クライアント SDK をお求めの上級者の方は、azure-mobile-services リポジトリの Android ブランチ (英語) をご利用ください。

付録: ToDoItem プロジェクトを変更してこの記事で使用できるようにする方法

ここからは、ポータルからダウンロードした既存の ToDo リスト アプリを変更してアイテムを編集できるようにする方法を説明します。

新規アクティビティの追加

新しい画面でアイテムを編集できるようにするために、まずは新しいアクティビティをプロジェクトに追加します。Eclipse でプロジェクトのアイコンを右クリックし、[New]、[Other] の順にクリックして Android ノードを展開し、さらに [Android Activity]、[Empty Activity] の順にクリックします。

新しいアクティビティに EditToDoActivity という名前を付けて [Finish] をクリックします。

これでアクティビティを構成する準備ができました。文字列ファイル (res/values/strings.xml) を開いて、ローカライズ警告が発生しないように変更します。

  • “hello_world” という文字列の値を削除します。
  • “title_activity_edit_to_do” の値を “EditToDoActivity” から “Edit ToDo item” に変更します。
  • 下記の文字列を新たに追加します。
    • <string name=”label_item_text”>Item</string>
    • <string name=”label_complete”>Complete</string>
    • <string name=”button_item_edit_done”>Done</string>

新しいアクティビティのレイアウトを開き、既定のテキスト ビューを削除します。次に、編集対象のアイテムのテキストを含む編集テキスト (@+id/textBoxEditItem, hint:@string/title_activity_edit_to_do)、アイテムの完全なステータスを含むチェックボックス (@+id/checkBoxItemComplete; text:@string/label_complete)、および編集終了を示すボタン (@+id/buttonDoneEditing; text:@string/button_item_edit_done) の各コントロールを追加します。このレイアウトを実装すると下図のようになります。さらに、ユーザーの利便性を向上させるために、アイテムのテキストのラベルを持つテキスト ビュー (text:@string/label_item_text) を追加します。

既存のチェックボックスを削除してテキスト ビュー (@+id/todoItemText) に置き換え、テーブルの行テンプレート (res/layout/row_list_to_do.xml) を変更します。これでメイン アクティビティでアイテムのみを表示し、クリックすると新しいアクティビティに切り替えて編集可能な状態にします。

次に、コードについて説明します。src/com.example.<プロジェクト名> にある EditToDoActivity.java を開き、その内容を次のコードに置き換えます。

 public class EditToDoActivity extends Activity {

    protected static final String ITEM_TEXT_KEY = "com.example.blog20140807.item_text";
    protected static final String ITEM_COMPLETE_KEY = "com.example.blog20140807.item_complete";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_edit_to_do);

        Bundle extras = getIntent().getExtras();
        String itemText = extras.getString(ITEM_TEXT_KEY);
        boolean itemComplete = extras.getBoolean(ITEM_COMPLETE_KEY);

        final EditText itemTextBox = (EditText)findViewById(R.id.textBoxEditItem);
        itemTextBox.setText(itemText);
        final CheckBox completeCheckbox = (CheckBox)findViewById(R.id.checkBoxItemComplete);
        completeCheckbox.setChecked(itemComplete);

        Button btnDone = (Button)findViewById(R.id.buttonDoneEditing);
        btnDone.setOnClickListener(new View.OnClickListener() {

            @Override
            public void onClick(View v) {
                Intent i = new Intent();
                i.putExtra(ITEM_TEXT_KEY, itemTextBox.getText().toString());
                i.putExtra(ITEM_COMPLETE_KEY, completeCheckbox.isChecked());
                setResult(RESULT_OK, i);
                finish();
            }
        });
    }
}

コードを更新して新しいアクティビティを使用する

アダプターのコード (src/com.example.<プロジェクト名>/ToDoItemAdapter.java) を開き、getView の実装を次のコードに置き換えます。

 @Override
public View getView(int position, View convertView, ViewGroup parent) {
    View row = convertView;

    final ToDoItem currentItem = getItem(position);

    if (row == null) {
        LayoutInflater inflater = ((Activity) mContext).getLayoutInflater();
        row = inflater.inflate(mLayoutResourceId, parent, false);
    }

    row.setTag(currentItem);
    final TextView textView = (TextView) row.findViewById(R.id.todoItemText);
    textView.setText(currentItem.getText());

    return row;
}

メイン アクティビティ (src/com.example.<プロジェクト名>/ToDoActivity.java) に移動します。まずパブリックの checkItem メソッドを削除して、下記の新しい updateItem メソッドに置き換えます (完了マークを付ける以外の操作を可能にするため)。

 private void updateItem(ToDoItem item) {
    if (mClient == null) {
        return;
    }

    mToDoTable.update(item, new TableOperationCallback<ToDoItem>() {

        public void onCompleted(ToDoItem entity, Exception exception, ServiceFilterResponse response) {
            if (exception == null) {
                if (entity.isComplete()) {
                    mAdapter.remove(entity);
                }

                refreshItemsFromTable();
            } else {
                createAndShowDialog(exception, "Error");
            }
        }

    });
}

次に、下記のフィールドをクラスに追加します。そのうちの 1 つは編集されたアイテムを追跡するもので、もう 1 つは編集アクティビティをタグ付けするものです。

 /**
 * 編集中のアイテムの位置
 */
private int mEditedItemPosition = -1;

private static final int EDIT_ACTIVITY_REQUEST_CODE = 1234;

ここからはアクティビティのジャンプ コードについて説明します。編集アクティビティに移動するには、onCreate メソッドのコードで、アイテムを表示するリストに OnItemClickListener を追加します。ハンドラーで新たに Intent を作成し、編集中のアイテムの情報を追加して (他のアクティビティがこの情報を取得します)、このアクティビティを開始します。

 // アダプターを作成して、ビューにアイテムをバインド
mAdapter = new ToDoItemAdapter(this, R.layout.row_list_to_do);
ListView listViewToDo = (ListView) findViewById(R.id.listViewToDo);
final ListView listViewToDo = (ListView) findViewById(R.id.listViewToDo);
listViewToDo.setAdapter(mAdapter);

listViewToDo.setOnItemClickListener(new OnItemClickListener() {

    @Override
    public void onItemClick(AdapterView<?> parent, View view,
            int position, long id) {
        Intent i = new Intent(getApplicationContext(), EditToDoActivity.class);
        mEditedItemPosition = position;
        ToDoItem item = mAdapter.getItem(position);
        i.putExtra(EditToDoActivity.ITEM_COMPLETE_KEY, item.isComplete());
        i.putExtra(EditToDoActivity.ITEM_TEXT_KEY, item.getText());
        startActivityForResult(i, EDIT_ACTIVITY_REQUEST_CODE);
    }
});

// Mobile Services からアイテムを読み込む
refreshItemsFromTable();

最後に onActivityResult メソッドを上書きして、新しいアクティビティから結果が返された場合には、この実装で何らかの変更があったかどうかを確認します。変更があった場合は、updateItem メソッドを呼び出してその変更を保存します。

 @Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if (requestCode == EDIT_ACTIVITY_REQUEST_CODE && resultCode == RESULT_OK && mEditedItemPosition >= 0) {
        ToDoItem item = mAdapter.getItem(mEditedItemPosition);
        String text = intent.getExtras().getString(EditToDoActivity.ITEM_TEXT_KEY);
        boolean complete = intent.getExtras().getBoolean(EditToDoActivity.ITEM_COMPLETE_KEY);

        if (!item.getText().equals(text) || item.isComplete() != complete) {
            item.setText(text);
            item.setComplete(complete);
            updateItem(item);
        }
    }
}

これで準備が整い、クイックスタート アプリでアイテムを編集できるようになりました。この記事の説明に従ってオフライン サポートをこのアプリに追加してみてください。