時代は AsyncTask より AsyncTaskLoader

  • 投稿日:
  • by
  • カテゴリ:

時代は AsyncTask より AsyncTaskLoader

Android 4.0、通称 Ice Cream sandwich というスマートフォンもタブレット端末もカバーする新しい OS がもうすぐデビューするとかいう時期なので、Android プログラミングもそれの普及をにらんだ実装に切り替えていくべき。

まずは、きっと Activity 上での非同期処理に多用されているであろう AsyncTask を、Android 3.0 以降で追加された AsyncTaskLoader へ乗り換えるところから始めるのもいいんじゃないかと思ってちょっと書いてみます。

あ、これは Activity での非同期処理について、という前提での内容になりますので、たとえば Service の中で非同期処理したい場合はどうすれば的な質問には役に立たないと思います。

いくら 4.0 がリリースされたとはいえ、世の中はまだまだ 2.3 以前が幅を利かせている時代。
追加されたり便利に改善されたりした API とか使いたくても使えないというジレンマを解消するために、Google は互換ライブラリをリリースしています。

Support Package | Android Developers
http://developer.android.com/intl/ja/sdk/compatibility-library.html

これを使えば Android 1.6 以降なら新しい API の一部を使えるようになったりします。超便利。

というわけで、まずはこの互換ライブラリを Java のビルドパスに追加しましょう。考えるよりも先に。

そして AsyncTaskLoader ですが、まずこんな AsyncTask があるものとします。

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;

import org.json.JSONException;
import org.json.JSONObject;

import android.os.AsyncTask;
import android.text.TextUtils;
import android.util.Log;

public class AsyncFetchJSONTask extends AsyncTask<String, Void, JSONObject> {

    private static final String TAG = AsyncFetchJSONTask.class.getSimpleName();

    @Override
    protected JSONObject doInBackground(String... params) {
        if (params == null || params.length < 1) {
            return null;
        }

        URL url;
        try {
            url = new URL(params[0]);
        } catch (MalformedURLException e) {
            Log.e(TAG, "invalid URL : " + params[0], e);
            return null;
        }

        HttpURLConnection conn = null;
        try {
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Connection", "close");
            conn.setFixedLengthStreamingMode(0);

            conn.connect();

            int code = conn.getResponseCode();
            Log.d(TAG, "Responce code : " + code);

            if (code != 200) {
                Log.e(TAG, "HTTP GET Error : code=" + code);
                return null;
            }

            return new JSONObject(readContent(conn));
        } catch (IOException e) {
            Log.e(TAG, "Failed to get content : " + url, e);
            return null;
        } catch (JSONException e) {
            Log.e(TAG, "invalid JSON String", e);
            return null;
        } finally {
            if (conn != null) {
                try {
                    conn.disconnect();
                } catch (Exception ignore) {
                }
            }
        }
    }

    private String readContent(HttpURLConnection conn) throws IOException {
        String charsetName;

        String contentType = conn.getContentType();
        if (! TextUtils.isEmpty(contentType)) {
            int idx = contentType.indexOf("charset=");
            if (idx != -1) {
                charsetName = contentType.substring(idx + "charset=".length());
            } else {
                charsetName = "UTF-8";
            }
        } else {
            charsetName = "UTF-8";
        }

        InputStream is = new BufferedInputStream(conn.getInputStream());

        int length = conn.getContentLength();
        ByteArrayOutputStream os = length > 0 ? new ByteArrayOutputStream(length) : new ByteArrayOutputStream();

        byte[] buff = new byte[10240];
        int readLen;
        while ((readLen = is.read(buff)) != -1) {
            if (readLen > 0) {
                os.write(buff, 0, readLen);
            }
        }

        return new String(os.toByteArray(), charsetName);
    }

    @Override
    protected void onPostExecute(JSONObject result) {
        // TODO result を使って何かする
    }

}

実にひねりのないつまらないコードですがそれはともかく、指定した URL から取得できる JSON 文字列を org.json.JSONObject に変換したものが得られる AsyncTask クラスの実装です。

こんな感じで使ったりするんじゃないですかね。

AsyncFetchJSONTask task = new AsyncFetchJSONTask();
task.execute("https://api.twitter.com/1/statuses/user_timeline.json?screen_name=twj_dev");

で、これを AsyncTaskLoader に置き換えるとこんな感じになります。

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;

import org.json.JSONException;
import org.json.JSONObject;

import android.content.Context;
import android.support.v4.content.AsyncTaskLoader;
import android.text.TextUtils;
import android.util.Log;

public class AsyncFetchJSONLoader extends AsyncTaskLoader<JSONObject> {

    private static final String TAG = AsyncFetchJSONLoader.class.getSimpleName();

    private final String urlStr;
    private JSONObject result;

    public AsyncFetchJSONLoader(Context context, String urlStr) {
        super(context);
        this.urlStr = urlStr;
    }

    @Override
    public JSONObject loadInBackground() {
        URL url;
        try {
            url = new URL(this.urlStr);
        } catch (MalformedURLException e) {
            Log.e(TAG, "invalid URL : " + this.urlStr, e);
            return null;
        }

        HttpURLConnection conn = null;
        try {
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Connection", "close");
            conn.setFixedLengthStreamingMode(0);

            conn.connect();

            int code = conn.getResponseCode();
            Log.d(TAG, "Responce code : " + code);

            if (code != 200) {
                Log.e(TAG, "HTTP GET Error : code=" + code);
                return null;
            }

            String content = readContent(conn);

            return TextUtils.isEmpty(content) ? null : new JSONObject(content);
        } catch (IOException e) {
            Log.e(TAG, "Failed to get content : " + url, e);
            return null;
        } catch (JSONException e) {
            Log.e(TAG, "invalid JSON String", e);
            return null;
        } finally {
            if (conn != null) {
                try {
                    conn.disconnect();
                } catch (Exception ignore) {
                }
            }
        }
    }

    private String readContent(HttpURLConnection conn) throws IOException {
        String charsetName;

        String contentType = conn.getContentType();
        if (! TextUtils.isEmpty(contentType)) {
            int idx = contentType.indexOf("charset=");
            if (idx != -1) {
                charsetName = contentType.substring(idx + "charset=".length());
            } else {
                charsetName = "UTF-8";
            }
        } else {
            charsetName = "UTF-8";
        }

        InputStream is = new BufferedInputStream(conn.getInputStream());

        int length = conn.getContentLength();
        ByteArrayOutputStream os = length > 0 ? new ByteArrayOutputStream(length) : new ByteArrayOutputStream();

        byte[] buff = new byte[10240];
        int readLen;
        while ((readLen = is.read(buff)) != -1) {
            if (isReset()) {
                return null;
            }

            if (readLen > 0) {
                os.write(buff, 0, readLen);
            }
        }

        return new String(os.toByteArray(), charsetName);
    }

    @Override
    public void deliverResult(JSONObject data) {
        if (isReset()) {
            if (this.result != null) {
                this.result = null;
            }
            return;
        }

        this.result = data;

        if (isStarted()) {
            super.deliverResult(data);
        }
    }

    @Override
    protected void onStartLoading() {
        if (this.result != null) {
            deliverResult(this.result);
        }
        if (takeContentChanged() || this.result == null) {
            forceLoad();
        }
    }

    @Override
    protected void onStopLoading() {
        super.onStopLoading();
        cancelLoad();
    }

    @Override
    protected void onReset() {
        super.onReset();
        onStopLoading();
    }

    public String getUrlStr() {
        return urlStr;
    }

    @Override
    public void dump(String prefix, FileDescriptor fd, PrintWriter writer, String[] args) {
        super.dump(prefix, fd, writer, args);
        writer.print(prefix); writer.print("urlStr="); writer.println(this.urlStr);
        writer.print(prefix); writer.print("result="); writer.println(this.result);
    }

}

キモはコンストラクタと loadInBackground() の辺りですかね。
その他のメソッドはわりと定型的な感じで、よくわからなくてもこう書いておけばうまく動く、程度に把握しといても当面は困らないです。

これをどう使うかというと、まず呼び出す Activityandroid.support.v4.app.FragmentActivity から継承したものにします。
さらにインターフェース android.support.v4.app.LoaderManager.LoaderCallbacks&lt;T> も持たせます。

import org.json.JSONObject;

import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader;
import android.text.TextUtils;
import android.util.Log;

public class FetchJsonActivity extends FragmentActivity implements LoaderManager.LoaderCallbacks<JSONObject> {

    private static final String TAG = FetchJsonActivity.class.getSimpleName();

    private static final String KEY_URL_STR = "urlStr";

    @Override
    protected void onCreate(Bundle saved) {
        super.onCreate(saved);
        Log.d(TAG, "onCreate");

        setContentView(R.layout.main);

        Bundle args = new Bundle(1);
        args.putString(KEY_URL_STR, "https://api.twitter.com/1/statuses/user_timeline.json?screen_name=twj_dev");
        getSupportLoaderManager().initLoader(0, args, this);
    }

    @Override
    public Loader<JSONObject> onCreateLoader(int id, Bundle args) {
        String urlStr = args.getString(KEY_URL_STR);
        if (! TextUtils.isEmpty(urlStr)) {
            return new AsyncFetchJSONLoader(getApplication(), urlStr);
        }
        return null;
    }

    @Override
    public void onLoadFinished(Loader<JSONObject> loader, JSONObject data) {
        // TODO 取得できた data で何かする
    }

    @Override
    public void onLoaderReset(Loader<JSONObject> data) {
        // 特に何もしない
    }

}

こうすると、onCreate() の最後にある getSupportLoaderManager()... の辺りで上に書いた AsyncFetchJSONLoader が別スレッドでスタートし、終わったら onLoadFinished() が UI スレッドでコールバックされる、といった動作になります。
この Activity のコード内ではすべてのメソッドが UI スレッド上でのみ実行されるものに限定されるので、非同期処理に必須のスレッドセーフ的な注意がほぼ必要なくなって、何をするにも気楽でいられますね。

もうちょっと掘り下げると、

getSupportLoaderManager().initLoader(0, args, this);

この initLoader(0, null, this) の引数 0null が、

@Override
public Loader<JSONObject> onCreateLoader(int id, Bundle args) {
    // ...
}

というメソッドの idargs に渡されてます。

設計した人の狙いとしては、onCreateLoader() に渡される id の値に応じて戻り値にする Loader<T> のインスタンスを切り替えたり、Loader<T> インスタンスの初期化に必要なパラメーターを args で渡したりできるようにってところだと思います。
このサンプルではそこまで凝ったことはやってませんけど。

いにしえの AsyncTask は、裏に用意されたスレッドプール上でなんとなく AsyncTask インスタンスが順次実行されて結果を UI スレッドで受け取る、といった用途で使う設計でした。

が、この設計だと UI スレッドで結果を受け取る doPostExecute() や、非同期処理前に UI スレッドで実行される doPreExecute()AsyncTask クラス上に実装されてしまうので、Activity クラス間との依存関係がどうしても発生しやすくなっていました。
わりと Activity クラス内に入れ子で AsyncTask クラスを実装しがちになってソースファイルが長くなり、見通し悪いってレベルじゃねーぞと気が重くなるプログラマーも多かったんじゃないですかね。

ほかにも AsyncTask#cancel() 周辺にバグがあったりなかったりして面倒がくさい事態が招かれる不幸なケースがあったかもという話もあるようですが、まぁそれはともかく。

たぶんそうした苦労や苦情がきっかけとなって、Android 3.0 で「ローダー」という Activity による非同期処理の強い味方が追加されました。

1.2 ローダ - ソフトウェア技術ドキュメントを勝手に翻訳
https://sites.google.com/a/techdoctranslator.com/jp/android/guide/activities/loaders

このローダーというものが ActivityAsyncTask の間に立ってくれるようになって、お互いのクラスにあるメソッドを UI スレッドと別スレッドそれぞれからいい具合に呼び出してくれるおかげで、上記のような実装ができるようになったというわけです。おそらく。

このローダーの真価は、AsyncTask だけでなく Cursor を使う場合に発揮されると個人的には考えてるんですけど、長くなったのでそれは次回の講釈で(CV 芥川隆行)。