Androidで遷移前にAPI通信を行うパターンの実装例

## 状況

最近、下記のような画面遷移のアプリを作ることがありました。

1. 一覧画面を表示
2. ユーザが任意の要素をタップ
3. ローディングダイアログを表示し、API通信を始める
4. APIの結果を受け取り、成功であれば詳細画面に遷移する

遷移元と遷移先が1対1であれば、遷移元にAPI実行処理を書いても問題ないとは思いますが、
多対1のケースもあったので、「遷移処理」を共通化するクラスを導入しました。

## 前提

今回のアプリでは、以下のライブラリを利用しました。

- RxJava2
- Retrofit2
- Dagger2

また、API通信中には一律でローディングダイアログを表示し、
APIがエラーを返却した場合には、一律でエラーダイアログを表示することとしました。

もろもろ制約があり、Javaでの実装でした。。。

## 実装

**※ 今回は、プロジェクトのコードを一部改ざんしながら貼ってるので、そのままでは動かない可能性があります。**

まずは、APIエラーの場合に、一律でエラーメッセージを表示する部分。

```java
public class ErrorConsumerFactory {
    private final LoginRouter loginRouter;

    @Inject
    public ErrorConsumerFactory(LoginRouter loginRouter) {
        this.loginRouter = loginRouter;
    }

    public Consumer<Throwable> create(Activity activity) {
        return throwable -> {
            if (throwable instanceof ConnectException || throwable instanceof UnknownHostException) {
                showMessageDialog(activity, activity.getString(R.string.message_network_error));
                return;
            }
            if (throwable instanceof SocketTimeoutException) {
                showMessageDialog(activity, activity.getString(R.string.message_timeout_error));
                return;
            }
            if (throwable instanceof HttpException) {
                Response response = ((HttpException) throwable).response();
                if (response.code() == 401) {
                    // authorization error
                    loginRouter.navigate(activity);
                    return;
                }
            }

            ErrorList errorList = convertFromThrowable(throwable);
            if (errorList == null) {
                showMessageDialog(activity, activity.getString(R.string.message_unknown_error));
                return;
            }
            String message = errorList.getErrorMessage();
            showMessageDialog(activity, message);
        };
    }

    @Nullable
    private ErrorList convertFromThrowable(Throwable throwable) {
        try {
            if (throwable instanceof HttpException) {
                Response response = ((HttpException) throwable).response();
                String responseString = response.errorBody().string();
                return // convert JSON to POJO by Gson
            }
            return null;
        } catch (IOException | JsonSyntaxException e) {
            // unknown JSON
            return null;
        }
    }

    private void showMessageDialog(Activity activity, String message) {
        // show message dialog.
    }
}
```

エラー時のExceptionをハンドリングして対応するメッセージのダイアログを出したり、
401エラーの場合は認証切れなのでログイン画面を出したり、
エラーの場合はJSONから表示用のメッセージのダイアログを出したり、
というのを共通処理化しました。

これをRouterクラスから利用します。
今回は、ECアプリにおけるカート画面を想定します。
カート画面は、メニューから遷移する以外にも、カート追加後に遷移したり、いくつかの遷移元があるかと思います。
カート一覧取得APIの実行が分散しないように、下記のRouterクラスを作成しました。

```java
public class CartRouter extends AbstractRouter {
    private final ApiService apiService;
    private final ErrorConsumerFactory errorConsumerFactory;

    @Inject
    public CartRouter(ApiService apiService,
                      ErrorConsumerFactory errorConsumerFactory) {
        this.apiService = apiService;
        this.errorConsumerFactory = errorConsumerFactory;
    }

    public Disposable navigate(Activity activity, FragmentController fragmentController) {
        Fragment currentFragment = fragmentController.getCurrentFragment();
        if (currentFragment instanceof CartFragment) {
            return Disposables.empty();
        }

        showLoading(activity);

        return apiService.fetchCart()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnError(errorConsumerFactory.create(activity))
                .subscribe(cart -> {
                    Fragment fragment = CartFragment.newInstance(cart);
                    fragmentController.stackFragment(fragment);

                    hideLoading();
                }, throwable -> hideLoading());
    }
}
```

AbstractRouterに `showLoading` と `hideLoading` を定義し、ダイアログインスタンスを保持しています。
DaggerのDIにより、RetrofitのAPI serviceインスタンスと、先程の ErrorConsumerFactory をinjectします。

なんらかの条件（今回は、カート画面にいるのにカート画面に遷移しようとしてる場合）で遷移が不要の場合は、 `Disposable.empty` を返却することで、呼び元ではあまり意識する必要が無くなります。
`doOnError` に `errorConsumerFactory.create` の返却値を渡すことで、エラーの場合には先程のエラーハンドリングを行った上で、subscribeの第二引数が実行されます。
`fetchCart` が成功した場合には、取得できた情報を元にFragmentを作成し、スタックに追加しています。（ `FragmentController.stackFragment` では、FragmentManagerを利用して `add` を行ったり、その前後の共通処理を行ったりしています。）

これを呼び出す側としては、下記のようになります。

```java
public class MainActivity extends DaggerAppCompatActivity {
    @Inject
    CartRouter cartRouter;

    private CompositeDisposable disposable = new CompositeDisposable();

    @Override
    protected void onDestroy() {
        disposable.dispose();
        super.onDestroy();
    }

    // なんらかのボタンを押したときに呼ばれる
    public void onClickGoToCart() {
        disposable.add(
                cartRouter.navigate(TopActivity.this,
                        new FragmentController(getSupportFragmentManager()))
        );
    }
}
```

いろいろ省略してますが、DIによりinjectされた `CartRouter` のインスタンスの `navigate` を実行します。
返却された `Disposable` インスタンスは、 `CompositeDisposable` のインスタンスに追加しておくことで、Activityが破棄されたタイミングでちゃんと解除されます。

遷移元の画面としては、エラー処理や遷移の成否を気にすることなく、単純に遷移依頼を行っているだけとなり、すっきりします。

## 感想

今回、XXXRouterクラスを導入することで、API通信 -> 画面遷移を実装しました。
遷移に必要な処理をRouterクラスに押し込めることで、遷移元・遷移先の処理を単純化することができました。
同時に、すべての画面遷移を各Routerクラスに記述し、routerパッケージ以下にまとめることで、画面遷移を一箇所で管理することができました。

上記のような利点もありますが、悩みどころもありました。

まずは、そもそもAPI通信 -> 画面遷移という流れで良いのか？という点です。
多くのアプリでは、通信前に画面遷移を行ってしまい、遷移先の画面に応じたローディング表示を行っているかと思います。
例えば、一覧画面への遷移の場合はSwipeRefreshLayoutのローディングを表示させてみたり、詳細画面ではグレーの矩形を表示することで読み込み中を表したり。
こちらの方が、ユーザの体感速度は上がりそうです。
また、今回はローディング表示をモーダルで行っているため、API実行に長時間かかる場合には、ユーザーは待つしかなくなります。
これもユーザビリティを悪化させているとは思いますが、個別の画面での検討時間を減らし、実装の単純化のために仕方なく、といった状況でした。

ただ、今回の要件に対しては、Routerという層を追加するのは、ベターな選択肢だったと思います。