Posted by 荒木 佑一 Developer Relations Team

「本記事は Google I/O 2014 アプリのテクニカル リード Bruno Oliveira が 8 月 27 日に Android Developers blog に投稿した「Conference Data Sync and GCM in the Google I/O App」という記事を元に、翻訳・作成しています。詳しくは元記事をご覧ください。 - 荒木」 ...
Posted by 荒木 佑一 Developer Relations Team

「本記事は Google I/O 2014 アプリのテクニカル リード Bruno Oliveira が 8 月 27 日に Android Developers blog に投稿した「Conference Data Sync and GCM in the Google I/O App」という記事を元に、翻訳・作成しています。詳しくは元記事をご覧ください。 - 荒木」

クラウドとのデータ同期は多くのアプリで重要な部分です。Google I/O アプリも例外ではありません。これにあたって標準の Android 機能を存分に活用しました。SyncAdapter です。SyncAdapter を使うことにより、例えば定期的にアラームを仕掛けたりするような原始的なやり方に比べてたくさんのメリットがあります。システムがバッテリー効率を勘案して自動的に SyncAdapter のスケジューリングを行ってくれるからです。

データはローカルの SQLite データベースに格納してあります。ただし、アプリのあらゆる部分が直接データベースにアクセスするようなことはせず、Android 標準のメカニズムに従ってデータへのアクセスを管理しています。皆さんお分かりの通り、ContentProvider を利用しています。SQLite データベースに直接アクセスするのは ContentProvider だけです。アプリの他の部分は ContentProvider を通してのみデータにアクセスするようにします。これにより、データベース内でのデータ表現とアプリ中での表示を切り離して、柔軟に構成することができます。

I/O アプリは大きく分けて 2 種類のデータを扱います。カンファレンス データ(セッション、講演者、部屋など)と、ユーザー データ(ユーザー個人のスケジュール)です。カンファレンス データが Google Cloud Storage に格納された JSON ファイルからデバイスへの一方向にのみ同期されるのに対して、ユーザー データはユーザーの Google Drive AppData フォルダーとの間で双方向に同期されます。



カンファレンス データの効率的なダウンロード

Google I/O ともなると、カンファレンス データはかなり巨大です。セッション、部屋、講演者、地図の位置情報、ソーシャル ハッシュタグ、ビデオ ライブラリの項目など、すべての情報が入っています。すべてのデータを繰り返しダウンロードするのはバッテリー使用料や通信容量の観点から見て無駄です。そこで我々は、どうすればダウンロードしたり処理したりするデータの量を最小化できるか検討しました。

検討の結果、データをいくつかの JSON ファイルに分割し、それらをメインの JSON ファイル(「マニフェスト ファイル」)から参照するようにしました。マニフェスト ファイルの URL が唯一アプリの中に埋め込まれている URL です(Config.javaMANIFEST_URL 定数)。JSON ファイルを置いておくための場所として、今回の I/O アプリでは Google Cloud Storage を利用しましたが、HTTP でアクセスできる同様のホスティング サービスであれば同じように利用できます。

同期プロセスの最初の部分でマニフェスト ファイルを見に行きます。前回ダウンロードしたときから変更があるかチェックし、ある場合のみ処理します。このロジックを実装しているのは RemoteConferenceDataFetcher クラスの fetchConfenceDataIfNewer メソッドです。

public class RemoteConferenceDataFetcher {
    // (...)
    public String[] fetchConferenceDataIfNewer(String refTimestamp) throws IOException {
        BasicHttpClient httpClient = new BasicHttpClient();
        httpClient.setRequestLogger(mQuietLogger);
        // (...)

        // データが refTimestamp より新しいときのみダウンロード
        if (!TextUtils.isEmpty(refTimestamp) && TimeUtils
            .isValidFormatForIfModifiedSinceHeader(refTimestamp)) {
                httpClient.addHeader("If-Modified-Since", refTimestamp);
            }
        }

        HttpResponse response = httpClient.get(mManifestUrl, null);
        int status = response.getStatus();
        if (status == HttpURLConnection.HTTP_OK) {
            // データに変更があったので処理する
        } else if (status == HttpURLConnection.HTTP_NOT_MODIFIED) {
            // サーバーのデータに変更はないので何もしない
            return null;
        } else {
            // (エラー処理)
        }
    }
    // (...)
}

HTTP リクエストに If-Modified-Since ヘッダーがついていることに注意してください。もしマニフェストに前回から変更がない場合、レスポンス コードとして HTTP_OK ではなく HTTP_NOT_MODIFIED が返ってくるので、後はダウンロードもパースも必要ありません。つまり、マニフェスト ファイルに変更がない限り同期プロセスは実に経済的です。HTTP リクエストが 1 回で、レスポンスもとても短いものです。

マニフェスト ファイルのフォーマットは単純明快です。カンファレンス データを含んだ JSON ファイルへの参照が並んでいます。

{
  "format": "iosched-json-v1",
  "data_files": [
    "past_io_videolibrary_v5.json",
    "experts_v11.json",
    "hashtags_v8.json",
    "blocks_v10.json",
    "map_v11.json",
    "keynote_v10.json",
    "partners_v2.json",
    "session_data_v2.681.json"
  ]
}

同期プロセスは次にデータ ファイルを順に処理していきます。この部分も経済的に実装してあります。すでにデータ ファイルのキャッシュがある場合は処理を省略してローカルのキャッシュを使います。この部分は processManifest メソッドで行っています。

JSON ファイルをそれぞれパースして情報をメモリに乗せ、最後にまとめて ContentProvider に書き込みます。

ContentProvideOperation の効率的な実行

カンファレンス データの同期は効率的でなければなりませんが、それはダウンロードするデータの容量だけでなく、データベースに対して実行する処理の量についても同様です。出来る限り経済的に行うために、この部分も最適化されています。データベース全体を新しいデータで上書きするのではなく、既存のローカル データを活かして変更のあるデータだけ更新するようにするのです。

これは 3 階層目の重要な最適化です。これを実現するためには、アプリがメモリ上の情報と ContentProvider 内の情報を比較して、更新の必要があるかどうか判別できなければなりません。メモリーとデータベースのフィールドを 1 つずつ見ていってもいいのですが、手間ですし、すべてのフィールドを見ていくのでパフォーマンスが劣ります。その代わりに、今回は各エンティティーに「インポート ハッシュコード」というフィールドを付け加えることにしました。インポート ハッシュコードはデータから生成される弱いハッシュです。例えば、講演者のデータに対するインポート ハッシュは以下のように求められます。

public class Speaker {
    public String id;
    public String publicPlusId;
    public String bio;
    public String name;
    public String company;
    public String plusoneUrl;
    public String thumbnailUrl;

    public String getImportHashcode() {
        StringBuilder sb = new StringBuilder();
        sb.append("id").append(id == null ? "" : id)
                .append("publicPlusId")
                .append(publicPlusId == null ? "" : publicPlusId)
                .append("bio")
                .append(bio == null ? "" : bio)
                .append("name")
                .append(name == null ? "" : name)
                .append("company")
                .append(company== null ? "" : company)
                .append("plusoneUrl")
                .append(plusoneUrl == null ? "" : plusoneUrl)
                .append("thumbnailUrl")
                .append(thumbnailUrl == null ? "" : thumbnailUrl);
        String result = sb.toString();
        return String.format(Locale.US, "%08x%08x", 
            result.hashCode(), result.length());
    }
}

データベースでエンティティーが更新されるたびにそのインポート ハッシュがデータベースのカラムに保存されます。後から更新されている可能性のあるバージョンがあるときは、そのインポート ハッシュを計算してデータベースに保存されているバージョンのインポート ハッシュと比較するだけでいいのです。値が異なる場合は、データベースのエンティティーを更新する ContentProviderOperation を実行します。値が同一の場合、そのエンティティーの更新は必要ないので飛ばします。この部分の処理は SpeakersHandler クラスの makeContentProviderOperations メソッドなどで見ることができます。

public class SpeakersHandler extends JSONHandler {
    private HashMap mSpeakers = new HashMap();
    // (...)
    @Override
    public void makeContentProviderOperations(ArrayList list) {
        // (...)
        int updatedSpeakers = 0;
        for (Speaker speaker : mSpeakers.values()) {
            String hashCode = speaker.getImportHashcode();
            speakersToKeep.add(speaker.id);

            if (!isIncrementalUpdate || !speakerHashcodes.containsKey(speaker.id) ||
                    !speakerHashcodes.get(speaker.id).equals(hashCode)) {
         // スピーカーが更新されているので、ContentProviderOperation を発行する
                ++updatedSpeakers;
                boolean isNew = !isIncrementalUpdate || 
                 !speakerHashcodes.containsKey(speaker.id);
                buildSpeaker(isNew, speaker, list);
            }
        }

 // 古いスピーカーを削除
        int deletedSpeakers = 0;
        if (isIncrementalUpdate) {
            for (String speakerId : speakerHashcodes.keySet()) {
                if (!speakersToKeep.contains(speakerId)) {
                    buildDeleteOperation(speakerId, list);
                    ++deletedSpeakers;
                }
            }
        }
    }

buildSpeaker()buildDeleteOperation() というメソッド(ここでは中身を省略しています)はただ単に ContentProviderOperation を組み立てるだけです。ContentProvider にスピーカーの情報を挿入、更新、削除する ContentProviderOperation です。ここで注目していただきたいのは、ContentProviderOperation でスピーカー情報を更新するのはインポート ハッシュコードが変わったときだけだということです。また、以前は存在していたのに今はデータ上で参照されていないスピーカー情報はもう必要ないので、削除を行っています。

同期の信頼性を高める

I/O アプリの SyncAdapter はカンファレンス データの同期、ユーザーのスケジュールの同期、ユーザーからのフィードバックの同期など、いくつかのタスクを担っています。ネットワークの状態によっては、そういったタスクが途中で失敗することもあり得ます。それでも、あるタスクが失敗したからといって他のタスクを巻き添えにするのは好ましくありません。そのため、同期のプロセスはそれぞれ独立したタスクで構成されています。つまり、それぞれが try/catch ブロックで保護されています。これは SyncHelper クラスの performSync() メソッドでご覧いただけます。

// リモート同期を構成するタスク。
// 1 つづつ実行される(どれかが失敗しても他に影響を与えない)。
final int OP_REMOTE_SYNC = 0;
final int OP_USER_SCHEDULE_SYNC = 1;
final int OP_USER_FEEDBACK_SYNC = 2;

int[] opsToPerform = userDataOnly ?
        new int[] { OP_USER_SCHEDULE_SYNC } :
        new int[] { OP_REMOTE_SYNC, OP_USER_SCHEDULE_SYNC, OP_USER_FEEDBACK_SYNC};

for (int op : opsToPerform) {
    try {
        switch (op) {
            case OP_REMOTE_SYNC:
                dataChanged |= doRemoteSync();
                break;
            case OP_USER_SCHEDULE_SYNC:
                dataChanged |= doUserScheduleSync(account.name);
                break;
            case OP_USER_FEEDBACK_SYNC:
                doUserFeedbackSync();
                break;
        }
    } catch (AuthException ex) {
        // (... 認証エラーの後始末 ...)
    } catch (Throwable throwable) {
        // (... その他のエラーの後始末 ...)

        // 例外の発生をシステムに知らせる
        if (syncResult != null && syncResult.stats != null) {
            ++syncResult.stats.numIoExceptions;
        }
    }
}

同期プロセスの一部が失敗したときは syncResult.stats.numIoExceptions をインクリメントしてシステムに知らせます。こうしておけば、システムがあとで指数バックオフを利用しつつ同期を再試行します。

同期をいつ行うか。GCM の世界にようこそ。

カンファレンス データの更新を迅速に受け取ることができるかどうかは、ユーザーにとってとても重要です。特に Google I/O の最中(と、その前数日)はそうです。これを実現する方法として安易に思いつくのはアプリがサーバーをポーリングすることですが、当然のごとくそれでは通信帯域やバッテリーを多量に消費してしまいます。

より洗練された解決法として、我々は GCM (Google Cloud Messaging) を利用することにしました。サーバー側に新しいデータがあるときは、サーバーからすべての登録済みデバイスに GCM メッセージを送るのです。GCM メッセージを受け取ったデバイスは同期を開始して新しいカンファレンス データをダウンロードします。GCM メッセージを処理しているのは GCMIntentService クラスです。

public class GCMIntentService extends GCMBaseIntentService {

    private static final String TAG = makeLogTag("GCM");

    private static final Map MESSAGE_RECEIVERS;
    static {
        // 既知のメッセージと GCM メッセージの受信部
        Map  receivers = new HashMap();
        receivers.put("test", new TestCommand());
        receivers.put("announcement", new AnnouncementCommand());
        receivers.put("sync_schedule", new SyncCommand());
        receivers.put("sync_user", new SyncUserCommand());
        receivers.put("notification", new NotificationCommand());
        MESSAGE_RECEIVERS = Collections.unmodifiableMap(receivers);
    }

    // (...)

    @Override
    protected void onMessage(Context context, Intent intent) {
        String action = intent.getStringExtra("action");
        String extraData = intent.getStringExtra("extraData");
        LOGD(TAG, "Got GCM message, action=" + action + ", extraData=" + extraData);

        if (action == null) {
            LOGE(TAG, "Message received without command action");
            return;
        }

        action = action.toLowerCase();
        GCMCommand command = MESSAGE_RECEIVERS.get(action);
        if (command == null) {
            LOGE(TAG, "Unknown command received: " + action);
        } else {
            command.execute(this, action, extraData);
        }

    }
    // (...)
}

ここでは onMessage() メソッドが GCM メッセージの "action" フィールドに従ってメッセージを適切なハンドラーに引き渡しています。action フィールドが "sync_schedule" なら、メッセージは SyncCommand クラスのインスタンスに引き渡され、同期が開始します。ちなみに、SyncCommand クラスに対する GCM メッセージには jitter というパラメーターを指定することができます。同期は即座に行われるのではなく、jitter で指定された猶予期間までのランダムなタイミングで実行されます。これにより、全てのクライアントが一斉にリクエストを投げてサーバー側が大量のリクエストにさらされることを防いでいます。

ユーザー データの同期

I/O アプリではユーザーが興味のあるセッションを選択して自分個人のスケジュールを作ることができるようになっています。これは「私のスケジュール」画面で見ることができます。

このデータはユーザーが利用している複数の Android 端末間で同期され、さらに I/O のウェブサイトとも同期される必要があります。つまり、ユーザーの Google アカウントを使ってクラウド上にデータを保存する必要があります。今回は Google Drive AppData フォルダーを利用することにしました。

ユーザー データは SyncHelper クラスの doUserScheduleSync() メソッドで Google Drive に同期されます。ソース コードを見ていただくとわかりますが、このメソッドでは Google Drive HTTP API を使って Google Drive AppData フォルダーにアクセスし、ユーザーが登録したセッションのデータをクラウド上と端末内とですり合わせた上で、必要に応じてクラウド上のデータにローカルでの変更を反映しています。

つまり、ユーザーが Android 端末であるセッションを登録し、I/O のウェブサイト上で別のセッションを登録した場合、Android 端末と I/O ウェブサイトともに、両方のセッションが登録されている状態に同期されます。

これに加えて、ユーザーが I/O のウェブサイトでセッションを登録したり解除したりした場合にデータがすべての Android 端末に同期される必要があります。逆方向も同様です。このため、ユーザーがスケジュールに変更を行うたび、I/O ウェブサイトから GCM サーバーに通知を送ります。GCM サーバーはユーザーが利用しているすべてのデバイスに GCM メッセージを送信し、同期が開始されます。同じメカニズムが複数の端末の間でも働きます。つまり、あるデバイスでデータが更新されれば、他のすべてのデバイスに GCM メッセージが送信されるのです。

まとめ

データを新鮮に保つことは多くの Android アプリで肝心な機能です。ネットワーク通信やデータベースへの書き込みを最小限にとどめつつデータを最新に保ち、Google Cloud Storage や Google Drive や Google Cloud Messaging を活用して、複数のプラットフォーム、複数の端末間でデータの同期を取るなど、I/O アプリの開発での我々の取り組みについてご紹介しました。