状況
最近、下記のような画面遷移のアプリを作ることがありました。
- 一覧画面を表示
- ユーザが任意の要素をタップ
- ローディングダイアログを表示し、API通信を始める
- APIの結果を受け取り、成功であれば詳細画面に遷移する
遷移元と遷移先が1対1であれば、遷移元にAPI実行処理を書いても問題ないとは思いますが、
多対1のケースもあったので、「遷移処理」を共通化するクラスを導入しました。
前提
今回のアプリでは、以下のライブラリを利用しました。
- RxJava2
- Retrofit2
- Dagger2
また、API通信中には一律でローディングダイアログを表示し、
APIがエラーを返却した場合には、一律でエラーダイアログを表示することとしました。
もろもろ制約があり、Javaでの実装でした。。。
実装
※ 今回は、プロジェクトのコードを一部改ざんしながら貼ってるので、そのままでは動かない可能性があります。
まずは、APIエラーの場合に、一律でエラーメッセージを表示する部分。
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クラスを作成しました。
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
を行ったり、その前後の共通処理を行ったりしています。)
これを呼び出す側としては、下記のようになります。
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という層を追加するのは、ベターな選択肢だったと思います。