雑念日記

主に技術的なことをつらとらと(書ければいいな)。

Processing AndroidモードでGoogle Play Game Servicesを利用する

某フォーラムにトピックが上がってたのと、(今のところ使う予定は特に無いが)個人的に興味があったのでいろいろ調べてみたまとめ。

長いので結論から先に書くと以下のようにProcessing Androidモードを利用しつつGoogle Play Game Servicesを利用することが出来た。

f:id:hoshi_sano:20150121005124p:plainf:id:hoshi_sano:20150121005131p:plainf:id:hoshi_sano:20150121005142p:plainf:id:hoshi_sano:20150121005153p:plainf:id:hoshi_sano:20150121005158p:plain


ただしProcessingユーザとしてProcessingのAPIやツールを使って何かできることは少なく、基本的にある程度のAndroidアプリ開発の知識が必要だろうということがわかった。
加えてProcessingのAndroidモードがAndroidの仕組みにおいてどのように動作しているかを多少知っておく必要もあった。

Androidアプリの開発にProcessing(とその周辺のライブラリ)しか使ったことのない人がすんなり越えられるハードルではない気がする。
かく言う僕もAndroidアプリ開発の経験はほぼ皆無で、以降の説明もおそらく不自然な点があろうかと思うので、詳しい方はご指摘いただけると幸いです。

全体のソースコードはこちら。

GitHub - hoshi-sano/processing-google-play-game-services-sample: Processing-Android with Google Play Game Services Sample

Googleのドキュメントとサンプルを参考にしつつ、適宜手を入れたのでもっと良いやり方はあるかもしれない。
そのままでは動かないので、もしお手元で動かしてみる場合にはREADME.mdと以降の内容を参考にしてみてください。

そもGoogle Play Servicesとは何か

Google Play Servicesとはその名の通りGoogleが提供するサービスで、ゲーム関連の機能に絞ったものがGoogle Play Game Servicesとなる。AndroidiOS、Web上のゲームにおいてクラウドへのセーブデータ保存ができたり、実績(Achievements, 達成条件を満たすと貰える証。メダルとかトロフィーみたいなもの)やリーダーボード(Leaderboards, スコアランキングみたいなもの)の管理ができる。これによってユーザがゲーム体験を共有したりそれぞれの腕前を比較したりできる。なおユーザがこれらの機能を利用するにはGoogle+のIDでサインインする必要があり、実績やスコアはGoogle+アカウントに紐付けられる模様。つまり開発側はアプリ内にGoogle+のサインインの口を用意してあげる必要がある。

開発者がこれらの機能を利用するにはGoogleデベロッパーへの登録が必須である。(お布施25ドルが必要)
アプリ開発には、GoogleがSDKを用意しているので特に理由が無ければこれらを利用するのが簡単だと思う。
サンプルも用意されているので、使い方も参考になる。

ProcessingとはいえAndroidモードを前提としている以上、Android用に用意されたSDKを使うのが自然かと思うので、以降はその方針で記述する。
Webアプリ向けにREST APIも公開されているし、ProcessingにはHTTPクライアントもあるので、必ずしもAndroid用のライブラリを使わなければならないというわけではないはず、なんだけど、それはそれで誰かが便利ライブラリみたいなのを作ってくれない限りは面倒だと思う(し、いまのところそのようなProcessing用ライブラリは観測していない)。

基本的に公式のドキュメントに沿っていけば各種手順やトラブルシューティングは問題ないと思う。
英語が辛い場合は日本語情報も結構ある。この記事ではDeveloper Consoleでの登録などについては詳細は書かないので、その点においてはこれらも参考にされたし。

注意点として、アプリ内でGoogle Play Servicesを利用する場合に、ボタンやアイコンなどに使っていいモノ悪いモノ、やっていいこと悪いことなどがガイドラインにまとめられているので、これに従う必要がある。

細かい話を始める前に

エミュレータでの動作確認について

Google Play ServicesのAndroidSDKを使って動作確認するにあたって、エミュレータは最新のものを使うべし。最新でないと以下のようなログが出て動かなかったりする。

W/GooglePlayServicesUtil( 2042): Google Play services out of date.  Requires 6587000 but found 4323036
PDEからのエミュレータでの動作確認について

Android用のSDKを利用するには、まずAndroid SDK Managerから「Google Play Services」をインストールする必要があるが、Processing(PDE)で利用するにはこれだけでは足りない。PDEのメニューからライブラリを追加できないためだ。メニューからライブラリを使うには以下に従う必要がある。

Library Basics · processing/processing Wiki · GitHub

要はスケッチブックフォルダ以下のlibrariesフォルダに、ライブラリのフォルダ名とjarファイル名を同じにして配置せよ、ということ。すなわち、以下のような感じ。

  /path/to/sketchbook/libraries/GooglePlayServices
  └── library
       └── GooglePlayServices.jar

SDKを入手すると、以下のようなパスにgoogle-play-services.jarがあるのでこれを利用した。

  /path/to/android-sdk/extras/google/google_play_services/libproject/google-play-services_lib/libs/google-play-services.jar

ただし、元の名前のまま以下のような構造で配置すると、Processingのライブラリの命名規則に従っていないという理由からエラーが発生するため、上のようにjarファイルをリネームして使う。(何とも気持ちの悪い)

  /path/to/sketchbook/libraries/google-play-services
  └── library
       └── google-play-services.jar

以上を実施した上で、PDEの「スケッチ > ライブラリのインポート > GooglePlayServices」を選択すると、以下のリンク先のような感じで大量のimport文が自動で挿入される。

import_google_play_services_lib.pde · GitHub

ここまで実施するとGoogle Play Servicesのライブラリを利用しつつPDEからエミュレータでの実行を選択して動作確認できる。またビルドすると依存ライブラリとしてGooglePlayServicesがプロジェクトファイルに含まれる。

...のだけれども、ドキュメントに沿って以降の作業を行おうとすると、どうしてもエクスポートしたプロジェクトファイル群を直接編集しなければならなくなる。

あくまで「"Processingでの"Androidアプリ開発」に重点を置きたかったので、PDE上でできることはPDE上でやりたかったが、お手本通りに進めるには結局エクスポートしたファイル群をイジらないとどうしようもなさそうなので、以降ではそのようにする。そのため以下の手順ではPDEはほぼ使っていない。僕はEclipseAndroid StudioなどのIDEを使わないので生のファイルをエディタでイジる想定で書いているが、IDE使いの人は適宜読み替えてほしい。

というわけで、PDEのためにわざわざリネームしたGooglePlayServices.jarは使わない。
もし本記事の手順を追っかけるのであれば、ここまでのところではSDK Managerから「Google Play Services」をインストールするだけでよいです。

何はともあれエクスポート

Processingのコードは既にある程度書けていて、Developer Consoleでのアプリの登録、実績やリーダーボードの作成、テスターの登録などは完了している想定。
公式ドキュメント的には以下あたりを実施する。

Get Started with Play Games Services for Android  |  Play Games Services for Android  |  Google Developers

↑はAndroidManifest.xmlとかを編集せよ、という内容なわけだけれども、PDEからはAndroidManifest.xmlについてパーミッションまわりしか操作できないっぽいので、とりあえずプロジェクトファイルをエクスポートし、前述のとおりファイルを直接編集する。

セッティング

まずDeveloper Consoleで得られた各種IDを持たせてあげることから始める。
以下のファイルを作成して、

  <アプリのルートディレクトリ>/res/values/ids.xml

以下の内容を記述する。

<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_id">Replace Me</string>

<string name="achievement_a">Replace Me</string>
<string name="achievement_b">Replace Me</string>
<string name="achievement_c">Replace Me</string>

<string name="test_leader_board">Replace Me</string>

</resources>

「Replace Me」となっているところは各自取得したIDで置換すること。
app_idはDeveloper Consoleで登録したゲームに割り当てられたクライアントIDを使う。

f:id:hoshi_sano:20150121005643p:plain

achievement_xxxは、Developer Consoleで登録した実績の数だけ記述しておく。
名前はacievement_xxxでなくて何でも良いがソースコードの中で利用するので使いやすい名前がよい。
IDはDeveloper Consoleで表示されるIDを使う。

f:id:hoshi_sano:20150121005659p:plain
(あ、誤字発見...)

リーダーボードも実績と同じく。
なおこのxmlはDeveloper Consoleの実績管理画面またはリーダーボード管理画面にある「リソースを取得」というリンクをクリックすると自動で生成されるので、それをそのままコピペしても良い。

f:id:hoshi_sano:20150121005715p:plain

次にAndroidManifest.xmlのapplicationタグの中に以下の2つのメタデータを埋め込む。

   <meta-data android:name="com.google.android.gms.games.APP_ID"
              android:value="@string/app_id"/>
   <meta-data android:name="com.google.android.gms.version"
              android:value="@integer/google_play_services_version"/>

ひとつめはids.xmlで定義したアプリケーションのID、ふたつめはGoogle Play Services SDKのバージョン。
google_play_services_versionはそのままだと参照できないので、次の手順を実施する必要がある。
(次の手順を行わず、app_idと同様にids.xmlgoogle_play_services_versionも定義するという手もあるが、おそらく推奨されない。また、Eclipseなどでは以下の手順はGUIで出来そうな気がする。)

以下のファイルに

  <アプリのルートディレクトリ>/project.properties

以下を追記する。このとき相対パスを使うこと。

android.library.reference.1=../path/to/android-sdk/extras/google/google_play_services/libproject/google-play-services_lib

antを使う人はgoogle-play-services_lib以下にbuild.xmlを生成する必要があるので以下も実施する。

  $ cd /path/to/android-sdk/extras/google/google_play_services/libproject/google-play-services_lib
  $ android update project --target <APIレベル> --path .

ここまででエクスポートしたプロジェクトファイルがビルドできるかどうか一旦確認しておくとよい。

GoogleAPIClientを使うコードを書く

ドキュメント的には以下の手順に相当する。

Get Started with Play Games Services for Android  |  Play Games Services for Android  |  Google Developers

まずはここ
いきなりActivityとか出てきて、非Androidデベロッパーには何をどうすれば的な感じになるので、ここでProcessing Androidモードの挙動について整理する。

Processing Androidモードについて

まずAndroidのキホンとして、ActivityやViewの概念を知る必要がある。

Activityとはユーザとアプリケーション間のやりとりを仲介するオブジェクトである、という説明がされる。
1つの画面に対して1つのActivityが対応づけられる。Activity間の連携も可能で、Activityから他のActivityを起動したり、他のアプリケーションのActivityから何らかの処理結果を受け取ったりできる。
Activityにはアプリの動作に伴う起動から終了までのライフサイクルがあり、状態が変わる毎に対応するコールバックメソッドが実行される。例えば、Activity起動時にはonCreate()が実行され、Activityが非表示になった際にはonStop()が実行される。

Viewとはテキストやボタンなど画面に表示する要素のことを指し、TextViewやButtonやViewGroupなど多くのサブクラスを持つ。目的に応じて階層構造的にViewやViewGroupを構成していくことで、複雑なUIを実現することも可能。

Processing Androidモードでは、スケッチのベースとなるPAppletクラスがAcitiviyを継承しているため、PAppletはAcitivityとして扱える。そして、スケッチをAndroid用にエクスポートしたファイルでは、以下のようにファイル名と同名のクラスがPAppletを継承しているため、これもやはりAcitivityとして扱える。

// エクスポート前 (ファイル名: AndroidTest.pde)
void draw() {
  rect(100, 100, 100, 100);
}
// エクスポート後 (ファイル名: AndroidTest.java)
public class AndroidTest extends PApplet {
  public void draw() {
    rect(100, 100, 100, 100);
  }
}

一方Viewはどうなっているかというと。
Androidアプリ入門なんかではres/layout/main.xmlなどにビューのレイアウトデータを定義して、

  setContentView(R.layout.main);

上のようにしてViewを反映させるというのが定石っぽい。けど、ProcessingのAndroidモードではもうちょっと複雑なことをしている。

https://github.com/processing/processing-android/blob/android-232/core/src/processing/core/PApplet.java#L516-L534

このあたり↑を見てもらうとわかるように、Processingではフルスクリーンで表示されているウィンドウにsurfaceViewというものをセットして使っており、rect()などのProcessingの命令による描画は基本的にsurafaceViewに対して行われる。

surfaceViewとはゲームやアニメーションなどパフォーマンスが必要な場合に使われるViewであるとのこと。
Processingのdraw()による描画を画面に表示し続けるには、このsurfaceViewが前面に出ている必要がある、ということを認識しておく必要がある。

メインのActivityにインターフェースを追加する

話をここに戻す。
Play Game ServicesのAPIの利用には基本的にGoogleApiClientクラスを使うのだけれども、こいつが接続に成功したり失敗したりした際のコールバックを実行するためのインターフェースを用意してやる必要がある。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L120-L123

public class AndroidTest extends PApplet implements
  GoogleApiClient.ConnectionCallbacks,
  GoogleApiClient.OnConnectionFailedListener,
  View.OnClickListener {

これによって、接続に成功すればonConnected()が呼ばれたり、失敗すればonConnectionFailed()が呼ばれたりするようになる。

GoogleApiClientのインスタンスを作成して接続を試みる

ドキュメント的にはここ

Googleの用意したサンプルではアクティビティのonCreate()の中でAPIクライアントを作成しているけど、僕のコードではProcessingらしくsetup()内でやることにした。
また、サンプルの方ではonStart()の中で接続を開始しているが、Processing Androidモードの場合では、setup()はonStart()よりも後で呼ばれるはずなので、接続開始もsetup()の中で問題ないはず。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L396-L403

  public void setup() {
    // Google API クライアントの生成
    mGoogleApiClient = new GoogleApiClient.Builder(this)
            .addConnectionCallbacks(this)
            .addOnConnectionFailedListener(this)
            .addApi(Plus.API).addScope(Plus.SCOPE_PLUS_LOGIN)
            .addApi(Games.API).addScope(Games.SCOPE_GAMES)
            .build();

    ...(省略...

    // サインインフローの開始
    mGoogleApiClient.connect();

GoogleApiClientによるAPIコールは全て非同期で行われる。べんり。
その他のコールバックは基本的にサンプルと同じようなことをしているつもり。
公開したコードでは起動時に自動でGoogle+にサインインしようとする処理になっている。

そのため、接続成功時のonConnected()が呼ばれた場合にはサインアウトボタンを表示するようになっているし、
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L193-L197
接続失敗時のonConnectionFailed()が呼ばれた場合には再接続するようになっている。
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L204-L230
非アクティブになった時に呼ばれるonStop()ではAPIクライアントの接続を切っている。
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L158-L164

UIについて

Google Play Services SDKを利用すると、よく見るGoogle+のサインインボタンなどがプリセットで用意されているので簡単に利用できる。

f:id:hoshi_sano:20150121010202p:plain

xmlでレイアウトを予め用意しておくには以下のような感じになる。
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/res/layout/sign_in_out_bar.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent">

  <!-- SIGN-IN BAR -->
  <LinearLayout android:id="@+id/sign_in_bar" style="@style/SignInOutBar">
    <com.google.android.gms.common.SignInButton
      android:id="@+id/button_sign_in" style="@style/SignInButton" />
    <TextView style="@style/SignInOutBarBlurb" android:text="@string/sign_in_explanation" />
  </LinearLayout>

  <!-- SIGN-OUT BAR -->
  <LinearLayout android:id="@+id/sign_out_bar" style="@style/SignInOutBar"
                android:visibility="gone">
    <TextView style="@style/SignInOutBarBlurb" android:text="@string/you_are_signed_in" />
    <Button style="@style/SignOutButton" android:id="@+id/button_sign_out" android:text="@string/sign_out" />
  </LinearLayout>
</LinearLayout>

あとはこのレイアウトを利用してViewとして表示してあげればよいわけだけれど、普通にsetContentView()とかすると上述のsurfaceViewが裏に行ってしまってスケッチが見えなくなったりするので、僕の場合は以下のようにした。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L410-L414

        View view = layoutInflater.inflate(R.layout.sign_in_out_bar, null);
        RelativeLayout.LayoutParams lp =
          new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT,
                                          LayoutParams.WRAP_CONTENT);
        getWindow().addContentView(view, lp);

上では、getWindow()でsurfaceViewが表示されているWindowオブジェクトが取得できるので、そのWindowにsurafaceViewの上へ重ねる形でサインイン/アウトボタンのViewを追加している。

また、Processing Androidモードの描画処理は「Animation Thread」というスレッドで動いているが、
https://github.com/processing/processing-android/blob/android-232/core/src/processing/core/PApplet.java#L1083-L1091
Androidはメインスレッド以外のスレッドでUIに関する処理を行うことを禁止しているので、UIを操作する処理は明示的に「UI用のスレッドで処理してね」という書き方をしてあげる必要がある。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L407-L416

    // UIに関するコードをUIスレッドで実行
    runOnUiThread(new Runnable() {
      public void run() {
        View view = layoutInflater.inflate(R.layout.sign_in_out_bar, null);
        RelativeLayout.LayoutParams lp =
          new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT,
                                          LayoutParams.WRAP_CONTENT);
        getWindow().addContentView(view, lp);
      }
    });

参考: https://forum.processing.org/two/discussion/3715/id-like-to-have-simple-alertdialog-in-my-processing-android-sketch/p1

ちなみに上記のようにしないと次のようなエラーログを吐いてアプリが落ちる。

E/AndroidRuntime( 3026): FATAL EXCEPTION: Animation Thread
E/AndroidRuntime( 3026): Process: processing.test.androidtest, PID: 3026
E/AndroidRuntime( 3026): android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

ここまででサインインボタンとサインアウトボタンを表示することができるようになる。(ただしサインアウトボタンはデフォルトでは見えないようにxmlで設定してある。)が、これだけではタップしても何も起こらないので、イベントリスナーをセットしてあげる必要がある。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L521-L529

    SignInButton buttonSignIn = (SignInButton) getWindow().findViewById(R.id.button_sign_in);
    View buttonSignOut = getWindow().findViewById(R.id.button_sign_out);
    if (buttonSignIn != null && buttonSignOut != null) {
      buttonSignIn.setOnClickListener(this);
      buttonSignOut.setOnClickListener(this);
      setClickListener = true;
    } else {
      Log.d(TAG, "could not find buttonSignIn or buttonSignOut, retry next frame.");
    }

ただし、draw()のコメントにも書いてある通り、UIスレッドによるUI関連の処理は非同期で行われているため、このイベントリスナー設定処理が実行された時点でUI関連の処理が完了している保証はなく、findViewById()がnullを返す可能性がある。そのため、イベントリスナーのセットに成功するまで繰り返しトライするような仕組みとした。
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L499-L502

setOnClickListener()を実行しているので、これが成功すればそれぞれのボタンをタップした場合にonClick()が呼ばれるようになる。
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L166-L191

これでアプリ上で好きなタイミングでGoogle+にサインインしたりサインアウトしたりできるようになった。

Google Play Game ServicesのAPIを叩く

ここまでできたら、あとは好きなAPIを好きなタイミングで叩くだけ。

たとえば実績をアンロックするには以下のような感じ。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L432-L437

        if (mGoogleApiClient.isConnected()) {
          // 実績「achievementA」をアンロックする
          Games.Achievements.unlock(mGoogleApiClient,
                                    getString(R.string.achievement_a));
        }

第1引数にはAPIクライアントを、第2引数にはアンロック対象の実績のIDを指定する。
getString(R.string.achievement_a)はids.xmlで設定したIDを引っ張ってくる処理。

リーダーボードへスコアを送信するには以下のような感じ。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L471-L478

        if (mGoogleApiClient.isConnected()) {
          // スコアをリーダーボードに送信する
          int score = (int)(random(0, 100)); // スコアは仮で0〜100のランダム
          Games.Leaderboards.submitScore(mGoogleApiClient,
                                         getString(R.string.test_leader_board),
                                         score);
        }

リーダーボードを表示するには以下のような感じ。

https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L486-L492

        if (mGoogleApiClient.isConnected()) {
          // リーダーボードを表示する
          startActivityForResult(
            Games.Leaderboards.getLeaderboardIntent(mGoogleApiClient, getString( R.string.test_leader_board)),
            RC_UNUSED // 任意のリクエストコード
          );
        }

実績のアンロックも、リーダーボードの表示も、画面への表示は自動でやってくれるので、何か凝ったことをしたいとかでなければ上を実行するだけで良い。

ちなみに、公開したコードではProcessingぽさを出すために実績アンロックやリーダーボード関連のボタンはViewを直接使わずにrect()やtext()、mouserPressed()を使って実現している。
https://github.com/hoshi-sano/processing-google-play-game-services-sample/blob/master/src/processing/test/androidtest/AndroidTest.java#L335-L390

おわり

長い記事をここまで読んでくださってありがとうございました。
Andoroidアプリ開発についてよく知らないまま手を出してちょっと痛い目を見たけど、そういえば別件でAndroidまわりは勉強したかったのでちょうどよかった。気がする。