工作思考: 利用协程同步代码形式获取弹窗返回结果

671 阅读7分钟

本文说明

Activity或Fragment打开弹窗,用户在弹窗里进行操作后把结果传递给打开的界面,已经是Android业务开发的基操. 本文讨论了其中的几种方式. 就算是工作中思考的一个记录吧.因为对协程了解不深,不确定会不会有什么坑,而且在Android开发全靠JetPack的今天,本篇文章中介绍的方式注定不会成为主流.

一.起因:对旧代码的质疑

前一段时间在维护公司的某个业务时,涉及了时间选择器弹窗的处理,公司有现成的封装库,于是我去看了一下,代码是这样的:

TimePickerBaseView fullTimeView = new TimePickerFullView(context);
if (maxTime != TIME_ERROR) {
    fullTimeView.setEndTime(maxTime);
}

fullTimeView
    .setStartTime(minTime)
    .setSelectedTime(nowTime)
    .setSubmitText("确定")
    .setTitleText("时间选择")
    .setOutSideCancelable(false)
    .setOnSelectChangeListener(new TimePickerBaseView.OnSelectListener() {
    @Override
    public void onTimeSelectCancel(Date date) {
        //onTimeSelectedCallBack(nowTime);
        callBack.cancelDialog();
    }

    @Override
    public void onTimeSelectSubmit(Date data) {
        onTimeSelectedCallBack(data.getTime());
    }

    private void onTimeSelectedCallBack(long time) {
        callBack.selectedTime(timeStamp2Date(time), time, timeStamp2DateSecond(time));
    }
}).onShow();

我承认为了灵活性使用了大量配置是可以接受的,但是这回调是怎么回事? 是否太过麻烦了呢? 回调还要套回调, 快奔着回调地狱去了.

二.思考:Activity、Fragment与Dialog之间的数据通信

1.接口回调

还真让我在Android官方文档中找到了如何在Activity、Fragment与Dialog之间通信的介绍,其也是使用接口回调:

class NoticeDialogFragment : DialogFragment() {
    // Use this instance of the interface to deliver action events
    internal lateinit var listener: NoticeDialogListener

    /* The activity that creates an instance of this dialog fragment must
     * implement this interface in order to receive event callbacks.
     * Each method passes the DialogFragment in case the host needs to query it. */
    interface NoticeDialogListener {
        fun onDialogPositiveClick(dialog: DialogFragment)
        fun onDialogNegativeClick(dialog: DialogFragment)
    }

    // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
    override fun onAttach(context: Context) {
        super.onAttach(context)
        // Verify that the host activity implements the callback interface
        try {
            // Instantiate the NoticeDialogListener so we can send events to the host
            listener = context as NoticeDialogListener
        } catch (e: ClassCastException) {
            // The activity doesn't implement the interface, throw exception
            throw ClassCastException((context.toString() +
                    " must implement NoticeDialogListener"))
        }
    }
}

只不过在AndroidX JetPack大行其道的今天这种方式有些过时了

2.ViewModel数据共享

比起对Activity生命周期无感知的Dialog, 国内设计看不上的AlertDialog, 官方更推荐DialogFragment, 不仅可以感知生命周期还能进行丰富的界面自定义.既然是Fragment,那么就可以使用共享ViewModel实例,把回调数据放在里面,利用LiveData或者协程的ShareFlow进行数据分发.

private val knowModel by activityViewModels<KnowledgeListViewModel>()

vm.isBeginMic.observe(this) {}
mViewModel.viewEvent.collect { event -> }

3.FragmentResult

FragmentResult 相关Api也是利用了接口回调机制,不过这个回调处理机制则交给了FragmentManager管理,数据也保存在FragmentManager,这样的好处是充分的利用了系统机制,避免了我们自定义回调可能引发的内存泄漏.不过利用Bunlde机制存储数据,数据内存不能过大.

4.思考上述方式

上述方式要么有回调地狱、内存泄漏的风险,要么弹窗的启动代码和获取弹窗返回数据的代码不在一起, 增加排查分析代码的成本.那么有没有一种新形式获取弹窗返回值呢?

三.分析 Activity、Fragment与Dialog之间的交互

我们先来分析下Dialog的交互

Screenshot_2022-08-13-17-10-13-46_c9f04cd026fda31f598b95b1ace88787.jpg

Screenshot_2022-08-13-17-10-24-55_e41039de8eaacf222a951c16e0560c66.jpg

Dialog无论长什么样,他都阻碍了用户操作它后面透出的界面,用户必须先对Dialog进行操作才能继续对后面的页面进行处理,也就是后面的页面在等待Dialog处理,而且实际上它自身还是在运行中.这正是一段异步等待操作.

那我们能不能写出一段看似同步执行确是异步等待的弹窗结果回调代码呢?

在这一点上, Flutter框架可以给我们提供思考,作为一个“单线程”语言, Dart支持在同一线程上执行异步代码,比如:


void _handlePayFail() async {
  await showDialog(builder: (BuildContext context) {
      return IDDialog(
        leftButtonClick: () {
          clickDialogButton = 1;
        },
        rightButtonClick: () {
          clickDialogButton = 2;
        },
      );
    },
  );

  if (clickDialogButton == 0) {
    //没有点击弹窗中的按钮,android返回键关闭弹窗
    routerMode = 0;
    orderId = null;
    currentPayResult = null;
  } else if (clickDialogButton == 1) {
    //点击弹窗中的左侧按钮
    clickDialogButton = 0;
    gotoPay();
  } else if (clickDialogButton == 2) {
    //点击弹窗中的右侧按钮
    clickDialogButton = 0;
    _jumpToOrderDetail();
  }
}

_handlePayFail()方法中, 利用await关键字执行了一段异步打开弹窗的代码,弹窗中的点击事件对成员变量进行赋值,showDialog()方法下面紧接着就是对弹窗返回结果的处理,整个过程都在同一方法中处理,没有向上面各个方法中的回调和监听之类的代码.

那么Kotlin是否也可以做到这样呢?

四.利用Kotlin协程:suspendCancellableCoroutine

suspendCancellableCoroutine()函数是我们将旧代码与Kotlin协程进行连接的钥匙, 通过它我们就可以写出一个同步形式的异步代码.这个函数本质上就是创建一个可取消的协程,并将其透传出来以供我们调用,之后一直在暂停查询协程的状态,直到我们返回值:

public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
        /*
         * For non-atomic cancellation we setup parent-child relationship immediately
         * in case when `block` blocks the current thread (e.g. Rx2 with trampoline scheduler), but
         * properly supports cancellation.
         */
        cancellable.initCancellability()
        block(cancellable)
        cancellable.getResult()
    }
@PublishedApi
internal fun getResult(): Any? {
    val isReusable = isReusable()
    // trySuspend may fail either if 'block' has resumed/cancelled a continuation
    // or we got async cancellation from parent.
    if (trySuspend()) {
        /*
         * Invariant: parentHandle is `null` *only* for reusable continuations.
         * We were neither resumed nor cancelled, time to suspend.
         * But first we have to install parent cancellation handle (if we didn't yet),
         * so CC could be properly resumed on parent cancellation.
         *
         * This read has benign data-race with write of 'NonDisposableHandle'
         * in 'detachChildIfNotReusable'.
         */
        if (parentHandle == null) {
            installParentHandle()
        }
        /*
         * Release the continuation after installing the handle (if needed).
         * If we were successful, then do nothing, it's ok to reuse the instance now.
         * Otherwise, dispose the handle by ourselves.
        */
        if (isReusable) {
            releaseClaimedReusableContinuation()
        }
        return COROUTINE_SUSPENDED
    }
}
private fun trySuspend(): Boolean {
    _decision.loop { decision ->
        when (decision) {
            UNDECIDED -> if (this._decision.compareAndSet(UNDECIDED, SUSPENDED)) return true
            RESUMED -> return false
            else -> error("Already suspended")
        }
    }
}

具体原理,这里就不做分析了,网上类似的文章很多. 下面我们就以打开系统时间选择器为例看看如果使用: 1.创建时间选择器的DialogFragment,当然也可以是单纯的Dialog:

class TimePickerDialogFragment : DialogFragment(), TimePickerDialog.OnTimeSetListener {

    fun show(fm: FragmentManager): TimePickerDialogFragment {
        show(fm, "TestDialog")
        return this
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val c = Calendar.getInstance()
        val hour = c.get(Calendar.HOUR_OF_DAY)
        val minute = c.get(Calendar.MINUTE)
        return TimePickerDialog(requireActivity(), this, hour, minute, DateFormat.is24HourFormat(requireActivity()))
    }

    override fun onTimeSet(view: TimePicker?, hourOfDay: Int, minute: Int) {
        Log.d("TimePickerTest", "dialog callback hourOfDay: $hourOfDay, minute: $minute")
    }
}

2.在TimePickerDialogFragment中添加susend方法:getTime():

suspend fun getTime(): String {
    return suspendCancellableCoroutine { continuation ->
        dialogContinuation = continuation
    }
}

同时在TimePickerDialogFragment中定义成员变量dialogContinuation:

private var dialogContinuation: CancellableContinuation<String>? = null

3.返回结果,我们可以在回调方法onTimeSet()调用协程的resume()函数返回结果:

override fun onTimeSet(view: TimePicker?, hourOfDay: Int, minute: Int) {
    dialogContinuation?.resume("hourOfDay: $hourOfDay, minute: $minute")
}

4.如果是点击取消或者点击了弹窗外的区域导致弹窗隐藏,我们也可以做resume()函数返回结果的操作,同时因为弹窗隐藏,我们不在乎需要弹窗和里面的数据,也可以同时将协程置null:

override fun onDismiss(dialog: DialogInterface) {
    super.onDismiss(dialog)
    if (dialogContinuation?.isCompleted != true) {
        //为true则是点击了确定,已经返回结果
        dialogContinuation?.cancel(RuntimeException("time pick is cancel."))
    }
    dialogContinuation = null
}

5.接收结果:我们可以在Activity或Fragment中写下下面的代码完成结果接收:

private fun openTimeDialog() {
    lifecycleScope.launch {
        try {
            val result = TimePickerDialogFragment().show(supportFragmentManager).getTime()
            Log.d("TimePickerTest", "activity get time: $result")
        } catch (e: Exception) {
            e.message?.let { Log.d("TimePickerTest", "activity get e: $it") }
        }
    }
}

这样就完成了.打开弹窗代码和接收弹窗结果代码不仅在同一代码块中,而且更加简洁直观.

如果不想将dialogContinuation声明为一个成员变量,可以类似Retrofit的扩展函数一样,TimePickerDialogFragment中仍然使用接口回调,不过在实现回调的地方传入Continuation, 比如:

suspend fun <T> Call<T>.awaitResponse(): Response<T> {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        continuation.resume(response)
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

另外,因为continuation.cancel()会报异常,因此,在接收结果时,必须加入trycatch代码块,如果不想这样,可以试着在取消时将返回值改为用Kotlin的Result包装:

suspend fun getTime(): Result<String> {
    return suspendCancellableCoroutine { continuation ->
        dialogContinuation = continuation
    }
}
if (dialogContinuation?.isCompleted != true) {
    dialogContinuation?.resume(Result.failure(RuntimeException("call cancel action.")))
}
dialogContinuation = null
dialogContinuation?.resume(Result.success("hourOfDay: $hourOfDay, minute: $minute"))

这样,接收时,就可以直接使用结果:

private fun openTimeDialog() {
    lifecycleScope.launch {
        val result = TimePickerDialogFragment().show(supportFragmentManager).getTime()
        val time = result.getOrElse {
            Log.d("TimePickerTest", "activity get error: ${it.message}")
        }
        Log.d("TimePickerTest", "activity get time: $time")
    }
}

五.结语

怎么样,你觉得文中介绍的方式对于简化代码是否有帮助?针对这种场景, 你还有其他的数据通信方式吗?欢迎讨论.