2013年2月22日金曜日

21.Google Cloud Endpointsを試してみた (2/3)




Ryo Yamasaki(@vierjp)です。

今回は、前回の「Google Cloud Endpointsを試してみた (1/2)」の続きです。

前回はEndpoints使用時のサーバー側について書きましたが、
今回はEndpointsの真骨頂(たぶん)、「自動生成されたクライアントライブリを使ってAPIを実行する」を
Androidで試してみました。

と言っても悩んだところやハマったところはだいたいサーバー側で、
Endpointsに関するクライアント側の実装は本当に簡単です。


◯Androidアプリの環境設定

・プロジェクトの作成

eclipseで「新規」→「Android」→「Android アプリケーション・プロジェクト」で
プロジェクトを作成する。

対応するAndroidのバージョンは
・「最小必須SDK」はAPI 8 (Android 2.2 Froyo)
・ターゲットSDKは API 17 (Android 4.2 Jelly Bean)
としました。
(この範囲のバージョンで動けばいまどき十分なんじゃないかな、ということで)

・AndroidManifest.xmlにパーミッションを追加

Endpoints を使うために必要なパーミッションはインターネットアクセスのみです。
<uses-permission android:name="android.permission.INTERNET"/>

・依存ライブラリの配置

サーバーアプリプロジェクトで生成したクライアントコードのディレクトリ「endpoint-libs」以下、
前回の例なら「PROJECT_LOC/endpoint-libs/libnewTestEndpoint-v2/newTestEndpoint/libs」
から下記のファイルを取得してAndroidプロジェクトの「libs」にコピーします。
・google-api-client-1.13.2-beta.jar
・google-oauth-client-1.13.1-beta.jar
・google-http-client-1.13.1-beta.jar
・guava-jdk5-jdk5-13.0.jar
・jsr305-1.3.9.jar
・google-http-client-gson-1.13.1-beta.jar (when using GSON)
・gson-2.1.jar
・google-api-client-android-1.13.2-beta.jar
・google-http-client-android-1.13.1-beta.jar

参考:
Using Endpoints in an Android Client - Google App Engine — Google Developers

・生成されたクライアントコードの配置

・Android側プロジェクトの直下に「endpoint-libs」フォルダを作成する 
・「endpoint-libs」直下からサーバー側の「libnewTestEndpoint-v2」フォルダにリンクする
 「endpoint-libs」を右クリック→「新規」→「フォルダー」
 →「拡張」→「Link to alternate location」をチェック
 →「参照」でサーバー側プロジェクトの「endpoint-libs/libnewTestEndpoint-v2」を選択してOK。
 (ワークスペースのディレクトリは「WORKSPACE_LOC」という変数でも指定できます)



この機能は「Linked Resources」という、eclipse内でのシンボリック・リンクのようなものです。
通常は「自動生成されたクライアント・ライブラリ」のフォルダを
サーバー側プロジェクトからクライアント側プロジェクトにコピーするのですが、
「Linked Resources」にしておけば
「クライアント・ライブラリ」を再生成した際に毎回クライアント側にコピーする必要が無くなります。
Endpoints以外でも便利に使えるタイミングがあるので地味にオススメです。

・ビルド・パスの設定

プロジェクトを右クリック→「プロパティ」→「Javaのビルド・パス」→「ソース」タブ→「フォルダの追加」で、
「endpoint-libs/libnewTestEndpoint-v2/newTestEndpoint/newtestendpoint-v2-generated-source」
を追加。
これによってサーバー側で生成したコードをAndroid側で使用できるようになります。


これでAndroidでEndpointsを利用するための準備が整いました。


◯Android側の実装サンプル

・クライアント・ライブラリの実行例

前回書いたのと同じですが、
クライアント側からの呼び出しは簡単で以下のように自動生成されたメソッドを呼び出すだけです。
NewTestEndpoint.Builder builder = new NewTestEndpoint.Builder(AndroidHttp.newCompatibleTransport(),
   new GsonFactory(), null);
mEndpoint = builder.build();
ListData listdata = mEndpoint.listData().setLimit(20);
CollectionResponseNewTestV2Dto response = listdata.execute();

・実装サンプル

作ったのはAppEngineのカーソルを使ったシンプルなオートページングのサンプルです。
よく見る「一定件数ずつデータを取得して必要に応じて続きを取得する」機能ですね。


・MainActivity.java

public class MainActivity extends Activity {
 private static final String TAG = "EndpointsAndroidSample";
 /**
  * ページング件数
  */
 private static int PAGING_LIMIT = 20;
 /**
  * Endpoint
  */
 private NewTestEndpoint mEndpoint;
 /**
  * ListView
  */
 private ListView mListView;
 /**
  * ListViewのフッター(ローディング画像の表示)
  */
 private View footer;
 /**
  * ListViewが使うAdapterクラス
  */
 private TestAdapter mAdapter;
 /**
  * ListViewに表示するListデータ
  */
 private List<NewTestV2Dto> mList;
 /**
  * 次ページのデータを取得するためのCursor(AppEngineの)
  */
 private String mCursor;
 /**
  * Endointからデータを取得するためのAsyncTask
  */
 private AsyncTask<Void, Void, CollectionResponseNewTestV2Dto> mTask;

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

  // ListViewを生成
  ListView listView = (ListView) getListView();
  listView.addFooterView(getFooter());
  listView.setAdapter(getAdapter());
  listView.setOnScrollListener(new OnScrollListener() {
   @Override
   public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
    if (totalItemCount == firstVisibleItem + visibleItemCount) {
     Log.d(TAG, "onScroll");
     additionalReading(false);
    }
   }

   @Override
   public void onScrollStateChanged(AbsListView view, int scrollState) {
   }
  });

 }

 private ListView getListView() {
  if (mListView == null) {
   mListView = (ListView) findViewById(R.id.listview);
  }
  return mListView;
 }

 private View getFooter() {
  if (footer == null) {
   footer = getLayoutInflater().inflate(R.layout.listview_footer, null);
  }
  return footer;
 }

 private void visibleFooter() {
  getListView().addFooterView(getFooter());
 }

 private void invisibleFooter() {
  getListView().removeFooterView(getFooter());
 }

 private TestAdapter getAdapter() {
  if (mAdapter == null) {
   mAdapter = new TestAdapter(this, R.layout.list_row, getList());
   additionalReading(true);
  }
  return mAdapter;
 }

 private List<NewTestV2Dto> getList() {
  if (mList == null) {
   mList = new ArrayList<NewTestV2Dto>();
  }
  return mList;
 }

 /**
  * ListViewのデータをクリアする(reload用)
  */
 private void clearList() {
  if (mList == null) {
   return;
  } else {
   mList.clear();
  }
 }

 private void additionalReading(boolean isForce) {

  Log.d(TAG, "additionalReading isForce:" + isForce);

  // 既に読み込み中ならスキップ
  if (mTask != null && mTask.getStatus() == AsyncTask.Status.RUNNING) {
   Log.d(TAG, "running asyncTask.");
   return;
  }

  // 読み込み回数が最大値以上ならスキップ。
  if (isForce == false && mCursor == null) {
   Log.d(TAG, "got AllData.");
   return;
  }
  // AsyncTaskを実行してデータを取得する
  mTask = new QueryDataTask(this, PAGING_LIMIT, mCursor).execute();
 }

 /**
  * ListViewのデータを追加する
  */
 private void addListData(List<NewTestV2Dto> addList) {
  List<NewTestV2Dto> list = getList();
  list.addAll(addList);
 }

 private class QueryDataTask extends AsyncTask<Void, Void, CollectionResponseNewTestV2Dto> {

  private Context context;
  private int limit;
  private String cursor;

  public QueryDataTask(Context context, int limit, String cursor) {
   this.context = context;
   this.limit = limit;
   this.cursor = cursor;
  }

  @Override
  protected CollectionResponseNewTestV2Dto doInBackground(Void... unused) {
   CollectionResponseNewTestV2Dto response = null;
   try {
    if (mEndpoint == null) {
     NewTestEndpoint.Builder builder = new NewTestEndpoint.Builder(AndroidHttp.newCompatibleTransport(),
       new GsonFactory(), null);
     mEndpoint = builder.build();
    }
    ListData listdata = mEndpoint.listData().setLimit(limit);
    if (cursor != null) {
     listdata.setCursor(cursor);
    }
    Log.d(TAG, "execute API.");
    response = listdata.execute();

    return response;
   } catch (IOException e) {
    // Log.d(TAG, e.getMessage(), e);
    // throw new RuntimeException(e);
    Toast.makeText(context, "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show();
    return null;
   }
  }

  @Override
  protected void onPostExecute(CollectionResponseNewTestV2Dto response) {
   if (response == null || response.getItems() == null) {
    return;
   }
   // 結果のListデータを取得する
   List<NewTestV2Dto> items = response.getItems();
   // ログ
   for (NewTestV2Dto dto : items) {
    Log.d(TAG, "onPostExecute - " + dto);
   }

   // 結果のListデータをセットして描画
   addListData(items);
   getListView().invalidateViews();

   // カーソルを保持する
   mCursor = response.getNextPageToken();
   // カーソルを取得できなければ次ページは無いのでfooterを消す
   if (mCursor == null) {
    invisibleFooter();
   }
  }
 }

 @Override
 public boolean onCreateOptionsMenu(Menu menu) {
  // Inflate the menu; this adds items to the action bar if it is present.
  getMenuInflater().inflate(R.menu.main, menu);
  return true;
 }

 @Override
 public boolean onOptionsItemSelected(MenuItem item) {
  Toast.makeText(this, "Selected Item: " + item.getTitle(), Toast.LENGTH_SHORT).show();
  if (item.getItemId() == R.id.action_reload) {
   clearList();
   visibleFooter();
   new QueryDataTask(this, PAGING_LIMIT, null).execute();
  }
  return true;
 }
}

・TestAdapter.java

public class TestAdapter extends ArrayAdapter<NewTestV2Dto> {
 private List<NewTestV2Dto> items;
 private LayoutInflater inflater;
 private int textViewResourceId;

 public TestAdapter(Context context, int textViewResourceId, List<NewTestV2Dto> items) {
  super(context, textViewResourceId, items);
  this.textViewResourceId = textViewResourceId;
  this.items = items;
  this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 }

 @Override
 public View getView(int position, View convertView, ViewGroup parent) {
  View view = convertView;
  if (view == null) {
   // Viewがnullなら新しくビューを生成
   view = inflater.inflate(textViewResourceId, null);
  }
  // 表示すべきデータの取得
  NewTestV2Dto item = items.get(position);
  if (item != null) {
   // ID
   TextView idView = (TextView) view.findViewById(R.id.textview_testlist_id);
   if (idView != null) {
    idView.setText(item.getId());
   }
   // 名前
   TextView nameView = (TextView) view.findViewById(R.id.textview_testlist_name);
   if (nameView != null) {
    nameView.setText(item.getName());
   }
   // 作成時刻
   TextView createDateView = (TextView) view.findViewById(R.id.textview_testlist_createDate);
   if (createDateView != null) {
    String dateString = item.getCreateDate().toString();
    createDateView.setText(dateString);
   }
  }
  return view;
 }
}


作成したのはこの2クラスのみです。(リソースのxmlは除く)
 やはりAPIの通信部分が無くなると実装量は減ります。
 APIやEndpoints に関連するのは「MainActivity.java」の157行目から167行目の呼び出し部分くらいで、
それ以外のコードは純粋にAndroidのコードです。

ほとんど下記ページを参考にさせてもらいました。
visible true: ListViewで最後尾までスクロールしたら自動的に要素を追加読み込みするサンプル

多少でも手を入れてるとソースまるごと貼り付けるのは勇気が要りますね。
私は一応Androidディベロッパーですが、Androidの経験は少ないのでお手柔らかに。(;´∀`)

本当はデータの追加・更新・削除も作りたかったんですが、
慣れないAndroidの実装が大変で力尽きました。。
まあEndpointsを試す目的においてはこれで十分かな、と。
やってみたかったオートページングはやりきったゾ。(`・ω・´)


・おまけ

・Java Scriptでのクライアント実装方法

Using Endpoints in a JavaScript Client - Google App Engine — Google Developers
今回試していませんが、Android以上に楽そうな気がします。
こっちでサンプル作るべきだったかも。

・iOSでのクライアント実装方法

Using Endpoints in an iOS Client - Google App Engine — Google Developers

iOSに関してはこちらのサイトで詳しく解説されています。
「Google Cloud Endpoints」 - Web先端技術味見部 - NAVER まとめ


・まとめ

Androidのクライアント側の実装内容は以上です。
重要なところは前回で書いてしまっていた気もしますが、
特にクライアント側に関しては環境設定の作業もAPI呼び出し部分の実装量も少なく、
実装がかなり楽になりそうに感じました。

個人的には、あとはFrontend Cacheが動作することさえ確認できれば、
自前でAPI作る形式から乗り換えるのはアリかな、と思ってます。

特にiPhone・Android両方に対応する必要があるようなケースや、
Endpointsが自動でやってくれている通信部分の処理を隠蔽する自前の基盤を現時点で持っていないなら、
Endpointsを使うことで結構な工数を削減できるように思います。


ではまた(`・ω・´)ノシ


次回 「Google Cloud Endpointsを試してみた (3/3)」 はJavaScriptでのクライアント実装例です。


0 件のコメント:

コメントを投稿