Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

FragmentのIllegalStateException回避

こんにちは、間藤です。だいぶ間が空いてしまいましたが、またもやAndroidネタです。
前回は、ViewPagerを利用するうえで押さえておきたいポイントを確認してみました。今回は、Fragmentのトランザクションに関するものです。このネタは、「Stack Overflow」の以下の投稿をベースにしています。


android - How to handle Handler messages when activity/fragment is paused - Stack Overflow

ちゃんと目を通せば「なるほど」となるのですが、(私にとっては)最初わかりにくかったので、私が理解した内容を以下に整理してみようと思います。

IllegalStateException

Fragmentの操作でこの例外が発生するシナリオを考えてみます。

ある画面上のボタンをタップすると、ネットワーク上のデータを取得してから画面遷移(あるいはダイアログを表示)するとします。表示する画面がFragmentで構成されている場合、以下のようなコードを書くことになるでしょう。

FragmentManager fragmentManager = getFragmentManager()
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
ExampleFragment fragment = new ExampleFragment();
fragmentTransaction.add(R.id.fragment_container, fragment);
fragmentTransaction.commit();

通信処理に時間がかかれば、通信中にHOMEキーが押されるなどして、アプリがバックグランドになることは十分にあり得ます。そして、その後で上記のコード(画面遷移のためのコード)が実行されると、IllegalStateExceptionが発生します。(トランザクションをcommitしたところで例外発生)

java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState
        at android.app.FragmentManagerImpl.checkStateLoss(FragmentManager.java:1280)
        at android.app.FragmentManagerImpl.enqueueAction(FragmentManager.java:1291)
        at android.app.BackStackRecord.commitInternal(BackStackRecord.java:548)
        at android.app.BackStackRecord.commit(BackStackRecord.java:532)

これは、バッググランドになる際に表示中のFragmentのonSaveInstanceState()が呼ばれて状態が保存された後、トランザクションをコミットしようとしたことで発生します。この例外を避けるために、commitメソッドの代わりにcommitAllowingStateLossメソッドを利用することができます。

fragmentTransaction.commitAllowingStateLoss();

しかし、このメソッドは、その名の通りトランザクションでの更新内容が保存されない可能性があるため、使いどころは限定されると思います。
また、アプリがバッググランドになる際、アクティビティが破棄されるようにすると(開発者向けオプションで設定)、getFragmentManager()がnullを返すことが確認されました。つまり、場合によっては、NullPointerExceptionが発生する可能性があるということです。

ということで、通信処理後にFragmentのトランザクションを処理することへは、いろいろと考慮が必要ということになります。そして、1つの解が冒頭に記載した「Stack Overflow」に示されています。以降では、ここで紹介されているプログラムについて確認していきます。

概要

Stack Overflowで紹介されているサンプルアプリは、ボタンが1つ配置された画面(Activity)があって、ボタン押下によりダイアログ(DialogFragment)が表示されるというものです。ボタン押下によりにダイアログが表示されるまで、2秒間待機するようになっていて、その間にアプリをバックグランドにする猶予が与えられるという作りです。(この2秒間のタイムラグが通信処理などを想定しています)

f:id:IntelligentTechnology:20150212145832p:plain:h400

なお、上の画面イメージでは、さらに入力フィールドを用意して、そこに入力された文字を、表示したダイアログのタイトルに設定するように私がカスタマイズしたものです。(当記事の内容とは関係ありません。本当はこれを利用してちょっと実験してみようと思ってましたが、時間がなくて出来ませんでした。)

Stack Overflowに掲載されているプログラムの説明の前に、上記の問題に対してどんなアプローチで解決しようとしているかを簡単に説明しておきます。

画面遷移をしようとした際、すでにonSaveInstanceState()が呼ばれているようなら、画面遷移の実行を待機するようにします。つまり、状態管理および遅延実行の仕組みを構築します。この仕組みを構築するため、サンプルプログラムではFragmentとHandlerを使います。

Fragmentのほうは、サンプルではStateクラスが状態管理の役割を担います。遅延実行のほうは、ConcreteTestHandlerクラスおよびPauseHandlerクラスが担当します。(ConcreteTestHandlerクラスは、PauseHandlerクラスを継承します)

プログラムの解説

それでは、プログラムを見ていきます。なお、以下に掲載するプログラム断片は、私が元のプログラムを多少変更している箇所もありますが、本質的には変わりありません。

初期処理

まずは、ActivityのonCreateメソッドです。この中でStateクラス(Fragment)のインスタンスを生成しています。このFragmentは画面要素を持たず、単に状態管理のために使います。また、ボタンの押下時のリスナーも登録していますが、見ての通りこの中ではダイアログを表示する処理は一切記述されていません。
なお、ここではsendMessageDelayedを使って、2秒間のタイムラグを作り出していますが、これは前述の遅延実行の仕組みとは一切関係ありません。単に画面遷移が起きるまでのタイムラグを作り出すための措置です。

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    if (savedInstanceState == null) {
        final Fragment state = new State();
        final FragmentManager fm = getFragmentManager();
        final FragmentTransaction ft = fm.beginTransaction();
        ft.add(state, State.TAG);
        ft.commit();
    }

    final Button button = (Button) findViewById(R.id.popup);
    final EditText edittext = (EditText) findViewById(R.id.edittext);

    button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {

            final FragmentManager fm = getFragmentManager();
            State fragment = (State) fm.findFragmentByTag(State.TAG);
            if (fragment != null) {
                // Send a message with a delay onto the message looper
                fragment.handler.sendMessageDelayed(
                        fragment.handler.obtainMessage(MSG_WHAT, MSG_SHOW_DIALOG, value++, edittext.getText().toString()),
                        DELAY);
            }
        }
    });
}

ハンドラによる画面遷移実行

ボタン押下時にハンドラに渡したメッセージが処理される流れを追いかけます。
以下、PauseHandlerクラスのhandleMessageメソッドです。

final public void handleMessage(Message msg) {
    if (paused) {
        if (storeMessage(msg)) {
            Message msgCopy = new Message();
            msgCopy.copyFrom(msg);
            messageQueueBuffer.add(msgCopy);
        }
    } else {
        processMessage(msg);
    }
}

pausedというインスタンス変数で状態管理していますが、その話は後回しにします。ここではpausedにfalseが設定されていると仮定します。
そうすると、processMessageが呼ばれることになります。このメソッドの実装は、ConcreteTestHandlerクラスにあります。PauseHandlerクラスに汎用的な処理を実装し、ConcreteTestHandlerクラスに業務寄りのロジックを実装するという考え方になっています。

final protected void processMessage(Message msg) {

    final Activity activity = this.activity;
    if (activity != null) {
        switch (msg.what) {

            case MSG_WHAT:
                switch (msg.arg1) {
                    case MSG_SHOW_DIALOG:
                        final FragmentManager fm = activity.getFragmentManager();
                        final TestDialog dialog = new TestDialog();
                        Bundle bundle = new Bundle();
                        bundle.putInt(TestDialog.BUNDLE_KEY1, msg.arg2);
                        bundle.putString(TestDialog.BUNDLE_KEY2, (String)msg.obj);
                        dialog.setArguments(bundle);

                        // We are on the UI thread so display the dialog
                        // fragment
                        dialog.show(fm, TestDialog.TAG);
                        break;
                }
                break;
        }
    }
}

ここでようやくダイアログを表示する処理が実行されます。なお、DialogFragmentのshowメソッド内でFragmentTransactionのcommitメソッドを呼び出すので、ここでIllegalStateExceptionが発生する恐れがあります。そうならないよう前出のhandleMessageメソッドが実装されているということです。

状態の管理

前出のhandleMessageメソッドに戻って、話を先送りしたpausedというインスタンス変数について説明します。このbool変数のオン/オフは、Stateクラスで行っています。なお、onSaveInstanceStateはonPauseの直後に呼ばれますので、ここでフラグをオンにすることは、onSaveInstanceStateが呼ばれることの目印になります。

@Override
public void onResume() {
    super.onResume();

    handler.setActivity(getActivity());
    handler.resume();
}

@Override
public void onPause() {
    super.onPause();

    handler.pause();
}

handleMessageメソッドが実行されたタイミングでpausedがtrueということは、このままダイアログ表示を実行してはいけないということになります。よって、その場合は送信されたメッセージをmessageQueueBufferというコレクション(Vector)に保管します。

if (storeMessage(msg)) {
    Message msgCopy = new Message();
    msgCopy.copyFrom(msg);
    messageQueueBuffer.add(msgCopy);
}

そして、resumeメソッド(StateクラスのonResumeから呼ばれる)内で保管しておいたメッセージを処理するようにします。これで再度handleMessageメソッドが呼ばれ、このタイミングではpausedにはかなりの確率でfalseが設定されているので、ダイアログ表示が実行されることになります。

final public void resume() {
    paused = false;

    while (messageQueueBuffer.size() > 0) {
        final Message msg = messageQueueBuffer.elementAt(0);
        messageQueueBuffer.removeElementAt(0);
        sendMessage(msg);
    }
}

これで安全に画面遷移(今回はダイアログの表示)が出来るようになっていることがわかりました。なお、バックグランド中にActivityが破棄された場合は、関連するインスタンスも同様に破棄されますから、上記resumeメソッドが呼ばれた際には、messageQueueBufferは空です。つまり、バックグランドになる前に保管したメッセージは失われます。


Android開発では、ActivityやFragmentのライフサイクルに関する落とし穴が結構ありますので、経験を積んで覚えていくしかないのかなと思ってます。