實現一個 Coroutine 版 DialogFragment

語言: CN / TW / HK

highlight: androidstudio theme: hydrogen


本文已參與「掘力星計劃」,贏取創作大禮包,挑戰創作激勵金。


Android 對話方塊有多種實現方法,目前比較推薦的是 DialogFragment,先比較與直接使用 AlertDialog,可以避免螢幕旋轉等配置變化造成消失。但是其 API 建立在回撥的基礎上使用起來並不友好。接入 Coroutine 我們可以對其進行一番改造。

1. 使用 Coroutine 進行改造

自定義 AlertDialogFragment 繼承自 DialogFragment 如下

```kotlin class AlertDialogFragment : DialogFragment() {

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

    val listener = DialogInterface.OnClickListener { _: DialogInterface, which: Int ->
        _cont.resume(which)
    }
    return AlertDialog.Builder(context)
        .setTitle("Title")
        .setMessage("Message")
        .setPositiveButton("Ok", listener)
        .setNegativeButton("Cancel", listener)
        .create()
}

private lateinit var _cont : Continuation<Int>
suspend fun showAsSuspendable(fm: FragmentManager, tag: String? = null) = suspendCoroutine<Int> { cont ->
    show(fm, tag)
    _cont = cont
}

} ```

實現很簡單,我們是使用 suspendCoroutine 將原本基於 listener 的回撥轉化為掛起函式。接下來我們可以用同步的方式獲取 dialog 的返回值了: kotlin button.setOnClickListener { GlobalScope.launch { val result = AlertDialogFragment().showAsSuspendable(supportFragmentManager) Log.d("AlertDialogFragment", "$result Clicked") } }


2. 螢幕旋轉時的崩潰

經過測試,發現上述程式碼存在問題。我們知道 DialogFragment 在螢幕旋轉時可以保持不消失,但是此時如果點選 Dialog 的按鈕,會出現崩潰:

log kotlin.UninitializedPropertyAccessException: lateinit property _cont has not been initialized 如果瞭解 Fragment 和 Activity 銷燬重建的過程就能輕鬆推理出發生問題的原因:

  1. 旋轉螢幕時,Activity 將會重新建立。
  2. Activity 臨終前會在 onSaveInstanceState() 中儲存 DialogFragment 的狀態 FragmentManagerState;
  3. 重建後的 Activity,在 onCreate() 中根據 savedInstanceState 所給予的 FragmentManagerState 自動重建 DialogFragment 並且 show() 出來

總結起來流程如下:

旋轉螢幕 --> Activity.onSaveInstanceState() --> Activity.onCreate() --> DialogFragment.show()

重建後的 FragmentDialog 其成員變數 _cont 尚未初始化,此時對其訪問自然發生 crash。

那麼如果不使用 lateinit 就沒問題了呢? 我們嘗試引入 RxJava 對其進行改造


3. 二次改造: RxJava + Coroutine

通過 RxJava 的 Subject 避免了 lateinit 的出現,防止 crash :

groovy //build.gradle implementation "io.reactivex.rxjava2:rxjava:2.2.8" 新的 AlertDialogFragment 程式碼如下:

```kotlin class AlertDialogFragment : DialogFragment() {

private val subject = SingleSubject.create<Int>()

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
    val listener = DialogInterface.OnClickListener { _: DialogInterface, which: Int ->
        subject.onSuccess(which)
    }

    return AlertDialog.Builder(requireContext())
        .setTitle("Title")
        .setMessage("Message")
        .setPositiveButton("Ok", listener)
        .setNegativeButton("Cancel", listener)
        .create()
}

suspend fun showAsSuspendable(fm: FragmentManager, tag: String? = null) = suspendCoroutine<Int> { cont ->
    show(fm, tag)
    subject.subscribe { it -> cont.resume(it) }
}

} ``` 顯示 dialog 時,通過訂閱 SingleSubject 響應 listener 的回撥。

經過修改,旋轉屏幕後點選 Dialog 按鈕時沒有再發生 crash 的現象,但是仍然存在問題:螢幕旋轉後我們無法接收到 Dialog 的返回值,即沒有按預期的那樣顯示下面的日誌 Log.d("AlertDialogFragment", "$result Clicked")

當 DialogFragment 重建後, Subject 也跟隨重建,但是丟失了之前的 Subscriber ,所以點選按鈕後,Rx 的下游無法響應。

有沒有辦法讓 Subject 重建時能夠恢復之前的 Subscriber 呢? 此時想到了藉助 onSaveInstanceState

想要 subject 作為 Fragment 的 arguments 儲存到 savedInstanceState,必須是一個 Serializable 或者 Parcelable


4. 三次改造: SerializableSingleSubject

令人高興的是,查閱 SingleSubject 原始碼後發現其成員變數全是 Serializable 的子類,也就是隻要 SingleSubject 實現 Serializable 介面就可以存入 savedInstanceState 了, 但可惜它不是,而且它是一個 final 類,只好拷貝原始碼出來,自己實現一個 SerializableSingleSubject :

```java /* * 實現 Serializable 介面並增加 serialVersionUID / public final class SerializableSingleSubject extends Single implements SingleObserver, Serializable { private static final long serialVersionUID = 1L;

final AtomicReference<SerializableSingleSubject.SingleDisposable<T>[]> observers;

@SuppressWarnings("rawtypes")
static final SerializableSingleSubject.SingleDisposable[] EMPTY = new SerializableSingleSubject.SingleDisposable[0];

@SuppressWarnings("rawtypes")
static final SerializableSingleSubject.SingleDisposable[] TERMINATED = new SerializableSingleSubject.SingleDisposable[0];

final AtomicBoolean once;
T value;
Throwable error;

// 以下程式碼同 SingleSubject,省略

```

基於 SerializableSingleSubject 重寫 AlertDialogFragment 如下:

```kotlin class AlertDialogFragment : DialogFragment() {

private var subject = SerializableSingleSubject.create<Int>()

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    savedInstanceState?.let {
        subject = it["subject"] as SerializableSingleSubject<Int>
    }

}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
    val listener = DialogInterface.OnClickListener { _: DialogInterface, which: Int ->
        subject.onSuccess(which)
    }

    return AlertDialog.Builder(requireContext())
        .setTitle("Title")
        .setMessage("Message")
        .setPositiveButton("Ok", listener)
        .setNegativeButton("Cancel", listener)
        .create()
}


override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putSerializable("subject", subject);

}

suspend fun showAsSuspendable(fm: FragmentManager, tag: String? = null) = suspendCoroutine<Int> { cont ->
    show(fm, tag)
    subject.subscribe { it -> cont.resume(it) }
}

} ```

重建後通過 savedInstanceState 恢復之前的 Subscriber ,下游順利收到訊息,日誌正常輸出。

需要注意的是,此時仍然存在隱患:螢幕旋轉後,點選 dialog 雖然可以正常獲得返回值,但是此時協程恢復的上下文是前一次 launch { ... } 的閉包

kotlin GlobalScope.launch { val frag = AlertDialogFragment() val result = frag.showAsSuspendable(supportFragmentManager) Log.d("AlertDialogFragment", "$result Clicked on $frag") } 如上,此時列印的 frag 是重建之前的 DialogFragment,如果 launch{...} 裡引用了外部 Activity(獲取成員) ,那也是舊的 Activity,此處需要特別注意避免類似操作。


5. 純 RxJava 方式

既然引入了 RxJava,最後捎帶介紹一下不使用 Coroutine 只依靠 RxJava 的版本: kotlin fun showAsSingle(fm: FragmentManager, tag: String? = null): Single<Int> { show(fm, tag) return subject.hide() } 使用時,由 subscribe() 替代掛起函式的使用。 kotlin button.setOnClickListener { AlertDialogFragment().showAsSingle(supportFragmentManager).subscribe { result -> Log.d("AlertDialogFragment", "$result Clicked") } }


歡迎在評論區討論,掘金官方將在掘力星計劃活動結束後,在評論區抽送100份掘金周邊,抽獎詳情見活動文章