2013年7月10日水曜日

AndroidでCloud Save(Google Play App State)を使ってみた

Cloud Save(Google Play App State)とは

アプリのデータをGoogleのサーバ上に保存することができます。
・保存できるデータサイズは128KB × 4スロット (最大512KB)
・データフォーマットはバイト配列

公式ドキュメント
Google Play Game Services — Google Developers

今回クライアント側はAndroidですが、iOS/Webアプリからも利用できるようです。
ドキュメントではGoogle Play Game Servicesの括りで紹介されていますが、ゲーム以外でも活用できるんじゃないかと思います。

Androidアプリ向けのDeveloper's Guideはこちら。
Cloud Save in Android ― Google Play Game Services — Google Developers

Cloud Saveサービス側のセットアップ

1. Google API ConsoleにアクセスしてAPI Projectを作成。

2. Servicesから[Google Play App State]を有効化。

3. API Accessから[Create an OAuth 2.0 client ID...]をクリック。

4. [Produce name]を入力して[Next]をクリック。

5. Application typeは[Installed application]を選択。
    Installed application typeは[Android]を選択。
    [Pacage name]/[Signing certificate fingerprint]を入力。
    [Create client ID]をクリック。

そうするとClient IDがこんな感じで生成されますので、このClient IDを後述のAndroidManifest.xmlに設定します。

Androidアプリ側のセットアップ

1. Google Play servicesライブラリ(rev.7)をインストールしてEclipseにインポート
2. アプリから上記ライブラリを参照

サンプルコード

MainActivity.java
package com.example.cloudsave;

import android.app.Activity;
import android.content.Intent;
import android.content.IntentSender;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.EditText;
import android.widget.TextView;

import com.google.android.gms.appstate.AppStateClient;
import com.google.android.gms.appstate.OnStateLoadedListener;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesClient.ConnectionCallbacks;
import com.google.android.gms.common.GooglePlayServicesClient.OnConnectionFailedListener;
import com.google.android.gms.common.Scopes;

public class MainActivity extends Activity implements ConnectionCallbacks, OnConnectionFailedListener,
        OnStateLoadedListener, OnClickListener {

    private AppStateClient mAppStateClient;
    private boolean mConnecting;
    private TextView mTextView;
    private EditText mEditText;

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

        String[] scopes = new String[] {
                Scopes.APP_STATE
        };
        mAppStateClient = new AppStateClient.Builder(this, this, this).setScopes(scopes).create();

        findViewById(R.id.button_update).setOnClickListener(this);
        findViewById(R.id.button_load).setOnClickListener(this);
        mTextView = (TextView) findViewById(R.id.textview);
        mEditText = (EditText) findViewById(R.id.edittext);
    }

    @Override
    protected void onStart() {
        super.onStart();

        if (!mConnecting) {
            mConnecting = true;
            mAppStateClient.connect();
        }
    }

    @Override
    protected void onStop() {
        super.onStop();

        mAppStateClient.disconnect();
    }

    @Override
    public void onConnected(Bundle bundle) {
        mConnecting = false;
        mAppStateClient.loadState(this, 0);
    }

    @Override
    public void onDisconnected() {
    }

    @Override
    public void onConnectionFailed(ConnectionResult connectionResult) {
        if (connectionResult.hasResolution()) {
            try
            {
                mConnecting = true;
                connectionResult.startResolutionForResult(this, 0);
            } catch (IntentSender.SendIntentException e) {
                mAppStateClient.connect();
            }
        } else {
            // TODO:接続失敗時の処理
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (resultCode == RESULT_OK) {
            mAppStateClient.connect();
        }
    }

    @Override
    public void onStateConflict(int stateKey, String resolvedVersion, byte[] localData, byte[] serverData) {
        mAppStateClient.resolveState(this, 0, resolvedVersion, serverData);
    }

    @Override
    public void onStateLoaded(int statusCode, int stateKey, byte[] data) {
        if (data == null) {
            return;
        }
        mTextView.setText("Cloud Saveサービス上のデータ:" + new String(data));
        mEditText.setText("");
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.button_update:
                String message = mEditText.getText().toString();
                mAppStateClient.updateStateImmediate(this, 0, message.getBytes());
                break;
            case R.id.button_load:
                mAppStateClient.loadState(this, 0);
                break;
            default:
                break;
        }
    }

}
ざっくり解説
【データセーブ】
データのセーブはupdateStateImmediate()で行う。
更新成功するとonStateLoaded()が呼ばれる。
コンフリクト発生時はonStateConflict()が呼ばれるのでresolveState()で解決する。
【データロード】
データのロードはloadState()で行う。
読み込みが完了するとonStateLoaded()が呼ばれる。

このコードではデータのセーブにAppStateClient.updateStateImmediate()を使っていますが、AppStateClient.updateState()を使った方がバッテリー・ネットワークに優しいそうです。

AndroidManifest.xml
    <application>
        <meta-data
            android:name="com.google.android.gms.appstate.APP_ID"
            android:value="<Your Client ID>" />
    </application>
<application>タグの中にClient IDを設定する。
※レイアウトXMLは割愛

動かしてみた

ActivityのonStart()でconnect()を読んでいるので
アプリを起動するとログインを求めるダイアログが出ます。

サンプルアプリの動きは下記の通りです。
[Update State]ボタンでEditTextに入力した内容をCloud Saveに保存
[Load State]ボタンでCloud Saveからデータを読み込みTextViewに表示

2台の端末からデータのセーブを行うケースで、ローカルの情報が古い状態でデータのセーブを行うとコンフリクトが発生します。
サンプルでは、コンフリクト発生時はサーバ側のデータを採用するようにしています。

コンフリクトの解決についての公式ドキュメントはこちらです。#まだ読んでません...
Resolving Cloud Save Conflicts | Android Developers http://developer.android.com/training/cloudsave/conflict-res.html

最後に

API Consoleを見ると、20,000,000 requests/dayとあります。
上限を超過したときってどうなるんでしょう?
ご存知の方がおられましたら教えてください。