有时候我们想要同步的等待Dialog返回结果,而不是通过异步回调,这在前端和Flutter中都是很容易实现的事情。在Android中并没有提供此类功能的默认实现,但通过kotlin和协程实现一个还是不难的。

官方的 SnackbarHost 组件也是可以同步等待返回结果的:

class SnackbarHostState {
    private val mutex = Mutex()

    var currentSnackbarData by mutableStateOf<SnackbarData?>(null)
        private set
    
    suspend fun showSnackbar(
        message: String,
        actionLabel: String? = null,
        duration: SnackbarDuration = SnackbarDuration.Short
    ): SnackbarResult = mutex.withLock {
        try {
            return suspendCancellableCoroutine { continuation ->
                currentSnackbarData = SnackbarDataImpl(message, actionLabel, duration, continuation)
            }
        } finally {
            currentSnackbarData = null
        }
    }
}

所以我们可以参照 SnackbarHost 实现一个同步的Dialog。

由于 Compose Dialog 使用 Disposeable 实现了Dialog的自动隐藏,并且使用局部的协程环境启动的协程也是会自动取消,使得 Compose 中实现同步Dialog 相对简单。

Compose中的同步Dialog

在这里演示一个简单的 ConfrimDialog,包含标题、内容和一个确认按钮。可以用于用户确认弹框,比如询问是否同意协议、App更新、权限申请询问等。

首先我们需要定义一个 DialogState,用来封装Dialog中需要用到的数据(例如内容,标题等),并提显示和隐藏Dialog的方法。

class ConfirmDialogState {

    var controller: ConfirmDialogController? by mutableStateOf(null)
        private set

    suspend fun show(title: String, content: String): Boolean {
        try {
            return suspendCancellableCoroutine { continuation ->
                controller = ConfirmDialogController(
                    title = title,
                    content = content,
                    continuation = continuation,
                )
            }
        } finally {
            controller = null
        }
    }
}
class ConfirmDialogController(
    val title: String,
    val content: String,
    private val continuation: CancellableContinuation<Boolean>,
) {
    fun confirm() {
        if (continuation.isActive) {
            continuation.resume(true)
        }
    }

    fun cancel() {
        if (continuation.isActive) {
            continuation.resume(false)
        }
    }
}

然后创建一个 ConfirmDialog 使用这个 ConfrimDialogState,通过判断 controller 是否为 null 来控制dialog的显示和隐藏。

@Composable
fun ConfirmDialog(
    state: ConfirmDialogState
) {
    state.controller?.let { controller ->
        AlertDialog(
            modifier = Modifier.fillMaxWidth(0.85f),
            onDismissRequest = controller::cancel,
            properties = DialogProperties(
                // compose dialog bug,不设置为false的话连续弹出的弹框大小不变
                usePlatformDefaultWidth = false,
            ),
            title = { Text(text = controller.title) },
            text = { Text(text = controller.content) },
            confirmButton = {
                Button(onClick = controller::confirm) {
                    Text(text = "Confirm")
                }
            },
            dismissButton = {
                Button(onClick = controller::cancel) {
                    Text(text = "Cancel")
                }
            }
        )
    }
}

最后,我们通过一个双确认弹框的案例来说明。用户同意两次弹框后,我们就打开外部浏览器。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                MainScreen(
                    openBrowser = {
                        val intent = Intent(Intent.ACTION_VIEW)
                            .setData("https://google.com".toUri())
                            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                        startActivity(intent)
                    }
                )
            }
        }
    }
}

@Composable
private fun MainScreen(openBrowser: () -> Unit) {
    val coroutineScope = rememberCoroutineScope()
    val confirmDialogState = remember { ConfirmDialogState() }

    ConfirmDialog(state = confirmDialogState)

    Scaffold { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .padding(16.dp)
        ) {
            Button(
                onClick = {
                    coroutineScope.launch {
                        if (
                            confirmDialogState.show(
                                title = "Open Browser",
                                content = "Do you want to open browser?"
                            )
                            &&
                            confirmDialogState.show(
                                title = "Open Browser",
                                content = "Do you really want to open browser?"
                            )
                        ) {
                            openBrowser()
                        }
                    }
                },
                modifier = Modifier.fillMaxWidth(),
            ) {
                Text(text = "Open browser")
            }
        }
    }
}

另外需要注意的是协程作用域的选择最好是当前组件的 rememberCoroutineScope(),以防止窗体泄漏或内存泄漏。因为它的生命周期跟随组件,组件销毁时协程会自动取消,并让我们dialog自动隐藏。

下面是视频示例:

原生View中的同步Dialog

当然,原生Views版本的同步Dialog也很简单。

class ViewsActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_views)
        val openBtn = findViewById<Button>(R.id.open_btn)
        openBtn.setOnClickListener {
            lifecycleScope.launch {
                if (showConfirmDialog(
                        context = this@ViewsActivity,
                        title = "Open Browser",
                        content = "Do you want to open browser?"
                    )
                    &&
                    showConfirmDialog(
                        context = this@ViewsActivity,
                        title = "Open Browser",
                        content = "Do you really want to open browser?"
                    )
                ) {
                    openBrowser()
                } else {
                    finish()
                }
            }
        }
    }

    private fun openBrowser() {
        try {
            val intent = Intent(Intent.ACTION_VIEW)
                .setData("https://google.com".toUri())
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            startActivity(intent)
        } catch (e: ActivityNotFoundException) {
            e.printStackTrace()
        }
    }
}


private suspend fun showConfirmDialog(
    context: Context,
    title: String,
    content: String,
): Boolean {
    return suspendCancellableCoroutine { continuation ->
        AlertDialog.Builder(context)
            .setTitle(title)
            .setMessage(content)
            .setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() }
            .setPositiveButton("Confirm") { _, _ ->
                if (continuation.isActive) {
                    continuation.resume(true)
                }
            }
            .setOnCancelListener {
                if (continuation.isActive) {
                    continuation.resume(false)
                }
            }
            .show()
    }
}

如果没有协程和kotlin,也有使用handler版本实现的方式,可以参考这个: https://news.ebscer.com/2016/04/synchronous-alertdialogs-in-android/

Ps: 很多异步回调的API都可以使用协程转为同步调用,但某些场景需要考虑超时、异常处理等。