引言:权限申请的痛点
在 Android 应用开发中,权限申请是必不可少的环节。如果直接申请权限弹窗,显得有点突兀,用户不了解为何需要此权限,所以通常在申请权限之前会有个说明弹窗,当用户同意之后再去弹系统权限的弹窗。然后这种方案也有缺点,就是每次申请权限都需要2个弹窗:说明弹窗+系统权限弹窗,不过现在主流App的方案都是将这两个弹窗合二为一了,说明弹窗和系统权限弹窗同时展示,比如:
本文就实现一下这种效果。
权限申请流程优化
优化后流程:
用户点击功能 → 展示自定义说明 → 自动触发系统弹窗 → 处理结果
效果图
示例代码
以相机权限为例,在Fragment中申请,核心代码如下:
/**
* 权限申请时,自定义顶部TIPS
*/
class PermissionRequestFragment : BaseFragment() {
private val btnPermission: Button by id(R.id.btn_permission_request)
private val requestCameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
handleCameraPermissionResult(isGranted)
}
override fun getLayoutId(): Int {
return R.layout.layout_permission_request
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
btnPermission.setOnClickListener { requestPermission() }
}
private fun handleCameraPermissionResult(isGranted: Boolean) {
removeTopTipsView(requireActivity())
PermissionUtils.setCameraPermissionRequested(requireActivity(), true)
log("isGranted:$isGranted")
if (isGranted) {
//权限授予成功,打开相机
showToast("权限授予成功,打开相机")
} else {
//权限被拒绝,检查是否永久拒绝
val shouldShowRationale = shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)
if (!shouldShowRationale) {
//用户选择了"不再询问",属于永久拒绝
PermissionUtils.setCameraPermissionRequested(requireActivity(), false)
showGoToSettingsDialog()
} else {
//临时拒绝
showToast("相机权限被拒绝,无法使用拍照功能")
}
}
}
private fun showGoToSettingsDialog() {
AlertDialog.Builder(requireActivity())
.setTitle("相机权限被永久拒绝")
.setMessage("相机权限已被永久拒绝,请到应用设置中手动开启权限。")
.setPositiveButton("去设置") { _, _ ->
openAppSettings()
}
.setNegativeButton("取消", null)
.setCancelable(false)
.show()
}
/**
* 打开应用设置页面
*/
private fun openAppSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = "package:${requireActivity().packageName}".toUri()
startActivity(intent)
}
private fun requestPermission() {
if (hasCameraPermission()) {
showToast("已经有对应权限了")
return
}
//展示tips
if (shouldShowPermissionTips()) {
addTopTipsView(requireActivity())
}
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
/**
* 判断是否应该显示权限提示 tips
* 返回 true 的情况:用户第一次请求权限,或者之前只是临时拒绝
* 返回 false 的情况:
* 1. 已经有权限
* 2. 用户永久拒绝了权限(选择了"不再询问")
* 3. 其他不应该展示的情况
*/
private fun shouldShowPermissionTips(): Boolean {
//如果已经有权限,不需要提示
if (hasCameraPermission()) {
return false
}
//判断用户之前是否请求过权限
val hasBeenRequestedBefore = PermissionUtils.hasCameraPermissionBeenRequested(requireActivity())
if (!hasBeenRequestedBefore) {
return true
}
// 检查是否需要显示权限说明
// 注意:shouldShowRequestPermissionRationale() 在以下情况返回 false:
// - 第一次请求权限
// - 用户永久拒绝(选择了"不再询问")
// - 用户已经授予权限
// 只有在用户临时拒绝时返回 true
val shouldShowRationale = shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)
// 如果shouldShowRationale为true,说明用户之前临时拒绝过,现在再次请求,这种情况下可以展示提示
return shouldShowRationale
}
private fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
}
/**
* 顶部展示权限说明Tips
*/
private fun addTopTipsView(activity: FragmentActivity) {
runCatching {
val decorView = activity.window.decorView
(decorView as? ViewGroup)?.let { viewGroup ->
//检查是否已经存在tips,通过Tag查找
val existingView = viewGroup.findViewWithTag<TextView>(CAMERA_PERMISSION_TIPS_TAG)
if (existingView != null) return@let
val textView = TextView(activity)
textView.run {
tag = CAMERA_PERMISSION_TIPS_TAG
setBackgroundResource(R.drawable.shape_black_bg)
setTextColor(Color.WHITE)
typeface = Typeface.DEFAULT_BOLD
text = "请相机权限:“为了给您提供‘拍照搜题’服务,需要申请使用您的相机权限。我们承诺仅用于此功能,保障您的隐私安全。”"
textSize = 16f
setLineSpacing(2.dp2px().toFloat(), 1f)
val dpLength = 12.dp2px()
setPadding(dpLength, 18.dp2px(), dpLength, 18.dp2px())
val layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
layoutParams.leftMargin = dpLength
layoutParams.rightMargin = dpLength
layoutParams.topMargin = dpLength * 3
decorView.addView(textView, layoutParams)
}
}
}
}
/**
* 删除Tips
*/
private fun removeTopTipsView(activity: FragmentActivity) {
runCatching {
val decorView = activity.window.decorView
(decorView as? ViewGroup)?.let { viewGroup ->
//通过Tag精确查找并移除
val tipsView = viewGroup.findViewWithTag<TextView>(CAMERA_PERMISSION_TIPS_TAG)
if (tipsView != null) {
viewGroup.removeView(tipsView)
}
}
}
}
companion object {
private const val CAMERA_PERMISSION_TIPS_TAG = "camera_permission_tips_tag"
}
}
object PermissionUtils {
private const val PREFS_NAME = "app_permissions_prefs"
private const val KEY_CAMERA_REQUESTED_ONCE = "camera_requested_once"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
// 标记相机权限已经被请求过
fun setCameraPermissionRequested(context: Context, status: Boolean) {
getPrefs(context).edit { putBoolean(KEY_CAMERA_REQUESTED_ONCE, status) }
}
//检查相机权限是否被请求过(用于区分“第一次”和“永久拒绝”)
fun hasCameraPermissionBeenRequested(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CAMERA_REQUESTED_ONCE, false)
}
}
上述代码中的解释已经很详细了,主要是在弹系统弹窗的同时需要展示我们自己的说明弹窗,通过addTopTipsView()展示说明弹窗,其方法内部是通过activity.window.decorView找到页面的根布局,通过addView将说明弹窗展示出来;当权限申请结束时通过removeTopTipsView()删除说明弹窗即可。