onSaveInstanceState 後に表示しようとするとクラッシュする DialogFragment

DialogFragment は例えば以下のように表示できる:

// MainActivity 内のコード
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    MyDialogFragment().show(supportFragmentManager, null)  // 表示処理
}

class MyDialogFragment : AppCompatDialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog
            = AlertDialog.Builder(activity).setMessage("Hello World").create()
}

通常はこれで問題ないのだが、例えば以下のようにして画面を開いた直後に「スリープ状態にする」「画面を閉じる」など行うとクラッシュする:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    // 5 秒待ってから表示. この間にスリープやバックグラウンドに回すとクラッシュ!!
    Handler().postDelayed({ MyDialogFragment().show(supportFragmentManager, null) }, 5000)
}

class MyDialogFragment : AppCompatDialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog
            = AlertDialog.Builder(activity).setMessage("Hello World").create()
}

上記のように数秒待ってからダイアログ表示することなどそうそうないと思われるかもしれないが、昨今は通信を伴ったアプリの開発が当たり前であり通信の結果やエラー表示をダイアログで行っていたりするとよくこのエラーを目にすることとなる。

クラッシュの内容は java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState といった感じで恐らく Fragment を使っている人であればよく見ているのではないかと思う。 要するに Activity や Fragment の onPause() から onResume() 間には Fragment (DialogFragment) を操作できないというわけであるが、これの回避が思ったより難しい。

onPause() から onResume() 間に操作しようとしている DialogFragment の show()dismiss()ouResume() の後に pending するという方法でうまく回避できたのでここに記しておく。

前提

今回は前提として私が Qiita に以前書いた DialogFragment の共通化処理を入れているものとする。 例えば以下のような MyDialogFragment が用意されているものとする:

class MyDialogFragment : AppCompatDialogFragment() {

    /** コールバック */
    private val mCallback: Callback by lazy {
        if (targetFragment is Callback) {
            targetFragment as Callback
        } else if (activity is Callback) {
            activity as Callback
        } else {
            throw UnsupportedOperationException()
        }
    }

    /** リクエストコード */
    private val mRequestCode: Int
        get() = if (arguments.containsKey("request_code")) arguments.getInt("request_code") else targetRequestCode

    /**
     * MyDialog で何か処理が起こった場合にコールバックされるリスナ.
     */
    interface Callback {

        /**
         * MyDialog で positiveButton, NegativeButton, リスト選択など行われた際に呼ばれる.
         *
         * @param requestCode MyDialogFragment 実行時 mRequestCode
         * @param resultCode DialogInterface.BUTTON_(POSI|NEGA)TIVE 若しくはリストの position
         * @param params MyDialogFragment に受渡した引数
         */
        fun onMyDialogSucceeded(requestCode: Int, resultCode: Int, params: Bundle) = Unit

        /**
         * MyDialog がキャンセルされた時に呼ばれる.
         *
         * @param requestCode MyDialogFragment 実行時 mRequestCode
         * @param params MyDialogFragment に受渡した引数
         */
        fun onMyDialogCancelled(requestCode: Int, params: Bundle) = Unit

        /**
         * MyDialog で multiChoice した際に呼ばれる.
         *
         * @param requestCode MyDialogFragment 実行時 mRequestCode
         * @param position チェック位置
         * @param checked チェックされたか否か
         */
        fun onMyDialogMultiChoiceClicked(requestCode: Int, position: Int, checked: Boolean) = Unit

        /**
         * MyDialog で dateSet した際に呼ばれる.
         *
         * @param requestCode MyDialogFragment 実行時 mRequestCode
         * @param year 年
         * @param month 月
         * @param date 日
         */
        fun onMyDialogDateSet(requestCode: Int, year: Int, month: Int, date: Int) = Unit
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val listener = DialogInterface.OnClickListener { _, which ->
            dismiss()
            mCallback.onMyDialogSucceeded(mRequestCode, which, arguments.getBundle("params"))
        }
        val isProgress = arguments.getBoolean("is_progress")
        val title = arguments.getString("title")
        val message = arguments.getString("message")
        val positive = arguments.getString("positive")
        val negative = arguments.getString("negative")
        val items = arguments.getStringArray("items")
        val multiChoiceItems = arguments.getStringArray("multi_choice_items")
        val multiChoiceItemValues = arguments.getBooleanArray("multi_choice_item_values")
        val date = arguments.getSerializable("date") as Date?
        isCancelable = arguments.getBoolean("cancelable")

        if (isProgress) {
            return ProgressDialog(activity).apply { message.let { setMessage(it) } }
        } else if (date != null) {
            val cal = Calendar.getInstance(Locale.getDefault())
            cal.time = date
            val picker = DatePickerDialog(activity, { _, year, month, date ->
                mCallback.onMyDialogDateSet(mRequestCode, year, month, date)
            }, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DATE))
            title?.let { picker.setTitle(it) }
            message?.let { picker.setMessage(it) }
            return picker
        } else {
            val builder = AlertDialog.Builder(activity)
            title?.let { builder.setTitle(it) }
            message?.let { builder.setMessage(it) }
            positive?.let { builder.setPositiveButton(positive, listener) }
            negative?.let { builder.setNegativeButton(negative, listener) }
            items?.let { builder.setItems(items, listener) }
            multiChoiceItems?.let {
                builder.setMultiChoiceItems(multiChoiceItems, multiChoiceItemValues) {
                    _, position, checked ->
                    mCallback.onMyDialogMultiChoiceClicked(mRequestCode, position, checked)
                }
            }
            return builder.create()
        }
    }

    override fun onCancel(dialog: DialogInterface?) {
        mCallback.onMyDialogCancelled(mRequestCode, arguments.getBundle("params") ?: Bundle())
    }
}

通常のタイトルやメッセージを持ったダイアログ表示に加えて、複数のアイテムから 1 つを選択するものや複数チェックできるもの、あと DatePickerDialog での日付選択や ProgressDialog での経過表示も対応している。 これを呼ぶ場合用にもショートカット用のメソッドを以下のように用意する:

/**
 * MyDialogFragment を表示する.
 *
 * @param requestCode リスクエストコード
 * @param params Bundle パラメータ
 * @param title タイトル
 * @param message メッセージ
 * @param positive positiveButton ラベル
 * @param negative negativeButton ラベル
 * @param items シングルタップ選択用の items
 * @param multiChoiceItems 複数選択用の items
 * @param multiChoiceItemValues 複数選択用の items (値)
 * @param date DatePicker 用の初期値
 * @param tag Tag
 * @param cancelable キャンセル出来るか否か
 */
fun showDialog(requestCode: Int = -1, params: Bundle = Bundle(), title: Any? = null,
        message: Any? = null, positive: Any? = null, negative: Any? = null,
        items: Any? = null, multiChoiceItems: Any? = null, multiChoiceItemValues: BooleanArray? = null,
        date: Date? = null, tag: String = "default", cancelable: Boolean = true) {
    fun getStringArray(x: Any): Array<String> = when (x) {
        is Int -> resources.getStringArray(x)
        is ArrayList<*> -> x.toArray(arrayOfNulls(0))
        is Array<*> -> x.map(Any?::toString).toTypedArray()
        else -> throw IllegalArgumentException()
    }
    if (this !is MyDialogFragment.Callback) {
        throw UnsupportedOperationException("Using MyDialogFragment must implement Callback to Fragment")
    }
    val args = Bundle()
    title?.let { args.putString("title", if (it is Int) getString(it) else it.toString()) }
    message?.let { args.putString("message", if (it is Int) getString(it) else it.toString()) }
    items?.let { args.putStringArray("items", getStringArray(it)) }
    multiChoiceItems?.let { args.putStringArray("multi_choice_items", getStringArray(it)) }
    multiChoiceItemValues?.let { args.putBooleanArray("multi_choice_item_values", it) }
    date?.let { args.putSerializable("date", it) }
    positive?.let { args.putString("positive", if (it is Int) getString(it) else it.toString()) }
    negative?.let { args.putString("negative", if (it is Int) getString(it) else it.toString()) }
    args.putBoolean("cancelable", cancelable)
    args.putBundle("params", params)

    MyDialogFragment().apply { arguments = args }.show(supportFragmentManager, tag)
}

ちょっと複雑そうに見えるのは、各種引数をリソース ID と文字列の双方で渡せるように考慮を入れているからである (その為多くの型を Any? にしている)。 このメソッドを使用して前述の MyDialogFragment を show() するには以下のように書く:

// 5 秒待ってから表示
Handler().postDelayed({ showDialog(message = "Hello World") }, 5000)

この時点では onPause() 後の pending の対応が入っていないため先ほどと同じようにスリープにしたり画面を閉じたりすると IllegalStateException がスローされる。

対処

この対処を行う上で残念なのが結局 Fragment を拡張するようなパターンが必要 (BaseFragment のようなもの) になってしまうということだ。 どうしても onResume() と密に結びついてしまうので仕方がない。 これを避けようとすると作成した Activity や Fragment の onResume() の前に毎回お決まりのコードを入れなければならないので若干辛いものがある。

abstract class MyFragment : Fragment(), MyDialogFragment.Callback {

    /** 復帰のために貯めておく PTDialogFragment の表示データ */
    private val mDialogData: MutableList<Bundle> = mutableListOf()

    /** 復帰のために貯めておく dismissDialog のデータ */
    private val mDismissingTags: MutableList<String> = mutableListOf()

    /**
     * ProgressDialog を dismiss する.
     *
     * @param tag Tag
     */
    fun dismissDialog(tag: String = "progress") {
        if (isResumed) {
            fragmentManager.findFragmentByTag(tag)?.let {
                if (it is AppCompatDialogFragment) {
                    it.dismiss()
                } else {
                    throw UnsupportedOperationException()  // DialogFragment ではない
                }
            }
        } else {
            mDismissingTags += tag  // resume でない場合は pending する
        }
    }

    /**
     * ProgressDialog を表示する.
     *
     * @param message メッセージ
     * @param tag Tag
     * @param cancelable キャンセルできるか否か
     */
    fun showProgressDialog(message: Any? = null, tag: String = "progress", cancelable: Boolean = true) {
        val args = Bundle()
        args.putBoolean("is_progress", true)
        message?.let { args.putString("message", if (it is Int) getString(it) else it.toString()) }
        args.putBoolean("cancelable", cancelable)

        // resume でない場合は pending する
        if (isResumed) {
            with(MyDialogFragment()) {
                arguments = args
                setTargetFragment(this@MyFragment, 0)
                show(this@MyFragment.fragmentManager, tag)
            }
        } else {
            // 復帰させるために requestCode, tag を格納する
            args.putString("tag", tag)
            mDialogData += args
        }
    }

    /**
     * PTDialogFragment を表示する.
     *
     * @param requestCode リスクエストコード
     * @param params Bundle パラメータ
     * @param title タイトル
     * @param message メッセージ
     * @param positive positiveButton ラベル
     * @param negative negativeButton ラベル
     * @param items シングルタップ選択用の items
     * @param multiChoiceItems 複数選択用の items
     * @param multiChoiceItemValues 複数選択用の items (値)
     * @param date DatePicker 用の初期値
     * @param tag Tag
     * @param cancelable キャンセル出来るか否か
     */
    fun showDialog(requestCode: Int = -1, params: Bundle = Bundle(), title: Any? = null,
            message: Any? = null, positive: Any? = null, negative: Any? = null,
            items: Any? = null, multiChoiceItems: Any? = null, multiChoiceItemValues: BooleanArray? = null,
            date: Date? = null, tag: String = "default", cancelable: Boolean = true) {
        fun getStringArray(x: Any): Array<String> = when (x) {
            is Int -> context.resources.getStringArray(x)
            is ArrayList<*> -> x.toArray(arrayOfNulls(0))
            is Array<*> -> x.map(Any?::toString).toTypedArray()
            else -> throw IllegalArgumentException()
        }
        if (this !is MyDialogFragment.Callback) {
            throw UnsupportedOperationException("Using PTDialogFragment must implement Callback to Fragment")
        }

        // 既に detach されている場合は何もしない
        if (isDetached) return

        val args = Bundle()
        title?.let { args.putString("title", if (it is Int) getString(it) else it.toString()) }
        message?.let { args.putString("message", if (it is Int) getString(it) else it.toString()) }
        items?.let { args.putStringArray("items", getStringArray(it)) }
        multiChoiceItems?.let { args.putStringArray("multi_choice_items", getStringArray(it)) }
        multiChoiceItemValues?.let { args.putBooleanArray("multi_choice_item_values", it) }
        date?.let { args.putSerializable("date", it) }
        positive?.let { args.putString("positive", if (it is Int) getString(it) else it.toString()) }
        negative?.let { args.putString("negative", if (it is Int) getString(it) else it.toString()) }
        args.putBoolean("cancelable", cancelable)
        args.putBundle("params", params)

        // resume でない場合は pending する
        if (isResumed) {
            with(MyDialogFragment()) {
                arguments = args
                setTargetFragment(this@MyFragment, requestCode)
                show(this@MyFragment.fragmentManager, tag)
            }
        } else {
            // 復帰させるために requestCode, tag を格納する
            args.putInt("request_code", requestCode)
            args.putString("tag", tag)
            mDialogData += args
        }
    }

    override fun onResume() {
        super.onResume()
        mDialogData.forEach {
            with(MyDialogFragment()) {
                arguments = it
                setTargetFragment(this@MyFragment, it.getInt("request_code"))
                show(this@MyFragment.fragmentManager, it.getString("tag"))
            }
        }
        mDialogData.clear()
        mDismissingTags.forEach { dismissDialog(it) }
        mDismissingTags.clear()
    }
}

こんな MyFragment を用意する。 そうして以下のように MyFragment を継承した Fragment 上で MyDialogFragment を表示してみる:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    savedInstanceState ?: supportFragmentManager.beginTransaction()
            .add(android.R.id.content, AFragment())
            .commit()
}

class AFragment : MyFragment() {
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        
        // 5 秒後表示. 今度はスリープしてもクラッシュせずに復帰時にちゃんと Dialog が表示される!!
        Handler().postDelayed({ showDialog(message = "Hello World") }, 5000)
    }
}

今度はクラッシュしないし、画面復帰時に正しく表示しようとしていた Dialog が表示される。 肝は Dialog を表示しようとする時に isResumed でなかったら tag で表示しようとしている Dialog の情報を保存し onResume() 後に改めて表示するというところだ。 これを共通ダイアログに対して行っておくとこのようなボイラープレートコードをあちこちに仕込まなくて済むようになるので有用である。