Android

Activity,Fragmentの分割と協調動作の具体例 / Androidアプリを開発する際の俺的設計

More than 1 year has passed since last update.

@eaglesakura です。

社内でプロジェクトのメインプログラマーとしてコードレビューをする機会が増えてきたので、私の考えるActivity/Fragmentの基本的な設計方針を解説します。

この実装はあくまで私が考える範囲での設計です。この考え方は2016年Q3現在のものです。


事前に読んでおくべき記事

ActivityやFragmentに関してはこの記事も読んでおくと良いです。


ActivityとFragmentの違い

Androidの画面を構成する基本はActivityです。

そのため、画面から画面(例えば、ログイン画面からトップメニュー画面)への遷移はActivityからActivityへの遷移となります。

その基本を複雑にしているのが、Fragmentの存在です。

AndroidではFragmentを切り替えることで、Activityの画面遷移と同等の機能を実現できます。そのため、「1Activity+複数Fragmentのみ」というアプリも構成可能です。

どのようなアプローチで画面を構成するかは判断の分かれるところで、かつケースバイケース(NavigationDrawerを使う場合はFragment遷移のほうが楽な場合もある)です。


責任範囲を明確化する

私がメインで設計する場合、基本的には次のように責任範囲を分割しています。


  • Activity(画面遷移)


    • Fragment(1画面単位の構成)


      • ChildFragment(1画面内の細かな要素)





Fragmentは以下は階層構造を持てるため、「上位階層は、下位の階層を適切に管理する責任を持つ」と考えます。

このように分割することで、例えば「Activity遷移をせずにFragmentManagerの切替のみで画面遷移にする」となった場合でもActivityの修正(Intentを発行するか、FragmentManagerで遷移するか)のみで構成を変更することができます。

私が個人的に開発中のサイクルコンピュータアプリ・Andriders Central Engine(以下、A.C.E.)というアプリの1画面を例にすると、次のように分割が行われます。

Fragment分割例

具体的なクラス名は次のようになります。


  • ProfileSettingActivity(画面遷移管理)


    • UserProfileSettingFragmentMain(「プロファイル」の画面処理そのものを管理)


      • RoadbikeSettingFragment(「ロードバイク」設定のみを管理)

      • ZoneSettingFragment(「ゾーン」設定のみを管理)

      • FitnessSettingFragment(「フィットネス」設定のみを管理)





UserProfileSettingFragmentMainはプロファイル画面そのものを構築する責任を持ちます。

この責任には「プロファイル画面とは、上から順にロードバイク, ゾーン, フィットネスにより構成される」という画面構築を含みます。

ですが、個々の設定項目(ロードバイク、ゾーン、フィットネス)の細かな処理に対する責任はありません。

例としてRoadbikeSettingFragmentはタイヤサイズを設定する責任を持ちますが、「画面内のどこに配置されるか」に対する責任はありません。

このアプリではタイヤの規格(サイズ)を設定する必要がありますが、EditTextで設定するか、Spinnerで設定するかのUI的責任(入力値エラーを正しくハンドリングすることを含めて)の責任はRoadbikeSettingFragmentのみが持ちます。

また、UserProfileSettingFragmentMainから利用されるという前提もありません。まったく別なFragmentから再利用されることも想定されます(実際、開発中の試行錯誤としてUI配置を変更したりしています)

ZoneSettingFragmentFitnessSettingFragmentも同じく、「値を正しく設定できること」の責任を持ちますが、「画面内のどこに配置されるか」の責任は持ちません。それらの責任はUserProfileSettingFragmentMainが持ちます。

私が設計する場合、1画面の構成責任を持つFragmentをFragmentMainと呼んでいます(プロジェクト内での検索性を上げるためだけなので、基本的にわかりやすければ良い)。

Fragmentは再利用が可能ですので、「別な画面でもタイヤサイズを変更できるようにする」という場合は別なFragmentMainがRoadbikeSettingFragmentを利用することで再利用を実現します。

ProfileSettingActivityは、UserProfileSettingFragmentMainを正しく呼び出す責任を持っています。また、Activityは画面遷移の責任を持ちますので、次のような制御も行います。


  • BackKeyを押された場合に「本当に戻りますか?」のようなダイアログを出す

  • 別なActivityへの遷移を行なう(Activity.finish処理等)


疎結合であるための工夫

ProfileSettingActivityの設定項目は全て末端のChildFragment単位で完結していますが、場合によってはFragment同士の協調が必要になります。

具体的には次のようなUIです。

データロードを含んだUI

Fragment構成

A.C.E.はロードバイクの走行中に表示されるパラメータ(心拍、速度等)の表示位置・内容を2×7のスロットから自由に移動・変更できます。更に、別アプリを起動中もSystem Windowに常駐することで常に表示を行なうことができます。

「別アプリ(Google Map等)を起動中もサイコンとしての機能を保てる」ことがA.C.E.最大の特長です。

UIとしては、上記を実現するために「どのアプリを起動中に」「どんな表示を行なうか」をユーザーに任意選択させなければなりません。

具体的なクラス構成は次のようになります。


  • DisplayLayoutSettingActivity


    • DisplaySettingFragmentMain


      • LayoutAppSelectFragment(アプリ選択)

      • LayoutEditFragment(アプリごとのレイアウト設定)






画面遷移直後のデータロード

機能面でのテスタビリティを向上させるため、Fragmentにはアプリ一覧のロードデータベースへの保存といった具体的なコードは登場させていません。これらの操作を行なう、UIとは切り離されたDisplayLayoutControllerクラスが登場します。

DisplayLayoutControllerクラスはDisplayLayoutController.load()メソッドを呼び出すことで、アプリ一覧の読み込みやDB読み込みその他の重い処理(A.C.E.はプラグインによる拡張も行えるため、外部アプリのロード等も必要です)を行います。


だれがデータを握るべきか

DisplayLayoutControllerは、LayoutAppSelectFragmentLayoutEditFragmentの両方で必要になるクラスです。

ですが両方でnewしてしまうと、メモリ上のデータ重複やロード時間の肥大化が起こるので、やりたくありません。

この場合、「データを用意する」責任は、画面自体の責任者であるDisplaySettingFragmentMainに持たせています。DisplaySettingFragmentMainのロードが完了したタイミングでLayoutAppSelectFragmentLayoutEditFragmentに通知が行われ、UIが更新されます。


ottoによる実装

「ロードを完了したタイミングで通知が行われ」という処理の具体的な実装方法として、A.C.E.では Square / otto を採用しています。

通知が行われたことを示すアクセサメソッドを作ることは簡単ですが、ottoのほうが手間なく記述できることとパフォーマンス的な劣化を気にしなくても良い(大抵はCPUで殴りきれます)ためです。

非同期でmDisplayLayoutController.loadを呼び出し、完了したタイミングでmDisplayLayoutControllerBus.onSelected(defaultApp);を呼び出しています。事前に各FragmentをottoのBusに登録しておくことで、初期のUIが選択されたことを伝えることができます。

// otto送信側

public class DisplaySettingFragmentMain extends AppNavigationFragment implements LayoutAppSelectFragment.Callback, LayoutEditFragment.Callback {
@UiThread
void loadDisplayController() {
asyncUI(task -> {
CancelCallback cancelCallback = AppSupportUtil.asCancelCallback(task);
try (ProgressToken token = pushProgress(R.string.Widget_Common_Load)) {
mDisplayLayoutController.load(cancelCallback);
}
return this;
}).completed((result, task) -> {
if (!mDisplayLayoutController.hasDisplays()) {
// 表示すべき内容が1個もない
mCallback.onPluginNotEnabled(this);
} else {
// デフォルトアプリを選択済みにする
DisplayLayoutApplication defaultApp = mDisplayLayoutController.getDefaultApplication();
mDisplayLayoutControllerBus.onSelected(defaultApp);
}
}).failed((error, task) -> {
mCallback.onInitializeFailed(this, error);
}).start();
}

public interface Callback {
void onPluginNotEnabled(DisplaySettingFragmentMain self);

void onInitializeFailed(DisplaySettingFragmentMain self, Throwable error);
}
}

@Subscribeは本来publicメソッドにしか適用できませんが、可視性の高いことを強制するのが気に食わないという理由で、public以外のメソッドにも@Subscribeできるようにottoを拡張して使っています。


// otto受信側

public class LayoutAppSelectFragment extends AppFragment {
/**
* アプリが切り替えられた
*/

@Subscribe
void onSelectedApp(DisplayLayoutController.Bus bus) {
mDisplayLayoutController = bus.getData();

DisplayLayoutApplication data = mDisplayLayoutController.getSelectedApp();
new AQuery(getView())
.id(R.id.Item_Title).text(data.getTitle()) // タイトル設定
.id(R.id.Item_Icon).image(data.getIcon()) // アイコン設定
.id(R.id.Button_Delete).visibility(data.isDefaultApp() ? View.GONE : View.VISIBLE).clicked(view -> mCallback.onRequestDeleteLayout(this, data)); // 削除ボタン設定
}
}

public class LayoutEditFragment extends AppFragment {

/**
* アプリが切り替えられた
*/

@Subscribe
void onSelectedApp(DisplayLayoutController.Bus bus) {
mDisplayLayoutController = bus.getData();
DisplayLayoutApplication selectedApp = mDisplayLayoutController.getSelectedApp();
DisplayLayoutGroup layoutGroup = mDisplayLayoutController.getLayoutGroup(selectedApp.getPackageName());

// レイアウトを更新する
for (DisplayLayout layout : layoutGroup.list()) {
updateLayout(layout);
}
}
}


エラーハンドリングの責任

復旧不可能で破滅的なエラーが発生した場合、どのようなハンドリングを行なうかの責任はDisplaySettingFragmentMainにはありません。

アプリを強制終了させたり、依存関係にある別なUIを表示したり、前の画面に戻る責任は、上位であるDisplayLayoutSettingActivityにあります。

DisplaySettingFragmentMainにはCallbackというインターフェースが定義されており、これを使用するActivity(もしくはParentFragment、つまりは親となる要素)が必ずimplementsしなければなりません。

もし親要素でimplementsされていない場合、例外を投げてその場でアプリが停止させます(Build時にエラーに気づきませんが、許容範囲としました)

    /**

* 親クラスを特定のインターフェースに変換する
*
* 変換できない場合、このメソッドはnullを返却する。
* 親Fragmentを辿り、対応しているFragmentを検索する。
*/

@NonNull
public <T> T getParentOrThrow(@NonNull Class<T> clazz) {
Fragment fragment = getParentFragment();
Activity activity = getActivity();

while (fragment != null) {
if (ReflectionUtil.instanceOf(fragment, clazz)) {
return (T) fragment;
}

fragment = fragment.getParentFragment();
}

if (ReflectionUtil.instanceOf(activity, clazz)) {
return (T) activity;
}

throw new IllegalStateException(clazz.getName());
}

DisplaySettingFragmentMain.Callbackは2種類のエラーハンドリングを強制します。

DisplaySettingFragmentMain.Callback.onPluginNotEnabledは、プラグインが1つも有効化されていない場合に呼び出します。プラグインが有効化されていない場合は、設定のために別画面を呼び出す必要がありますが、具体的な遷移方法をActivity側に任せています。

もしかしたら将来的にはダイアログで実装するかもしれませんし、遷移せずにアプリを強制終了させるかもしれません。FragmentManagerのReplaceで実現するかもしれません。ですが、それを決めるのは上位の責任者であるActivityであり、Fragment側で強制してはなりません。また、強制しないことで再利用が容易になります。


public class DisplayLayoutSettingActivity extends AppNavigationActivity implements DisplaySettingFragmentMain.Callback {
中略...

@Override
public void onPluginNotEnabled(DisplaySettingFragmentMain self) {
AppDialogBuilder.newAlert(this, R.string.Message_Display_PluginNotEnabled)
.positiveButton(R.string.Common_OK, () -> {
Intent intent = new Intent(this, PluginSettingActivity.class);
startActivity(intent);
})
.dismissed(() -> finish())
.show(mLifecycleDelegate);
}

@Override
public void onInitializeFailed(DisplaySettingFragmentMain self, Throwable error) {
AppLog.printStackTrace(error);
AppDialogBuilder.newError(this, error)
.positiveButton(R.string.EsMaterial_Dialog_Close, () -> finish())
.cancelable(false)
.show(mLifecycleDelegate);
}
}


ユーザー操作によるUI遷移

ChildFragment同士も、なるべく依存関係を持たずに協調した動作を行います。

この画面では、LayoutAppSelectFragmentでアプリが選択されたら、速やかにLayoutEditFragmentが表示切り替えを行わなければなりません。

協調動作を行なう

LayoutAppSelectFragmentは次のようなコールバックが定義されており、親要素に実装を義務付けています。

public class LayoutAppSelectFragment extends AppFragment {

中略...

// 選択Dialogを表示する
@OnClick(R.id.Button_AppSelect)
void clickAppSelect() {
View view = LayoutInflater.from(getContext()).inflate(R.layout.display_setup_appselect_dialog, null, false);
Dialog dialog = AppDialogBuilder.newCustomContent(getContext(), getString(R.string.Title_Launcher_ChooseApp), view)
.fullScreen(true)
.show(mLifecycleDelegate);

SupportRecyclerView supportRecyclerView = ViewUtil.findViewByMatcher(view, it -> (it instanceof SupportRecyclerView));
IconItemAdapter<DisplayLayoutApplication> adapter = new IconItemAdapter<DisplayLayoutApplication>(mLifecycleDelegate) {
@Override
protected Context getContext() {
return getActivity();
}

@Override
protected void onItemSelected(int position, DisplayLayoutApplication item) {
// アイテムが選択されたらコールバックで伝える
dialog.dismiss();
mCallback.onApplicationSelected(LayoutAppSelectFragment.this, item);
}
};
adapter.getCollection().addAll(mDisplayLayoutController.listSortedApplications());

supportRecyclerView.getRecyclerView().setLayoutManager(new GridLayoutManager(getContext(), 3));
supportRecyclerView.setAdapter(adapter, true);
}

public interface Callback {
/**
* 表示対象のアプリが選択された
*/

void onApplicationSelected(LayoutAppSelectFragment fragment, DisplayLayoutApplication selected);

/**
* 削除がリクエストされた
*
* @param packageName 削除対象のパッケージ名
*/

void onRequestDeleteLayout(LayoutAppSelectFragment fragment, DisplayLayoutApplication packageName);
}

図で(2)のアプリ選択が行われた時点で、Callback.onApplicationSelectedが呼び出されます。この時点で、「アプリ選択を行なう」という機能の責任をLayoutAppSelectFragmentは果たしています。

協調動作の責任はCallback.onApplicationSelectedをimplementsしているクラスにあります。つまりここではDisplaySettingFragmentMainにあります。

DisplaySettingFragmentMainは「編集対象のアプリが切り替わった」ことをコールバックで伝えられると、必要なデータの保存やロード処理を行います。最後に、ottoを介して各Fragmentに「アプリが切り替わった」ことを通知して協調動作の責任を果たします。

2つのChildFragmentは直接的な協調動作(直接的なメソッド呼び出し)をせず、DisplaySettingFragmentMainを介した間接的な方法で協調動作を実現しています。

public class DisplaySettingFragmentMain extends AppNavigationFragment implements LayoutAppSelectFragment.Callback, LayoutEditFragment.Callback {

@Override
public void onApplicationSelected(LayoutAppSelectFragment fragment, DisplayLayoutApplication selected) {
// アプリ切り替えの送信を行う
asyncUI(task -> {
try (ProgressToken token = pushProgress(R.string.Word_Common_Working)) {
mDisplayLayoutController.getLayoutGroup(selected.getPackageName());
mDisplayLayoutController.commit();
return this;
}
}).completed((result, task) -> {
// 切り替えを許可する
mDisplayLayoutControllerBus.onSelected(selected);
}).failed((error, task) -> {
AppLog.report(error);
AppDialogBuilder.newError(getContext(), error)
.positiveButton(R.string.Common_OK, null)
.show(mLifecycleDelegate);
}).start();
}
}


最後に

いつの間にかAndroidアプリ開発歴が7年目に突入していました。

何かのネタでdroidkaigiに応募しようかなーとか思ってるうちに締め切られていたので、気が向いたタイミングで書きました。

気が向いたときに「こういうパターンでも実装したよ」みたいな例を追加したいと思います。

気が向いたときに。

「こういうときはこうするともっと楽だよ」みたいな例があったらみんな記事にしよう!

あと、娘氏と息子氏かわいいです。