实现一个 Coroutine 版 DialogFragment

·  阅读 1627
实现一个 Coroutine 版 DialogFragment

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。


Android 对话框有多种实现方法,目前比较推荐的是 DialogFragment,先比较与直接使用 AlertDialog,可以避免屏幕旋转等配置变化造成消失。但是其 API 建立在回调的基础上使用起来并不友好。接入 Coroutine 我们可以对其进行一番改造。

1. 使用 Coroutine 进行改造

自定义 AlertDialogFragment 继承自 DialogFragment 如下

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 的返回值了:

button.setOnClickListener {
    GlobalScope.launch {
        val result = AlertDialogFragment().showAsSuspendable(supportFragmentManager)
        Log.d("AlertDialogFragment", "$result Clicked")
    }
}
复制代码

2. 屏幕旋转时的崩溃

经过测试,发现上述代码存在问题。我们知道 DialogFragment 在屏幕旋转时可以保持不消失,但是此时如果点击 Dialog 的按钮,会出现崩溃:

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 :

//build.gradle
implementation "io.reactivex.rxjava2:rxjava:2.2.8"
复制代码

新的 AlertDialogFragment 代码如下:

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 :

/**
 * 实现 Serializable 接口并增加 serialVersionUID
 */
public final class SerializableSingleSubject<T> extends Single<T> implements SingleObserver<T>, 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 如下:

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 { ... } 的闭包

    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 的版本:

fun showAsSingle(fm: FragmentManager, tag: String? = null): Single<Int> {
    show(fm, tag)
    return subject.hide()
}
复制代码

使用时,由 subscribe() 替代挂起函数的使用。

button.setOnClickListener {
    AlertDialogFragment().showAsSingle(supportFragmentManager).subscribe { result ->
        Log.d("AlertDialogFragment", "$result Clicked")
    }
}
复制代码

欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章