2015年2月19日木曜日

Androidの通信処理はAsyncTaskで処理すると楽

前回アニメ番組表APIの通信処理(http://tommyproguram.blogspot.jp/2015/02/apiandroid.html)で、Thread, Hadlerを使った処理を行ったが、UIスレッド、通信スレッドの行き来を意識するのが面倒だった。
なので、今回はそれを簡単に実装できるAsyncTaskクラスを使って通信処理を書き直してみる。

AsyncTaskとは

「エイシンクタスク」って読むらしい。 このクラスはexecute()メソッドを実行することで、下記の順でメソッドが呼ばれる。メソッドごとに実行スレッドが違うのが特徴

メソッド名意味使用例
onPreExecute非同期処理する前に何かする場合に使用ダイアログで通信中を表示
doInBackground非同期処理通信処理、DB処理、データ読み込み
onProgressUpdate非同期処理中にUI更新する時使用
publishProgress()でこの処理を実行
%とかバーで処理状態表示
onPostExecute非同期処理で得られた結果を元にUI更新ダイアログ消去
通信処理結果表示

使用上の注意

めっちゃ便利そうだけど、何でもかんでもAsyncTaskで平行処理するのはよくない
UIが絡むとそれだけコストがかかる。このクラスは必ずUIスレッドを経由するため、UIスレッドを絡めない処理は無難にThreadクラスで処理した方が良い
UI関係の起こりやすく、発見しにくかったバグを2つ紹介する
  • AsyncTaskを複数使用するときの処理
    • Android2.3以前
    • execute()メソッドを実行する度に並列処理されている
    • Android3.0以降
    • execute()メソッドを実行すると現在実行中のexecute()が終了してから処理が実行される
    この違いを意識しないと、通信中に表示するダイアログやヌルポに影響が出るので注意
  • 処理をキャンセルするときの処理
  • 通信中画面遷移したりすると、通信キャンセルせざるおえない場合がある
    キャンセル処理をAsyncTask #onCancelled()にオーバライドしておき、
    AsyncTask #cansel()を実行することで、上記メソッドが呼ばれキャンセルできる仕組みだが、 非同期処理中 ( doInBackground() ) にキャンセルすると下記の2パターンに分岐する
    • Android2.3以前
    • すぐAsyncTask #onCancelled()が呼ばれる
    • Android3.0以降
    • doInBackground()が終了してからAsyncTask #onCancelled()が呼ばれる
    まぁ結局どっちもdoInBackground()が終了するまで処理は実行されるんだけど、onCancelled()がきても非同期処理が続くことを考えた実装が必要になる。
    画面遷移のため非同期処理をキャンセルする場合は、onProgressUpdate()にも注意。doInBackground()は途中で終わらないので、画面遷移後の存在しないView更新でヌルポしたりして痛い目にあうw
    対処方はdoInBackground()処理にisCancelled()メソッドでこまめに今キャンセル中か確認すること!


実装方法

AsyncTask<Params、Progress、Result>で表される。ジェネリクスはそれぞれ下記の意味
意味使用箇所
Params非同期通信の引数になる。execute()の引数で渡す →
doInBackground()の引数
Progress非同期処理中UIスレッド更新に渡す引数publishProgress()の引数で渡す →
onProgressUpdate()の引数
Result非同期処理結果のクラスdoInBackground()の戻り値 →
onPostExecute()の引数
今回の場合はこれなので
  • Params : リクエストするJSONのURL → String
  • Progress : 使用しない → Void
  • Result : レスポンスのJSONを文字列にしたもの → String
AsyncTask<String, Void, String> task = new AsyncTask<String, Void, String>() {
    @Override
    protected String doInBackground(String... params) {
        // TODO !非同期処理はアブストラクトメソッドなので必須!
        return null;
    }
};
で、あとは非同期処理中に通信処理。通信処理後にListViewの更新かける処理記述
AsyncTask<String, Void, String> task = new AsyncTask<String, Void, String>() {

    /**通信スレッド処理.
     * 引数のURLからリクエストし、レスポンスをJSONデータ文字列に変換
     * @param url リスエスト先
     * @return JSONデータの文字列
     */
    @Override
    protected String doInBackground(String... url) {
        return getAnimeInfo(url[0]);
    }

    /**UIスレッドの処理.
     * 引数のJSON文字列からタイトル配列を取得し、ListViewに表示
     * @param JSON文字列
     */
    @Override
    protected void onPostExecute(String json) {
        mListView.setAdapter(new ArrayAdapter<String>(
                MainActivity.this,
                android.R.layout.simple_expandable_list_item_1,
                getTitleForJSON(json)));
    }
};
最後に、Activityでexecute()を呼べば完成!※行削減の為コメ消去
public class MainActivity extends Activity{
    ListView mListView = null;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(mListView = new ListView(this));
        task.execute("http://animemap.net/api/table/osaka.json");
    }

    AsyncTask<String, Void, String> task = new AsyncTask<String, Void, String>() {
        @Override
        protected String doInBackground(String... url) {
            return getAnimeInfo(url[0]);
        }
        @Override
        protected void onPostExecute(String json) {
            mListView.setAdapter(new ArrayAdapter<String>(MainActivity.this,
                    android.R.layout.simple_expandable_list_item_1,getTitleForJSON(json)));
        }
    };
    
    public static String getAnimeInfo(String url) {
        AndroidHttpClient client = AndroidHttpClient.newInstance(null);
        HttpGet get = new HttpGet(url);
        StringBuilder sb = new StringBuilder();
        try {
            HttpResponse response = client.execute(get);
            BufferedReader br = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
            String line = null;
            while ((line = br.readLine()) != null) sb.append(line);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            client.close();
        }
        return sb.toString();
    }

    public static String[] getTitleForJSON(String jsonInfo) {
        final ArrayList<String> tmp = new ArrayList<String>();
        try {
            JSONArray json = new JSONObject(jsonInfo)
            .getJSONObject("response")
            .getJSONArray("item");
            for (int i = 0; i < json.length(); i++)
                tmp.add(((JSONObject) json.get(i)).getString("title"));
        } catch (JSONException e){
            e.printStackTrace();
        }
        return tmp.toArray(new String[0]);
    }
}
あ、マニフェストの書き忘れ注意!

2015年2月8日日曜日

アニメ番組表 API使った通信Androidアプリを作ってみた

WebAPIをまとめてるサイト(http://www.find-job.net/startup/api-2013)みてたら面白そうなのがあったので使ってみた

    WebAPIとは

    Webサイトに外部のサイトの提供する機能や情報を組み込んだり、アプリケーションソフトからWeb上で公開されている機能や情報を利用できるサービス
んで、ついでにAndroidの通信処理でよく引っかかるところについてもまとめとく
  1. メインスレッド(UIスレッド)で通信処理ができない
  2. UIスレッドでないとViewの編集ができない
  3. マニフェストに定義しないと通信処理できない
  4. JSONの使い方

アニメマップAPIの使い方

  1. WebAPIの基本的な動き
  2. 通信は基本的にXMLかJSONの2通りのやり方がある。
    違いは取得するデータの形。試しに今週のアニメ情報で比較
    XMLhttp://animemap.net/api/table/osaka.xml
    JSONhttp://animemap.net/api/table/osaka.json
    最近はJSON方式の方をよく見るので、JSONでつくってく。流れはこんな感じ

  3. アニメマップAPIへのリクエストとレスポンス
  4. アニメマップのAPIのサイト(http://animemap.net/pages/api/)を見ると地域ごとに取得できるみたい
    大阪の番組表が欲しいので、リクエスト(サーバへの要求)はコレ(http://animemap.net/api/table/osaka.json)
    実際にブラウザでこのURLに行くとJSON型がどんなデータが返ってくるのか確認できる
    Androidで書くとこんな感じでリクエストして、戻り値で JSON 型をStringにする。
    /**
     * アニメマップに大阪のテレビ情報をリクエスト.
     * 
     * @return JSON型のレスポンスをStringで返す
     */
    public static String getAnimeInfo() {
     // リクエスト
     AndroidHttpClient client = AndroidHttpClient.newInstance(null);
     HttpGet get = new HttpGet("http://animemap.net/api/table/osaka.json");
     StringBuilder sb = new StringBuilder();
     try {
      // リクエスト要求 ⇒ レスポンス
      HttpResponse response = client.execute(get);
      
      // レスポンスをStringにして返値にする
      BufferedReader br = new BufferedReader(new InputStreamReader(
        response.getEntity().getContent()));
      String line = null;
      while ((line = br.readLine()) != null) {
       sb.append(line);
      }
     } catch (IOException e) {
      e.printStackTrace();
     } finally {
      client.close();
     }
     return sb.toString();
    }
    
  5. JSONを解析
  6. JSONは{}で囲まれた領域に"変数名":"value"見たいな感じで定義される。
    ほんとの定義はこの中にもInt型とかいろいろあるけど省略
    レスポンスのJSONを改行や字下げとかで解析してみると、
    {
        "request":{
            "type":"json",
            "url":"地域ごとの一覧URL",
            "updated":"更新時間"
        },
        "response":{
            "item":[
                {
                    "title":"アニメのタイトル",
                    "url":"アニメの放送局一覧URL",
                    "time":"放送時間",
                    "station":"放送局",
                    "state":"よくわらん",
                    "next":"第X話",
                    "episode":"総話数",
                    "cable":"よくわからん",
                    "today":"よくわからん",
                    "week":"放送曜日"
                },
                {
                    "title":"アニメのタイトル",
                    ………省略………
                }
            ]
        }
    }
    
    このJSONは"request","response"の2つの連想配列を持ち、
    "response"にはitemって配列([]←この記号は配列)に1アニメずつ連想配列があることがわかる
    アニメ連想配列ごとに放送時間、放送局など色々な情報が含まれてるけど、とりあえずアニメのタイトル取得してリスト表示させようと思う
    さっきのJSONをStringにしたヤツからアニメタイトル一覧を取得する関数を作る
    /**
     * JSONからアニメのタイトルを抜き出す.
     * 
     * @param str_json
     * @return
     */
    public static String[] getTitleListForJSON(String jsonInfo) {
     final ArrayList<String> tmp = new ArrayList<String>();
     try {
      // String型をJSON型に変換
      JSONObject json = new JSONObject(jsonInfo);
      
      // "response"連想配列を取得
      JSONObject responce = json.getJSONObject("response");
      
      // "item"配列を配列型で取得
      final JSONArray animes = responce.getJSONArray("item");
      
      // "item"配列から1コずつ連想配列を取り出し、タイトルを出力用リストに入れてく
      for (int i = 0; i < animes.length(); i++) {
       JSONObject o = (JSONObject) animes.get(i);
       tmp.add(o.getString("title"));
      }
     } catch (JSONException e) {
      e.printStackTrace();
     }
     return tmp.toArray(new String[0]);
    }
    

Androidの実装

次にAndroidでの描画を実装。これがまた面倒。。 最初に述べたようにAndroidはUIスレッドでは通信できない。ので、別スレッドでデータの取得を行う。でも、取得したデータは通信のスレッドではViewに入れることができない。よって、元のUIスレッドでViewに表示させる処理がいる、、 なに言ってるのかわからなくなるので、一個ずつ処理していく
  1. ベース作り
  2. まず下準備。大本のマニフェストやレイアウトの設定
    アニメのタイトル一覧をListViewで表示させる
    • Manifest.xml
    • これ入れないと通信系動かないので注意!!
      <uses-sdk/>タグの後らへんに追記
       <uses-permission android:name="android.permission.INTERNET" />
      
    • MainActivity.java
    • public class MainActivity extends Activity {
       ListView mListView;
       @Override
       protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mListView = new ListView(this);
        mListView.setBackgroundColor(Color.BLACK);
        setContentView(mListView);
       }
      
       // 記載済みの為省略
       public static String getAnimeInfo() {……}
       public static String[] getTitleForJSON(String jsonInfo) {……}
      
      }
      
  3. 通信用スレッド立てる
  4. もはやAndroid以前にjavaの問題なんだけど、別スレッドを立てるにはThreadクラスのインスタンスがいる。 Thread#start()メソッドを実行することで、Runnable#run()メソッドが別スレッドで実行される。
    ActivityにRunnableインターフェースをくっつけて、runを実装
    public class MainActivity extends Activity implements Runnable {
     ListView mListView;
     @Override
     protected void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      mListView = new ListView(this);
      mListView.setBackgroundColor(Color.BLACK);
      setContentView(mListView);
      // スレッド起動!通信開始!!
      new Thread(this).start();
     }
    
     // 記載済みの為省略
     public static String getAnimeInfo() {……}
     public static String[] getTitleForJSON(String jsonInfo) {……}
    
     @Override
     public void run() {
      String jsonInfo = getAnimeInfo();
      final String[] titles = getTitleForJSON(jsonInfo);
      for(String str:titles)Log.d("TEST","title = "+str);
     }
    }
    
    ログにアニメタイトル一覧が出ればOK!

  5. Handler使ってUIスレッドで描画
  6. 現在通信スレッド上ににアニメタイトル一覧があるので、ここからViewにデータをセットしたい。そこで直面するのが「通信のスレッドではViewに入れることができない」問題。解決策はHandlerクラス
    HandlerとはHandlerのインスタンスを生成したスレッドで自信の処理を行う事ができる
    つまり、UIスレッド(Mainスレッド)でHandlerを生成しといて、別スレッドでこのHandlerを使えばUIスレッドで処理してることになる。run()をちょっと修正
    // UIスレッド上にHandler生成
    Handler mHandler =new Handler(); //Activityのメンバ変数
    @Override
    public void run() {
     String jsonInfo = getAnimeInfo();
     final String[] titles = getTitleForJSON(jsonInfo);
     for(String str:titles)Log.d("TEST","title = "+str);
     
     // Handlerはpostメソッドで引数のRunnable#run()メソッドを自信の生成スレッド上で実行させる
     mHandler.post(new Runnable() {
      @Override
      public void run() {
       // ここの処理がUIスレッドで実行される
       mListView.setAdapter(new ArrayAdapter<String>(MainActivity.this
         .getApplicationContext(),
         android.R.layout.simple_expandable_list_item_1, titles));
      }
     });
    }
    

完成!
最終的なコードはこんだけ。行数短くしたかったからコメント無、可読性落とした
public class MainActivity extends Activity implements Runnable{
 ListView mListView = null;
 Handler mHandler = new Handler();
 @Override
 protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(mListView = new ListView(this));
  new Thread(this).start();
 }

 public static String getAnimeInfo() {
  AndroidHttpClient client = AndroidHttpClient.newInstance(null);
  HttpGet get = new HttpGet("http://animemap.net/api/table/osaka.json");
  StringBuilder sb = new StringBuilder();
  try {
   HttpResponse response = client.execute(get);
   BufferedReader br = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
   String line = null;
   while ((line = br.readLine()) != null) sb.append(line);
  } catch (IOException e) {
   e.printStackTrace();
  } finally {
   client.close();
  }
  return sb.toString();
 }

 public static String[] getTitleForJSON(String jsonInfo) {
  final ArrayList<String> tmp = new ArrayList<String>();
  try {
   JSONArray json = new JSONObject(jsonInfo)
   .getJSONObject("response")
   .getJSONArray("item");
   for (int i = 0; i < json.length(); i++)
    tmp.add(((JSONObject) json.get(i)).getString("title"));
  } catch (JSONException e){
   e.printStackTrace();
  }
  return tmp.toArray(new String[0]);
 }

 @Override
 public void run() {
  final String[] titles = getTitleForJSON(getAnimeInfo());
  mHandler.post(new Runnable(){
   @Override
   public void run(){
    mListView.setAdapter(new ArrayAdapter<String>(MainActivity.this,android.R.layout.simple_expandable_list_item_1,titles));
   }
  });
 }
}
※マニフェストの追記は忘れない様に!