AsyncTaskと仲良くする必要はないのかもしれない - ReDo

2014年2月 2日

AsyncTaskと仲良くする必要はないのかもしれない

AsyncTaskと仲良くなろう:バックグラウンド処理とキャンセル
http://greety.sakura.ne.jp/redo/2011/02/asynctask.html

というのを昔書いたのですが。色々あってイマイチになっていたので、さしあたって以下の2点をどうにかしたものを置いておくことにします。

<差分>
・DialogFragment対応。
・なんちゃってHTMLスクレイピングをjsoupベースに変更。

○仲良くする必要はないの?

AsyncTaskLoaderさんの存在もあるんですが、ANRやUIスレッドの概念を理解するのが一番大事で、次にHandler/Message/Loaderあたりの低レベルな仕組みを理解してしまえば、AsyncTaskというのはやっぱり「ヘルパークラス」でしかないな、という。

AsycTaskもLoader系も「(Andoid標準のコーディング手法として)可読性を上げる」目的での利用が一番望ましく、実際のProjectではVolleyなりPicassoなり、もう少し高位(?)のライブラリを使った方が結局のところ生産性が高くなり、同じ機能に対してコード量が減るってことはテスト対象もメンテ対象も縮小するってことで。

あとはそもそもDialog出すよりはActionBarで済ませるとか、Viewにinlineでステータス表示しちゃう方が良かったりするケースも多いと思うんですよね。そのあたりうまく整理できてなくてアレなのですが、柔軟に考えた方が良さそうです。

以下、位置づけがビミョーになってしまった実コードをば。

aysnc-dialogfragment01.png async-dialogfragment02.png async-dialogfragment03.png

MainActivity.java
package youten.redo.async;
 
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Random;
 
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.v4.app.DialogFragment;
import android.support.v4.app.FragmentActivity;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.ImageView;
 
/**
 * 「とあるURLにHTMLがあって、HTMLの中に画像のURLがあって
 *  その画像をダウンロードして表示する」サンプル
 */
public class MainActivity extends FragmentActivity {
    /** ログタグ */
    private static final String TAG = "AsyncTaskCancel";
    /** Cookpad肉じゃが検索URL */
    private static final String NIKUJAGA_URL = "http://cookpad.com/search/%E8%82%89%E3%81%98%E3%82%83%E3%81%8C";
    /** tag for Fragment */
    private static final String TAG_DOWNLOAD_PROGRESS_DIALOG = "download_progress_dialog";
 
    /** Download Button */
    private Button mDownloadButton;
    /** Nikujaga Image */
    private ImageView mNikujagaImageView;
    /** ProgressDialogFragment */
    private ProgressDialogFragment mProgressDialogFragment;
    /** Download Task */
    private DownloadTask mDownloadTask;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        setContentView(R.layout.activity_main);
 
        mNikujagaImageView = (ImageView) findViewById(R.id.nikujaga_image);
        mDownloadButton = (Button) findViewById(R.id.download_button);
        mDownloadButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                // ある程度ランダムな肉じゃがに出会えるようにページ数をランダムで生成
                int page = new Random(System.currentTimeMillis()).nextInt(200) + 1;
                String url = NIKUJAGA_URL + "?page=" + Integer.toString(page);
 
                // Download Buttonをタップ時にDownload Taskを開始
                mDownloadTask = new DownloadTask();
                mDownloadTask.execute(url);
            }
        });
    }
 
    @Override
    protected void onPause() {
        super.onPause();
        Log.d(TAG, "onPause");
 
        cancelDownloadTask();
    }
 
    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        Log.d(TAG, "onConfigurationChanged");
    }
 
    /**
     * Progress Dialogがキャンセルされた際の処理。
     */
    private void onProgressCancel() {
        cancelDownloadTask();
    }
 
    /**
     * ダウンロード処理を停止
     */
    private void cancelDownloadTask() {
        if ((mDownloadTask != null) && (!mDownloadTask.isCancelled())) {
            mDownloadTask.cancel(false);
        }
        dissmissProgressDialog();
    }
 
    /**
     * Progress Dialogを消す。
     */
    private void dissmissProgressDialog() {
        // ProgressDialogを消す
        if ((mProgressDialogFragment != null) && mProgressDialogFragment.isAdded()) {
            mProgressDialogFragment.dismiss();
        }
        // Download Buttonを有効に戻す
        mDownloadButton.setEnabled(true);
    }
 
    /**
     * 画像をダウンロードできたら表示するAsyncTask。
     * 3つのジェネリクスでString, Integer, Bitmapを指定。
     * それぞれdoInBackground, onProgressUpdate, onPostExecuteの引数になっている。
     * 詳細はそれぞれのメソッドコメントを参照。
     */
    private class DownloadTask extends AsyncTask<String, Integer, Bitmap> {
        /**
         * 下準備。UI Thread
         */
        @Override
        protected void onPreExecute() {
            Log.d(TAG, "DownloadTask.onPreExecute");
 
            // Download Buttonを無効に
            mDownloadButton.setEnabled(false);
            // ProgressDialogを表示
            mProgressDialogFragment = new ProgressDialogFragment();
            mProgressDialogFragment.show(getSupportFragmentManager(), TAG_DOWNLOAD_PROGRESS_DIALOG);
        }
 
        /**
         * バックグラウンドで動作するメイン処理。UI Threadでないので重い処理を行っても問題ない。
         *
         * @param searchUrl 検索開始するURL([0])
         */
        @Override
        protected Bitmap doInBackground(String... searchUrl) {
            Log.d(TAG, "DownloadTask.doInBackground(" + searchUrl[0] + ")");
 
            //
            // 1. 検索結果から個別ページのURLを取得
            //
            if (isCancelled()) { // 終了チェック
                return null;
            }
            publishProgress(25); // 進行度:25%
 
            List<String> recipeUrlList = CookpadHtmlUtil
                    .getRecipeUrlListFromSearchResult(searchUrl[0]);
            // 失敗時にはキャンセルフラグON
            if ((recipeUrlList == null) || (recipeUrlList.size() == 0)) {
                cancel(false);
            }
 
            //
            // 2. 個別ページから画像URLを取得
            //
            if (isCancelled()) { // 終了チェック
                return null;
            }
            publishProgress(50); // 進行度:50%
 
            String imageUrl = CookpadHtmlUtil.getImageUrlFromRecipe(recipeUrlList.get(0)); // 検索結果の1つ目を指定。
            // 失敗時にはキャンセルフラグON
            if ((imageUrl == null) || (imageUrl.length() == 0)) {
                cancel(false);
            }
 
            //
            // 3. 画像URLから画像を取得。
            //
            if (isCancelled()) { // 終了チェック
                return null;
            }
            publishProgress(75); // 進行度:75%
 
            Bitmap bitmap = fetchImage(imageUrl);
            if (bitmap != null) {
                publishProgress(100); // 進行度:100%
            }
 
            return bitmap;
        }
 
        /**
         * 進行度を更新する。UI Thread。
         *
         * @param progress 進行度([0])
         */
        @Override
        protected void onProgressUpdate(Integer... progress) {
            Log.d(TAG, "DownloadTask.onProgressUpdate(" + progress[0] + ")");
            if ((mProgressDialogFragment != null)
                    && (mProgressDialogFragment.getDialog() instanceof ProgressDialog)) {
                ProgressDialog dialog = (ProgressDialog) mProgressDialogFragment.getDialog();
                dialog.setProgress(progress[0]);
            }
        }
 
        /**
         * バッググラウンド処理が終わった後、表示を更新する。UI Thraed。
         */
        @Override
        protected void onPostExecute(Bitmap result) {
            Log.d(TAG, "DownloadTask.onPostExecute");
 
            // 画像を表示
            if (result != null) {
                mNikujagaImageView.setImageBitmap(result);
            }
            dissmissProgressDialog();
        }
 
        /**
         * 中止された際の処理。
         */
        @Override
        protected void onCancelled() {
            Log.d(TAG, "DownloadTask.onCancelled");
 
            dissmissProgressDialog();
        }
    }
 
    /**
     * Progress Dialog Fragment
     */
    public static class ProgressDialogFragment extends DialogFragment {
        @Override
        public Dialog onCreateDialog(Bundle savedInstanceState) {
            Log.d(TAG, "ProgressDialogFragment.onCreateDialog");
            setCancelable(true);
 
            ProgressDialog dialog = new ProgressDialog(getActivity());
            dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
            dialog.setMessage("Now Downloading...");
            dialog.setMax(100); // MAX 100%
            dialog.setProgress(0); // 初期値 0%
            return dialog;
        }
 
        @Override
        public void onCancel(DialogInterface dialog) {
            super.onCancel(dialog);
            Log.d(TAG, "ProgressDialogFragment.onCancel");
 
            try {
                MainActivity activity = (MainActivity) getActivity();
                activity.onProgressCancel();
            } catch (ClassCastException e) {
                throw new ClassCastException("ProgressDialogFragment for MainActivty only.");
            }
        }
    }
 
    /**
     * 画像ファイルのURLから画像を取得し、Bitmapを取得する。
     *
     * @param imageUrl
     * @return デコードしたBitmap。失敗時にはnullを返す。
     */
    private static Bitmap fetchImage(String imageUrl) {
        Bitmap bitmap = null;
        HttpURLConnection urlConnection = null;
 
        try {
            URL imageURL = new URL(imageUrl);
            urlConnection = (HttpURLConnection) imageURL.openConnection();
            InputStream imageInputStream = new BufferedInputStream(urlConnection.getInputStream());
            if (imageInputStream != null) {
                bitmap = BitmapFactory.decodeStream(imageInputStream);
            }
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }
        }
        return bitmap;
    }
}
CookpadUtil.java
package youten.redo.async;
 
import java.io.IOException;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.List;
 
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
 
import android.util.Log;
 
/**
 * Cookpad HTML スクレイピング Util。<br>
 * パーサはjsoup(1.7.3で確認)を使用。http://jsoup.org/<br>
 * ※Cookpadさんはmobile向けのHTMLを返してくる点を留意してください:)<br>
 */
public class CookpadHtmlUtil {
    /** ログタグ */
    private static final String TAG = CookpadHtmlUtil.class.getSimpleName();
    /** url */
    private static final String TOP_URL = "http://cookpad.com";
    /** タグ:a */
    private static final String TAG_A = "a";
    /** タグ:img */
    private static final String TAG_IMG = "img";
    /** 属性名:href */
    private static final String ATTRNAME_HREF = "href";
    /** 属性名:src */
    private static final String ATTRNAME_SRC = "src";
    /** 個別レシピページのURL prefix */
    private static final String PREFIX_RECIPE_URL = "/recipe/";
    /** 個別レシピページのURLから除外するsuffix */
    private static final String SUFFIX_EXCLUDE_RECIPE_URL = "history";
    /** 個別レシピページの画像URL prefix */
    private static final String PREFIX_RECIPES_URL = "/recipes/";
 
    /**
     * 検索結果ページから個別レシピURLリスト取得
     *
     * @param url 検索ページURL
     * @return 読み取ったURLのリスト。エラー時にはnullを返す。
     */
    public static List<String> getRecipeUrlListFromSearchResult(String url) {
        ArrayList<String> ret = null;
        if ((url == null) || (url.length() <= 0)) {
            return null;
        }
 
        try {
            Document doc = Jsoup.connect(url).timeout(10000).get();
            List<Element> aList = doc.getElementsByTag(TAG_A);
 
            // ここまでExceptionなしならエラー発生せず
            ret = new ArrayList<String>();
 
            for (Element a : aList) {
                String href = a.attr(ATTRNAME_HREF);
                if ((href != null) && (href.indexOf(PREFIX_RECIPE_URL) != -1)
                        && (!href.endsWith(SUFFIX_EXCLUDE_RECIPE_URL))) {
                    Log.d(TAG, "url to recipe=" + href);
                    if (href.startsWith(PREFIX_RECIPE_URL)) {
                        ret.add(TOP_URL + href);
                    } else {
                        ret.add(href);
                    }
                }
            }
 
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
 
        return ret;
    }
 
    /**
     * 個別レシピページから画像URL取得
     *
     * @param url 検索ページURL
     * @return 読み取ったURL。エラー時にはnullを返す。
     */
    public static String getImageUrlFromRecipe(String url) {
        String ret = null;
        if ((url == null) || (url.length() <= 0)) {
            return null;
        }
 
        try {
            Document doc = Jsoup.connect(url).timeout(10000).get();
            List<Element> imgList = doc.getElementsByTag(TAG_IMG);
 
            for (Element img : imgList) {
                String imgsrc = img.attr(ATTRNAME_SRC);
                if ((imgsrc != null) && (imgsrc.indexOf(PREFIX_RECIPES_URL) != -1)) {
                    Log.d(TAG, "img url to recipes=" + imgsrc);
                    if (imgsrc.startsWith(PREFIX_RECIPES_URL)) {
                        ret = TOP_URL + imgsrc;
                    } else {
                        ret = imgsrc;
                    }
                    break;
                }
            }
 
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
 
        return ret;
    }
}

コメントする