■2014/04/28
[Android]AsyncTaskLoaderをもっと便利にする(準備編)
前書き
大昔に「AsyncTaskLoader便利じゃね?いっぱい使うんじゃね?」みたいなことを書いたんですが、全然使ってません。以前作ったこの子が便利すぎるからです。
とは言えちょっと不満なところもあります。再帰の処理を組み込むと、結構読みにくいんです。AsyncTask#executeで好きな引数を渡せるので、ちょっと工夫すれば実現不可能ってことはないんですが、いちいちonPreExecuteのイベントを無効化したり、二回目以降のonPostExecuteを書き換えたりしなきゃいけないのも、どうにもスマートさが足りません。
と言うわけで、AsyncTaskLoaderも上手いこと作り変えてもっと便利にしてしまいましょう。
Loaderを使うための準備
Loaderを使うには結構色々な準備が必要です。
- Loaderを継承したなにか
- LoaderManager.LoaderCallbacksを実装したなにか
- LoaderManagerを呼び出すためのActivity / Fragment
Loaderを継承した何かを自分で作るのは結構大変なので今回はAsyncTaskLoader<D>と言う抽象クラスを継承します。
これは名前の通り、Loaderを非同期で回すためのヘルパークラスみたいなものです。抽象メソッドとして宣言されているのはloadInBackgroundだけなので、ここに別スレッドで行う処理を記述するだけで動く…と思うじゃん?これだけだと色々面倒なこと(後述)があり、他のメソッドもオーバーライドしないとまともに動きません。
LoaderCallbacksに関してはActivity / Fragmentにimplementsしている例が多いのですが、こんなもんコールバックとして片手落ちじゃい、って話もしたいので(後述)これを実装した抽象クラスを作ります。
最後にLoaderManagerを呼び出すActivity / Fragmentですが、基本的にgetLoaderManagerと言うメソッドを呼ぶだけです。
ただし、Support Libraryを使う場合はSupport LibraryにあるActivityを使用する必要があります。(要はFragmentActivityとか、ActionBarActivityとかね。)
最低限実装しなければいけないもの
これはコードを見てもらうほうが早いですね。
public class TestActivity extends FragmentActivity implements LoaderCallbacks<String> { @Override protected onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_test); // 1.LoaderManager#initLoaderを呼ぶ getSupportLoaderManager().initLoader(0, null, this); } // 以下、LoaderCallbacksのメソッド @Override public Loader<String> onCreateLoader(int id, Bundle args) { // 2.新規にLoaderを作成する return new TestLoader(getApplicationContext()); } @Override public void onLoadFinished(Loader<String> loader, String data) { // 4.返ってきたデータでなにかする Toast.makeText(getApplicationContext(), data, Toast.LENGTH_SHORT).show(); } @Override public void onLoaderReset(Loader<String> loader) { // TODO:Loaderがリセットされたらなにかする } public static class TestLoader extends AsyncTaskLoader<String> { public TestLoader(Context context) { super(context); } @Override public String loadInBackground() { // 3.何かを読み込んで返す return "よみこんだよ"; } } }
まずLoaderManager#initLoaderを呼ばないといけません。
これの引数はLoaderのid、何かしらLoaderに渡したい引数を詰め込んだBundle、そしてLoaderCallbacksです。今回はTestActivityがLoaderCallbacksを実装しているのでthisを指定します。
idは何でもいいんですが、LoaderManager経由でLoaderを取得するgetLoaderメソッドないしはrestartLoaderメソッドで指定することになるので、Loaderを複数使用したい場合は上手いことやってください。
ちなみに、IDはActivity / Fragmentごとで管理するらしいので、別のActivity / Fragmentとかぶっても問題ないです。いつも一つしか作らないなら、全部0でいいんじゃないですかね。
後はコメントに書いてある通りです。
色々な不満点
別に問題はないんですが、不満はいくつかあります。ざっと列挙してみましょう。
- loadInBackgroundで発生したExceptionを通知できない
- データがこれ以上来ないかどうかを判定するのが面倒臭い
- ProgressDialogを表示する方法がスマートじゃない
一つ目は結構致命的です。エラーハンドリングをloadInBackgroundのみでやらなきゃいけません。普通にUIスレッドに返して欲しいんですが。
二つ目はまぁ、onLoadFinishedでLoaderが返ってくるので上手いことやれそうです。単に最初からつけといてくれって話です。
三つ目は正直どっちでもいいです。ただ、実行直前のイベントをLoaderCallbacksでもフックさせて欲しかったです。そこもUIスレッドでやりたいことがいっぱいあると思うんですが…。
AsyncTaskLoaderで返ってくる値をMaybeモナドちっくなものでラップする
とりあえずまずはloadInBackgroundでエラーが発生しても大丈夫なようにしましょう。
以前AsyncTaskを改造した時も同じような問題が出ていたので、同じように解決します。
public class ReactiveAsyncResult<T> { private T result; private RuntimeException error; public T getResult() { return this.result; } public void setResult(T result) { this.result = result; } public RuntimeException getError() { return this.error; } public void setError(RuntimeException error) { this.error = error; } public boolean hasError() { return this.error != null; } }
AsyncTaskLoaderから返ってくる値は全部こいつにラップするようにしましょう。そうすればLoaderCallbacks#onLoadFinishedでエラーかどうか判定して処理を振り分けることができます。
ついでにAsyncTaskLoaderの不具合を直す
ついでと言ってはなんですが、AsyncTaskLoaderには不具合があるので直してしまいます。具体的にどんな不具合かと言うと
- initLoaderやrestartLoaderだけではloadInBackgroundが呼ばれない
- Homeボタンを押して中断するとonLoadFinishedが呼ばれない
- 別のActivityからBackボタンで戻ってくるとloadInBackgroundが再度呼ばれる
などです。
なんかこうやって書くと何でこんなもんリリースされたんだって気がするんですが、Stack Overflowにその辺の問題を解決した(と言うか、恐らく元のAsyncTaskLoader#onStartLoadingの実装があまりにもお粗末過ぎたから直した)コードがあるので、これを流用します。
public abstract class ReactiveAsyncLoader<TReturn> extends AsyncTaskLoader<ReactiveAsyncResult<TReturn>> { private ReactiveAsyncResult<TReturn> _data; public abstract boolean isComplete(); public ReactiveAsyncLoader(Context context) { super(context); } @Override public void deliverResult(ReactiveAsyncResult<TReturn> data) { if (isReset()) { return; } _data = data; super.deliverResult(data); } @Override protected void onStartLoading() { if (_data != null) { deliverResult(_data); } if (takeContentChanged() || _data == null) { forceLoad(); } } @Override protected void onStopLoading() { cancelLoad(); } @Override protected void onReset() { super.onReset(); onStopLoading(); _data = null; } }
元のコードとの変更点は、必ずReactiveAsyncResultでラップした値を返すようにしたのと、「public abstract boolean isComplete();」の一文を追加したぐらいです。後は毎回ReactiveAsyncLoaderを継承するようにすれば、それなりに不満を潰すことが出来ますね。
改めてTestLoaderを実装する
じゃあさっきのコードを直してみましょう。
public class TestActivity extends FragmentActivity implements LoaderCallbacks<ReactiveAsyncResult<String>> { @Override protected onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_test); // 1.LoaderManager#initLoaderを呼ぶ getSupportLoaderManager().initLoader(0, null, this); } // 以下、LoaderCallbacksのメソッド @Override public Loader<ReactiveAsyncResult<String>> onCreateLoader(int id, Bundle args) { // 2.新規にLoaderを作成する return new TestLoader(getApplicationContext()); } @Override public void onLoadFinished(Loader<ReactiveAsyncResult<String>> loader, ReactiveAsyncResult<String> data) { // 4.返ってきたデータでなにかする if(!data.hasError()) { Toast.makeText(getApplicationContext(), data.getResult(), Toast.LENGTH_SHORT).show(); } else { Toast.makeText(getApplicationContext(), "あかん", Toast.LENGTH_SHORT).show(); } if(((ReactiveAsyncLoader<String>) loader).isComplete()) { Toast.makeText(getApplicationContext(), "もうデータはないよ", Toast.LENGTH_SHORT).show(); } } @Override public void onLoaderReset(Loader<ReactiveAsyncResult<String>> loader) { // TODO:Loaderがリセットされたらなにかする } public static class TestLoader extends ReactiveAsyncLoader<String> { private boolean isComplete; public TestLoader(Context context) { super(context); } @Override public ReactiveAsyncResult<String> loadInBackground() { // 3.何かを読み込んで返す ReactiveAsyncResult<String> r = new ReactiveAsyncResult<String>(); if(isComplete) { r.setError(new IllegalStateException("これ以上データはありません。")); return r; } try { r.setResult("よみこんだよ"); isComplete = true; } catch(RuntimeException e) { r.setError(e); } retrun r; } @Override public boolean isComplete() { return this.isComplete; } } }
LoaderCallbacksも改良する
しかしまぁ、エラーが起きたかどうかだとか、最後のデータが来たかとか、毎回毎回実装するのはクールさに欠けています。
そんなわけでその辺を実装済みの抽象クラスを作成します。
public abstract class LoaderObserver<TReturn> implements LoaderCallbacks<ReactiveAsyncResult<TReturn>> { private ProgressDialog _prog; // コンストラクタでProgressDialogを受け取った場合は初回時のみ表示させる public LoaderObserver() {} public LoaderObserver(ProgressDialog prog) { _prog = prog; } @Override public Loader<ReactiveAsyncResult<TReturn>> onCreateLoader(int id, Bundle args) { if(_prog != null) _prog.show(); return onCreate(id, args); } @Override public void onLoadFinished(Loader<ReactiveAsyncResult<TReturn>> loader, ReactiveAsyncResult<TReturn> data) { if(_prog != null) { _prog.dismiss(); _prog = null; } if(!data.hasError()) { if(!((ReactiveAsyncLoader<TReturn>) loader).isComplete()) { onNext(data.getResult()); } else { onComplete(data.getResult()); } } else { onError(data.getError()); } } @Override public void onLoaderReset(Loader<ReactiveAsyncResult<TReturn>> loader) { onReset((ReactiveAsyncLoader<TReturn>) loader); } public abstract void onNext(TReturn data); public abstract void onError(RuntimeException e); public abstract void onComplete(TReturn data); public abstract void onReset(ReactiveAsyncLoader<TReturn> loader); public abstract ReactiveAsyncLoader<TReturn> onCreate(int id, Bundle args); }
とりあえず初回時にはProgressDialogを表示するようにしました。
もし毎回表示させたいのであれば、onCreateで返ってきたReactiveAsyncLoaderにProgressDialogをセットし、onStartLoadingで表示、onLoadFinishedでLoaderからゲットして非表示、としなきゃいけなさそうです。が、onStartLoadingはUIスレッドなんですかね…?そこがいまいちわからないので今回は実装してません。
改めてTestLoaderを実装する(二回目)
じゃあ最終形を作りましょう。
public class TestActivity extends FragmentActivity { @Override protected onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_test); ProgressDialog prog = new ProgressDialog(this); prog.setMessage("読み込み中..."); getSupportLoaderManager().initLoader(0, null, new LoaderObserver<String>(prog) { @Override public ReactiveAsyncLoader<String> onCreate(int id, Bundle args) { return new TestLoader(getApplicationContext()); } @Override public void onNext(String data) { Toast.makeText(getApplicationContext(), data, Toast.LENGTH_SHORT).show(); } @Override public void onComplete(String data) { Toast.makeText(getApplicationContext(), data, Toast.LENGTH_SHORT).show(); Toast.makeText(getApplicationContext(), "もうデータはないよ", Toast.LENGTH_SHORT).show() } @Override public void onError(RuntimeException e) { Toast.makeText(getApplicationContext(), "あかん", Toast.LENGTH_SHORT).show(); } @Override public void onReset(ReactiveAsyncLoader<String> loader) { // not implement } }); } public static class TestLoader extends ReactiveAsyncLoader<String> { private boolean isComplete; public TestLoader(Context context) { super(context); } @Override public ReactiveAsyncResult<String> loadInBackground() { // 何かを読み込んで返す ReactiveAsyncResult<String> r = new ReactiveAsyncResult<String>(); if(isComplete) { r.setError(new IllegalStateException("これ以上データはありません。")); return r; } try { r.setResult("よみこんだよ"); isComplete = true; } catch(RuntimeException e) { r.setError(e); } retrun r; } @Override public boolean isComplete() { return this.isComplete; } } }
まとめ
どうでしょう。中々綺麗にまとまったのではないでしょうか。
問題があるとすれば、全部サクラエディタで書いたのでコンパイルが通るのかどうかすらわかってないところですかね。
後日もうちょっと具体的な使い方を実践編として書こうと思っているので、何か問題があったらその時にちゃんと説明します。
明日中にはテストできるといいなぁ。
[2014/06/15追記]
[2014/06/15追記ここまで]
参考
- 1.2 ローダ - ソフトウェア技術ドキュメントを勝手に翻訳
- AsyncTaskLoaderのあるActivityに戻ってきたときに再度loadInBackgroundが呼ばれる問題 - digital matter
- android - onLoadFinished not called after coming back from a HOME button press - Stack Overflow
- Issue 14944 - android - Honeycomb: initLoader() nor restartLoader() actually starts the loader - Android Open Source Project - Issue Tracker - Google Project Hosting
- 技術見聞録 - AsyncTaskLoaderの動作をソースから知る
- Yukiの枝折: LoaderのAPIまとめ