Edited at

楽で安全な非同期処理 / Androidアプリを開発する際の俺的設計

More than 1 year has passed since last update.

@eaglesakuraです。

この記事で解説するのは、2016年Q3現在、私個人が「気楽に非同期処理を実装できる」と考えた設計です。

私は好きにした。君たちも好きにしろ。


Androidの非同期処理とは

面倒の塊である。

Androidアプリの中心にあるのはActivity/Fragment/Serviceといったオブジェクト(便宜上、システムオブジェクトと呼ぼう)で、こいつらはユーザー操作によって画面のForegroundとBackgroundを縦横無尽に行き来する。

非同期処理中にForegroundからBackgroundに移るだけならまだしも、サクッとDestroyされてしまうこともある。なので、Androidの非同期処理(と、結果の受け取り)は慎重に実装されなければならないし、それがまた面倒だ。


コールバック地獄を回避する

非同期処理を簡単に行う(そして結果をUIスレッドでコールバックで受け取る)ようなライブラリはいくらでもあるが、基本的に同期的実行をサポートしていないライブラリは使いたくはない。

例えば(私が知ってる時代の)Volleyの場合、下記のように複数回のコールバックを受けるしかないため、大量の(インナー)クラスが出来上がる。


  1. GET開始

  2. GET完了コールバック

  3. POST開始

  4. POST完了コールバック

こいつらが途中キャンセルされたら?失敗したら?

UnitTest面倒だよね?コールバック待ちとかテストメソッド複雑にならない?

書けるけどさ、シンプルじゃないよね?

考えただけで面倒だね。

こういったコールバック主体のAPIはAndroidではよくあるけど、油断したら一瞬でコールバック地獄に陥るために個人的には好きではない。あと、ライブラリごと好き好き勝手にThread作りまくるな、という個人的な思想もある。

「こうすればコールバック主体でもUnitTestが出来るよ」はわかるが、そんなこと考えずにシンプルにいきたい。


非同期コールバック地獄を回避する

コールバック主体のライブラリはやめ、可能な限り同期的に実行されるようにする。その際、引数やクラスのメンバとして下記のようなキャンセルコールバッククラスを渡し、適宜処理を中断するようにする。

コールバック方式でも、結局のところ適宜キャンセルチェックを行うので、設計としてはあまり変動しないかと思う。キャンセルされた場合は例外を投げつけることで、呼び出し側のハンドリングも楽にできる。また、CancelCallbackはラムダ式を使えるようにすると記述もシンプルになるため、わかりやすい。

/**

* キャンセルチェックを行うクラス
*/

public interface CancelCallback {
boolean isCanceled() throws Throwable;
}

// 重い処理を行う側

@WorkerThread
public void getDataFromServer(CancelCallback callback) throws CalcelException {
// 重い処理
if(callback.isCanceled()) { throw new CalcelException(); } // キャンセル命令があったので中断

// 重い処理
if(callback.isCanceled()) { throw new CalcelException(); } // キャンセル命令があったので中断

// 重い処理
}

// 複数の重い処理を行う側

// キャンセルされたら例外で抜ける
@WorkerThread
void asyncMethod() throws Throwable {
mObject.getDataFromServer( ()-> /* キャンセル条件チェック */);
mObject.writeToSqlite( ()-> /* キャンセル条件チェック */);
mObject.postDataToServer( ()-> /* キャンセル条件チェック */);
}

これでコールバック地獄から開放されたので、getDataFromServer()asyncMethodのUnit Testもシンプルにメソッドを呼び出すだけになる。

簡単だね!


アプリがバックグラウンドにある際の「暴発」を防ぐ

クソい実装の「通信処理が完了したら別Activityに遷移」を考えてみよう。


  1. onCreateで通信処理を開始する

  2. 通信中にユーザーがHomeボタンを押してアプリをバックグラウンドに切り替えた

  3. 通信が完了した

  4. アプリがバックグラウンドなのに、別Activityに遷移した!

アプリがバックグラウンドにある際に、不意打ちのように画面遷移を行うべきではない。これはアプリがフォアグラウンドに移った後(再度onResumeが来た後)に適切に処理されるべきである。画面遷移以外にも、「ダイアログを表示する」「アニメーションを開始する」「Toastを出す」「Serviceを開始する」その他いろいろ、「適切なタイミングで非同期処理結果をハンドリングする」という機会は多い。

これを汎用化して考えると、「非同期処理が完了したら、システムオブジェクトの状態に応じて適切なタイミングで非同期処理の結果を受け取る」となる。適切なタイミングとは、上記で言えばonResumeが完了後に「別Activityに遷移」を行うべきだ。

非同期処理としてRxAndroidを採用しているが、このライブラリには上記を実現する(結果を受け取るのを保留し、適切なタイミングで発火する)という仕組みが存在しなかった(少なくとも1.x時点では特に見当たらなかった)ため、一枚ライブラリをかぶせて書けるようにした。その際、非同期処理するスレッドの種類数もかなり絞っており、アプリを制御しやすくしている。

        // 最終的にはこう書けるようにした。

// onResumeでこの処理を行う。
async(ExecuteTarget.LocalQueue, CallbackTime.CurrentForeground, (BackgroundTask<Intent> task) -> {
// これはExecuteTargetで指定したスレッドで呼び出される

// クソ重い起動時処理...
task.throwIfCanceled(); // キャンセル条件を満たしていたら、例外を投げてここで処理を停止する

AppLog.system("Boot Success");
Intent intent = new Intent(getActivity(), NavigationActivity.class);
return intent;
}).completed((intent, task) -> {
// CallbackTimeで指定したタイミングで結果を処理する
startActivity(intent);
getActivity().finish();
}).failed((error, task) -> {
// CallbackTimeで指定したタイミングでエラーを処理する
AppLog.report(error);
// 失敗時の処理を行う
}).finalized(task -> {
// 成功時も失敗時も必ず最後に行う処理を記述
}).cancelSignal(task -> {
// 追加のタスクキャンセル条件を登録する。例えば「ダイアログがキャンセルされている」等
}).start();


どのThreadで処理するか

ExecuteTargetは「どのスレッドで処理するか」を指定する。ThreadPoolExecutorを使用しているので、最大スレッド数までは勝手にスケールし、それを超えるとタスクがキューイングされる。

主に指定可能なのは下記の通り。タスクがなくなれば、勝手にスレッドはシャットダウンされる。


  1. LocalQueue


    • FragmentやActivityローカルでキューイングされる(最大1スレッドしか使用しない)

    • 非同期かつ1スレッドのみのため、必ず順番に処理されるため、主に画面構築用のデータロードに使用



  2. LocalParallels


    • FragmentやActivityローカルで並列処理(並列最大はCPUコア数依存)

    • 主に画像の非同期ロードに使用



  3. GlobalQueue


    • プロセス単位でキューイングされる(最大1スレッドしか使用しない)

    • どうしてもアプリ(プロセス)全体で順番をコントロールしたい場合(設定ファイルロード等)に使用される



  4. GlobalParallels


    • プロセス単位で並列処理される(並列最大はCPUコア数依存)



  5. Network


    • 通信処理用で、プロセス単位で並列処理される

    • 最大4スレッド(Volleyに合わせた)



  6. NewThread


    • 強制的に新規スレッドを作る

    • LocalParallelsの調律等、特殊用途




いつ結果を受け取るか

CallbackTimeは「非同期処理した結果を、いつ受け取るか」を指定する。ここでキモとなるのは指定されたタイミングまで結果受取は保留」され、「タイミングを逸した(onDestroyでシステムオブジェクトが廃棄された場合等)場合にはタスクが強制的にキャンセル」となり、仮に非同期処理を完了させても結果受取そのものがキャンセルされるという点だ。(この特性はRxAndroidのunsubscribe使用例から拝借した)


  1. Foreground「


    • onResume〜onPauseの、システムオブジェクトがフォアグラウンドになっている間であれば結果を受け取る

    • それ以外の場合、onPauseを通過するまでコールバックは保留される



  2. CurrentForeground


    • onResume〜onPauseの、システムオブジェクトがフォアグラウンドになっている間であれば結果を受け取る

    • onPauseを経過した時点で、タスクはキャンセル扱いとなる



  3. Alive


    • onCreate後〜onDestroyまでの間(システムオブジェクトが生きている間)であれば結果を受け取る



  4. FireAndForget


    • タスクは自動キャンセルされず、常に結果を受け取る



非同期処理の特性に合わせて、上記2つの「どのスレッドで処理を行い」「どのタイミングで結果を受け取るか」を指定することで、カジュアルに非同期処理を記述(そして簡単に成功時・エラー時のハンドリング)出来るようにした。

最初に記述したように「非同期コールバックを行わない」ことによって、async()の処理をlambda式の中で完結でき、処理自体もテストが書きやすい。これは3プロジェクトほどで使用し、結果的にTest省力化に役立っている。


最後に

非同期処理用のライブラリは RxAndroid-Support という名前でgithub公開している。どう実装するか(ここでいえばRxAndroidを内部で使用する)はともかく、この非同期処理の考え方はそれなりにカジュアルに非同期処理を書けるのでかなり役立つのでは無いだろうか?