Kotlin Compose短信验证码发送方案及Google Play合规问题咨询
Kotlin Compose 验证码发送与验证方案优化
问题背景
开发Kotlin Compose应用时,需向用户输入的手机号发送4位随机验证码完成归属验证。当前使用SmsManager实现短信发送,但存在两个核心问题:
- Google Play合规风险:多数申请
SEND_SMS权限的应用已被下架,担心后续上架时因权限合规被处理; - 权限请求体验差:当前权限请求仅触发一次,用户拒绝后反复请求,影响体验。
已调研SMS User Consent API和SMS Retriever API,但二者仅用于短信接收相关场景,无法满足发送需求,且不使用Firebase。
当前实现代码
注册页面路由与UI代码
@Composable fun UserFormScreenRoute( navHostController: NavHostController, sharedViewModel: SharedViewModel, viewModel: UserFormViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsState() UserFormScreen( navHostController = navHostController, sharedViewModel = sharedViewModel, state = state, sendMessage = viewModel::sendMessage, requestSmsPermission = viewModel::requestSmsPermission, ) } @Composable fun UserFormScreen( navHostController: NavHostController, sharedViewModel: SharedViewModel, state: UserFormScreenState, sendMessage: (String, String, Int) -> String, requestSmsPermission: (Activity, Int) -> Boolean ) { val context = LocalContext.current val activity = context as Activity val result = requestSmsPermission(activity, 123) if (result == false) { requestSmsPermission(activity, 123) } Scaffold( topBar = { BackPopUp( navController = navHostController, route = RegisterScreen.SignUpHealthyLifeStyleGoalsScreen.route ) }, backgroundColor = Color.Transparent ) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.CenterHorizontally ) { DYTLoginAndContinueButton( modifier = Modifier.fillMaxWidth().padding(16.dp), text = stringResource(id = R.string.devam), navController = navHostController, route = RegisterScreen.SignUpVerificationPhoneCodeScreen.route, enabled = buttonEnabled(state) ) { val smsCode = (1000..9999).random() startSMSRetrieverClient(context) val result = requestSmsPermission(activity, 123) if (result) sendMessage(getFullPhoneNumber(), "asdasd", smsCode) } } } }
注册页面ViewModel代码
@HiltViewModel class UserFormViewModel @Inject constructor() : ViewModel() { fun requestSmsPermission(activity: Activity, requestCode: Int): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return if (activity.checkSelfPermission(Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED) { true } else { activity.requestPermissions(arrayOf(Manifest.permission.SEND_SMS), requestCode) false } } else { true } } fun sendMessage(phoneNumber: String, message: String, smsCode: Int): String { return try { val smsManager: SmsManager = SmsManager.getDefault() smsManager.sendTextMessage(phoneNumber, null, "$smsCode $message", null, null) "success" } catch (e: Exception) { e.localizedMessage ?: "发送失败" } } }
验证码验证页面代码
@Composable fun VerificationPhoneCodeRoute( navHostController: NavHostController, viewModel: VerificationPhoneCodeViewModel = hiltViewModel()) { val state by viewModel.state.collectAsState() VerificationPhoneCodeScreen( navHostController = navHostController, state = state, onChangeOtpValue = viewModel::onChangeOtpValue) } @Composable fun VerificationPhoneCodeScreen( navHostController: NavHostController, state: VerificationPhoneCodeScreenState, onChangeOtpValue: (String) -> Unit ) { Scaffold( topBar = { BackPopUp(navController = navHostController, route = null) }, backgroundColor = Color.Transparent ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top ) { Text( textAlign = TextAlign.Center, modifier = Modifier.padding(10.dp), text = stringResource(id = R.string.telefonuna_gelen_kodu_asagidaki_alana_giriniz), style = MaterialTheme.typography.subtitle1 ) OtpTextField( otpText = state.otpValue, onOtpTextChange = { value, otpInputFilled -> onChangeOtpValue(value) } ) } } }
OTP输入框代码
@Composable fun OtpTextField( modifier: Modifier = Modifier, otpText: String, otpCount: Int = 4, onOtpTextChange: (String, Boolean) -> Unit ) { LaunchedEffect(Unit) { if (otpText.length > otpCount) { throw IllegalArgumentException("Otp text value must not have more than otpCount: $otpCount characters") } } BasicTextField( modifier = modifier, value = TextFieldValue(otpText, selection = TextRange(otpText.length)), onValueChange = { if (it.text.length <= otpCount) { onOtpTextChange.invoke(it.text, it.text.length == otpCount) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), decorationBox = { Row( modifier = Modifier.fillMaxWidth().padding(15.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { repeat(otpCount) { index -> CharView( index = index, text = otpText ) Spacer(modifier = Modifier.width(8.dp)) } } } ) } @Composable private fun CharView( index: Int, text: String ) { val isFocused = text.length == index val char = when { index == text.length -> "0" index > text.length -> "" else -> text[index].toString() } Text( modifier = Modifier .size(50.dp, 60.dp) .border( 2.dp, when { isFocused -> MaterialTheme.colors.DYTThemeColor else -> MaterialTheme.colors.grayColor }, RoundedCornerShape(8.dp) ) .padding(2.dp) .offset(y = 15.dp), text = char, style = MaterialTheme.typography.h4, color = MaterialTheme.colors.DYTThemeColor, textAlign = TextAlign.Center ) }
优化方案
1. 彻底解决Google Play合规问题:改用服务端发送验证码
SEND_SMS属于Google Play的高风险权限,仅允许短信类核心功能的应用申请(如短信群发、短信备份工具),普通应用申请该权限几乎无法通过审核,甚至会被下架。最优解决方案是:
- 客户端将用户输入的手机号发送到自己的后端服务;
- 后端调用第三方短信服务商API(如阿里云短信、腾讯云短信等)发送验证码;
- 客户端仅负责输入手机号、接收短信(可配合SMS Retriever API自动填充)、输入验证码并提交验证。
这种方式完全不需要申请SEND_SMS权限,从根源上解决合规问题,同时也避免了客户端发送短信的各种限制(如运营商拦截、设备权限限制)。
2. 权限请求逻辑优化(若坚持客户端发送)
如果因特殊需求必须在客户端发送短信,需优化权限请求逻辑:
(1)使用Activity Result API替代旧权限请求方式
Compose中推荐使用rememberLauncherForActivityResult处理权限请求,避免直接操作Activity:
@Composable fun UserFormScreen(...) { val context = LocalContext.current val smsPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { isGranted -> if (isGranted) { // 权限已授予,执行发送短信逻辑 val smsCode = (1000..9999).random() sendMessage(getFullPhoneNumber(), "asdasd", smsCode) } else { // 权限被拒绝,显示弹窗引导用户去设置开启 AlertDialog( onDismissRequest = {}, title = { Text("需要短信权限") }, text = { Text("发送验证码需要短信权限,请在设置中开启") }, confirmButton = { TextButton(onClick = { // 跳转到应用设置页面 val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) } context.startActivity(intent) }) { Text("去设置") } }, dismissButton = { TextButton(onClick = {}) { Text("取消") } } ) } } ) DYTLoginAndContinueButton(...) { when { ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED -> { // 权限已授予,直接发送 val smsCode = (1000..9999).random() sendMessage(getFullPhoneNumber(), "asdasd", smsCode) } shouldShowRequestPermissionRationale(context as Activity, Manifest.permission.SEND_SMS) -> { // 用户之前拒绝过权限,显示权限说明弹窗 AlertDialog( onDismissRequest = {}, title = { Text("需要短信权限") }, text = { Text("我们需要短信权限来发送验证码,验证您的手机号") }, confirmButton = { TextButton(onClick = { smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) }) { Text("同意") } } ) } else -> { // 首次请求权限 smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) } } } }
(2)优化权限请求时机
不要在页面加载时就请求权限,应该在用户点击“发送验证码”按钮时再触发,符合用户操作预期,减少不必要的权限干扰。
(3)处理权限拒绝后的引导
当用户拒绝权限后,不要反复弹出请求框,而是显示清晰的说明,告知用户权限的用途,并引导用户到设置页面手动开启。
3. 验证码验证逻辑补充
当前验证码验证页面仅接收用户输入,需补充:
- 将用户输入的验证码与后端(或客户端生成后存储的验证码)进行比对;
- 添加验证按钮,点击后执行验证逻辑,验证通过后跳转至下一页面;
- 处理验证码过期逻辑(如设置5分钟有效期)。
内容的提问来源于stack exchange,提问作者NewPartizal
相关产品推荐
相关产品推荐

