AsyncTaskと仲良くなろう:バックグラウンド処理とキャンセル - ReDo

2011年2月 7日

AsyncTaskと仲良くなろう:バックグラウンド処理とキャンセル

○お題:とあるURLにXMLがあって、XMLの中に画像のURLがあるのでダウンロードして表示するサンプル

  • ・とあるURLにXMLがあります。
  • ・XML内に画像のURLがあります。
  • ・その画像をダウンロードして表示します。
  • ・ダウンロードの開始にはボタンをタップします。
  • ・ダウンロード中は進行状況を示すダイアログが表示されます。
  • ・BACKキーで中断できます。

「別スレッド処理:new Thread(new Runnable).start()」とか、「別スレッドからのUI操作:Handler.post(new Runnable)」とか「バックグラウンド処理+UI更新AsyncTask実装例」まではそこそこサンプルが見つかるのですが、「中断処理」まで実例が載ってるサンプルがなかなか見つからないので、一から起こしてみることにしました。

async_task_01.png async_task_02.png

downloadボタンを押すと、「ボタンの無効化」「ダイアログの表示」を実行、別スレッドでCookpadから肉じゃが画像をダウンロードします。途中BACKキーでキャンセルが可能です。ダイアログ消去をトリガにスレッドを止めてます。

async_task_03.png async_task_04.png

無事ダウンロードが終わった際にはダイアログを消去して、肉じゃが画像を表示します。

以下、関連するANRとUI Threadの話に続いて、AsyncTaskの簡単な説明とソースをば。

【2014.02.12追記】本エントリ中の話がそこそこ古く、ソースコードも正常に動作しないことから、DialogFragmentベースで書き換えた続編のエントリ「AsyncTaskと仲良くする必要はないのかもしれない」を作成しました。初心者向けではないエントリになってしまっておりますが、よろしければこちらも参照ください。

○前置き:ANR

async_anr_01.png

Application Not Respondingの略で、「エラー(アプリ○○)は応答していません」というダイアログのことです。

公式:Designing for Responsiveness
http://developer.android.com/guide/practices/design/responsiveness.html

詳細は上記のページに記載されていますが、Androidでは標準で、UI Thread(キー押下やタップのイベントハンドラ)で5秒以上、BroadcastReceiverからの応答に10秒以上かかると上記のANRダイアログが表示されます。

このANRを回避するために、時間のかかる処理(ネットワーク処理、DB・ディスクI/O等)は別スレッドで行う必要があります。

○前置きその2:UI Thread

AndroidのUI操作はUI Threadと言われるメインのスレッドのみに制限されています。(おそらく描画処理の衝突によるパフォーマンス低下をフレームワークとしてさせないようにするのが目的です。)

そのため、UI ThreadでないスレッドからView等のUIをいじるとExceptionとご対面になり、それを回避するために、UI Thread処理を待ちキューでハンドリングしてくれるHandlerというものが存在します。

throw Life:AndroidのHandlerとは何か?
http://www.adamrocker.com/blog/261/what-is-the-handler-in-android.html

○AsyncTask

前項で述べた様な別スレッドでの処理と、処理完了後のUI更新を自前で書くのは面倒なので、そのあたりをある程度手助けするヘルパークラスとしてAsyncTaskというものがあります。

「下準備として処理中ダイアログ表示」「別スレッドで重い処理はバックグラウンドで」「途中で進行状況を示すUI更新」「処理結果をUIに反映」+「途中で中断」というフルセットの実装例を以下に。

公式:AsyncTask
http://developer.android.com/reference/android/os/AsyncTask.html

AsyncTask<Params, Progress, Result>:Genericsで三つの型を指定します。日本語にするとAsyncTask<入力パラメータ, 進行度, 結果のデータ型>なので、例えばAsyncTask<String, Integer, Bitmap>とすると、「入力パラメータをString列で渡して」「途中経過をIntegerで指定してUIを更新して」「最終結果をBitmapで返す」という意味になります。

というわけで以下本題のソース。

AsyncTaskTest.java

package youten.redo.async;

import java.io.InputStream;
import java.util.ArrayList;
import java.util.Random;

import android.app.Activity;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.ImageView;

/**
 * 「とあるURLにXMLがあってXMLの中に画像のURLがあって
 * その画像をBitmap.decode()してImageViewに表示する」サンプル
 *
 * 1. ダウンロード中にはProgressDialog表示
 * 2. BACKキーでキャンセル可
 */
public class AsyncTaskTest extends Activity {
    /** ログタグ */
    private static final String TAG = "AsyncTaskTest";
    /** Cookpad肉じゃが検索URL */
    private static final String NIKUJAGA_URL =
        "http://cookpad.com/%E3%83%AC%E3%82%B7%E3%83%94/%E8%82%89%E3%81%98%E3%82%83%E3%81%8C";
    /** Dialog: download */
    private static final int DIALOG_DOWNLOAD = 1;
    /** Search Button */
    private static Button mSearchButton = null;
    /** Photo ImageView */
    private static ImageView mPhotoImageView = null;
    /** progressDialog */
    private static ProgressDialog mProgressDialog = null;
    /** DownloadTask */
    private static DownloadTask mDownloadTask = null;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        mSearchButton = (Button) findViewById(R.id.searchButton);
        mPhotoImageView = (ImageView) findViewById(R.id.photoImage);

        mSearchButton.setOnClickListener(new OnClickListener() {
            public void onClick(View v) {
                // ある程度ランダムな肉じゃが画像になる様にページ数を生成
                int page = new Random(System.currentTimeMillis()).nextInt(200) + 1;
                String url = NIKUJAGA_URL + "?page=" + Integer.toString(page);

                // Search Buttonを押下時にダウンロードTaskを開始
                mDownloadTask = new DownloadTask();
                mDownloadTask.execute(url);
            }
        });
    }

    /**
     * 画像をダウンロードできたら表示するAsyncTask
     * 3つのジェネリクスでString, Integer, Bitmapを指定。
     * それぞれdoInBackground, onProgressUpdate, onPostExecuteの引数になっているため
     * 詳細はそれぞれのメソッドコメントを参照。
     */
    public class DownloadTask extends AsyncTask<String, Integer, Bitmap> {

        /**
         * 下準備。UI Thread。
         */
        @Override
        protected void onPreExecute() {
            Log.d(TAG, "DownloadTask.onPreExceute");
            // startButtonを無効に
            mSearchButton.setEnabled(false);
            // ProgressDialogを表示
            showDialog(DIALOG_DOWNLOAD);
        }

        /**
         * バックグラウンドで動作するメイン処理。 UI Threadでないので重い処理を行っても問題ない。
         */
        @Override
        protected Bitmap doInBackground(String... inputParams) {
            Log.d(TAG, "DownloadTask.doInBackground(" + inputParams[0] + ")");

            ArrayList<String> urlList = null;
            String imageUrl = null;
            Bitmap ret = null;

            try {
                //
                // 1. 検索結果から個別ページのURLを取得
                //
                if (isCancelled()) { // 終了チェック
                    return null;
                }
                publishProgress(25); // / 進行度:25%

                InputStream listInputStream = CookpadUtil.getHttpStream(inputParams[0]);
                // 検索結果から個別ページへのURLリストを取得
                if (listInputStream != null) {
                    urlList = CookpadUtil.getRecipeUrlListFromSearchResult(listInputStream, "utf-8");
                }
                // 失敗時にはキャンセルフラグON
                if (urlList == null) {
                    cancel(false);
                }

                //
                // 2. 個別ページから画像URLを取得
                //
                if (isCancelled()) { // 終了チェック
                    return null;
                }
                publishProgress(50); // / 進行度:50%

                if (urlList.size() > 0) {
                    // 個別ページから画像URLを取得
                    InputStream recipeInputStream = CookpadUtil.getHttpStream(urlList.get(0));
                    if (recipeInputStream != null) {
                        imageUrl = CookpadUtil.getImageUrlFromRecipe(recipeInputStream, "utf-8");
                    }
                }
                // 失敗時にはキャンセルフラグON
                if (imageUrl == null) {
                    cancel(false);
                }

                //
                // 3. URLからImage取得
                //
                if (isCancelled()) { // 終了チェック
                    return null;
                }
                publishProgress(75); // / 進行度:75%

                InputStream imageInputStream = CookpadUtil.getHttpStream(imageUrl);
                if (imageInputStream != null) {
                    ret = BitmapFactory.decodeStream(imageInputStream);
                }

                publishProgress(100); // / 進行度:100%

            } catch (Exception e) {
                Log.e(TAG, "DownloadThread Error!");
                e.printStackTrace();
            }

            return ret;
        }

        /**
         * 進み具合を更新する。UI Thread。
         */
        @Override
        protected void onProgressUpdate(Integer... progress) {
            Log.d(TAG, "DownloadTask.onProgressUpdate(" + progress[0] + ")");
            // Integerで指定された数値(%)にProgressDialogの表示を更新。
            mProgressDialog.setProgress(progress[0]);
        }

        /**
         * バックグラウンド処理が終わった後、表示を更新する。UI Thread。
         */
        @Override
        protected void onPostExecute(Bitmap result) {
            Log.d(TAG, "DownloadTask.onPostExecute");
            // 画像を表示。
            if (result != null) {
                mPhotoImageView.setImageBitmap(result);
            }
            // ProgressDialogを消す。
            if (mProgressDialog.isShowing()) {
                mProgressDialog.dismiss();
            }
            // Buttonを有効に戻す。
            mSearchButton.setEnabled(true);
        }

        /**
         * 中止された際の処理。
         */
        @Override
        protected void onCancelled() {
            Log.d(TAG, "DownloadTask.onCancelled");
            // ProgressDialogを消す。
            if (mProgressDialog.isShowing()) {
                mProgressDialog.dismiss();
            }
            // Buttonを有効に戻す。
            mSearchButton.setEnabled(true);
        }

    }

    @Override
    protected Dialog onCreateDialog(int id) {
        Log.d(TAG, "onCreateDialog");
        Dialog dialog = null;
        if (id == DIALOG_DOWNLOAD) {
            // ダウンロード中Dialog生成
            ProgressDialog progressDialog = new ProgressDialog(this);
            progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
            progressDialog.setMessage("Now Downloading...");
            progressDialog.setMax(100); // max 100%
            progressDialog.setProgress(0); // 初期値 0%
            progressDialog.setCancelable(true);
            progressDialog.setOnCancelListener(new OnCancelListener() {
                public void onCancel(DialogInterface di) {
                    // BACKキーでDialogを消す際にTaskを停止。
                    if (mDownloadTask != null) {
                        mDownloadTask.cancel(false); // stop gently
                    }
                }
            });
            dialog = progressDialog;
        }

        if (dialog != null) {
            return dialog;
        }
        return super.onCreateDialog(id);
    }

    @Override
    protected void onPrepareDialog(int id, Dialog dialog) {
        Log.d(TAG, "onPrepareDialog");
        // 表示するDialogへの参照を保存。
        if (id == DIALOG_DOWNLOAD) {
            mProgressDialog = (ProgressDialog) dialog;
        }
        super.onPrepareDialog(id, dialog);
    }

    @Override
    protected void onDestroy() {
        Log.d(TAG, "onDestroy");
        super.onDestroy();
    }

    @Override
    protected void onPause() {
        Log.d(TAG, "onPause");
        super.onPause();

        // Pause時にはダウンロード処理、ダウンロード中表示Dialogをともに停止。
        if (mDownloadTask != null) {
            mDownloadTask.cancel(false);
        }
        if (mProgressDialog != null) {
            if (mProgressDialog.isShowing()) {
                mProgressDialog.dismiss();
            }
        }

    }

    @Override
    protected void onResume() {
        Log.d(TAG, "onResume");
        super.onResume();
    }

}

CookpadUtil.java

package youten.redo.async;

import java.io.InputStream;
import java.util.ArrayList;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.HttpConnectionParams;
import org.xmlpull.v1.XmlPullParser;

import android.util.Log;
import android.util.Xml;

public class CookpadUtil {
    /** ログタグ */
    private static final String TAG = "CookpadUtil";
    /** タグ:div */
    private static final String TAG_DIV = "div";
    /** タグ:a */
    private static final String TAG_A = "a";
    /** タグ:img */
    private static final String TAG_IMG = "img";
    /** 属性名:class */
    private static final String ATTRNAME_CLASS = "class";
    /** 属性名:href */
    private static final String ATTRNAME_HREF = "href";
    /** 属性名:id */
    private static final String ATTRNAME_ID = "id";
    /** 属性名:src */
    private static final String ATTRNAME_SRC = "src";
    /** 属性:recipe-text */
    private static final String ATTRVALUE_RECIPE_TEXT = "recipe-text";
    // 余計な半角空白がつくようになったので両方見る。2011.05.28
    /** 属性:"recipe-text " */
    private static final String ATTRVALUE_RECIPE_TEXT2 = "recipe-text ";
    /** 属性:main-photo */
    private static final String ATTRVALUE_MAIN_PHOTO = "main-photo";

    /**
     * URLを指定してInputStreamを取得
     * @param url URL
     * @return 読み取ったInputStream。エラー時にはnullを返す。
     */
    public static InputStream getHttpStream(String url) {
        InputStream ret = null;

        if (url != null) {
            HttpClient client = new DefaultHttpClient();
            // タイムアウト設定
            HttpConnectionParams.setConnectionTimeout(client.getParams(), 10000);
            HttpConnectionParams.setSoTimeout(client.getParams(), 10000);

            HttpGet method = new HttpGet(url);
            HttpResponse response = null;
            try {
                // 検索結果取得
                response = client.execute(method);

                // responseが返ってきてきて200 OKだったら
                if (response.getStatusLine().getStatusCode() == 200) {
                    HttpEntity entity = response.getEntity();
                    if (entity != null) {
                        ret = entity.getContent();
                    }
                }

            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        return ret;
    }

    /**
     * 検索結果ページから個別レシピURLリスト取得
     * @param xmlInputStream 検索結果XML(XHTML)ストリーム。
     * @param encoding エンコード
     * @return 読み取ったURLのリスト。エラー時にはnullを返す。
     */
    public static ArrayList<String> getRecipeUrlListFromSearchResult(InputStream xmlInputStream, String encoding) {
        ArrayList<String> ret = null;
        if (xmlInputStream != null) {
            XmlPullParser parser = Xml.newPullParser();
            try {
                parser.setInput(xmlInputStream, encoding);
                ret = new ArrayList<String>();

                int eventType = parser.getEventType();
                boolean isRecipeText = false;
                boolean isRecipeText2 = false;
                // ドキュメントが終わるまでループ
                while (eventType != XmlPullParser.END_DOCUMENT) {
                    eventType = parser.next();
                    switch (eventType) {
                    case XmlPullParser.START_TAG:
                        if (TAG_DIV.equals(parser.getName())) {
                            if (ATTRVALUE_RECIPE_TEXT.equals(parser.getAttributeValue(null, ATTRNAME_CLASS))) {
                                // <div class="recipe-text" />
                                isRecipeText = true;
                            }
                            if (ATTRVALUE_RECIPE_TEXT2.equals(parser.getAttributeValue(null, ATTRNAME_CLASS))) {
                                // <div class="recipe-text " />
                                isRecipeText2 = true;
                            }
                        } else if (TAG_A.equals(parser.getName()) && (isRecipeText || isRecipeText2 )) {
                            String url = parser.getAttributeValue(null, ATTRNAME_HREF);
                            if (url != null) {
                                Log.d(TAG, "Recipe[" + ret.size() + "] = " + url);
                                ret.add(url);
                            }
                        }
                        break;
                    case XmlPullParser.END_TAG:
                        if (TAG_DIV.equals(parser.getName()) && isRecipeText) {
                            isRecipeText = false;
                        }
                        if (TAG_DIV.equals(parser.getName()) && isRecipeText2) {
                            isRecipeText2 = false;
                        }
                        break;
                    }
                }
            } catch (Exception e) {
                // e.printStackTrace();
                Log.d(TAG, e.getMessage());
            }
        }
        return ret;
    }

    /**
     * 個別レシピページから画像URL取得
     * @param xmlInputStream 個別レシピページXML(XHTML)ストリーム。
     * @param encoding エンコード
     * @return 読み取ったURL。エラー時にはnullを返す。
     */
    public static String getImageUrlFromRecipe(InputStream xmlInputStream, String encoding) {
        String ret = null;
        if (xmlInputStream != null) {
            XmlPullParser parser = Xml.newPullParser();
            try {
                parser.setInput(xmlInputStream, encoding);

                int eventType = parser.getEventType();
                boolean isMainPhoto = false;
                // ドキュメントが終わるまでループ
                while (eventType != XmlPullParser.END_DOCUMENT) {
                    eventType = parser.next();
                    switch (eventType) {
                    case XmlPullParser.START_TAG:
                        if (TAG_DIV.equals(parser.getName())) {
                            if (ATTRVALUE_MAIN_PHOTO.equals(parser.getAttributeValue(null, ATTRNAME_ID))) {
                                // <div id="main-photo" />
                                isMainPhoto = true;
                            }
                        } else if (TAG_IMG.equals(parser.getName()) && isMainPhoto) {
                            String url = parser.getAttributeValue(null, ATTRNAME_SRC);
                            if (url != null) {
                                Log.d(TAG, "imageUrl = " + url);
                                ret = url;
                            }
                        }
                        break;
                    case XmlPullParser.END_TAG:
                        if (TAG_DIV.equals(parser.getName()) && isMainPhoto) {
                            isMainPhoto = false;
                        }
                        break;
                    }
                }
            } catch (Exception e) {
                // e.printStackTrace();
                Log.d(TAG, e.getMessage());
            }
        }
        return ret;
    }
}

コメント(1)

とても参考になりました。ありがとうございます。

コメントする